v2: In-app support chat with Telegram notifications (REVIEW — not deployed)

Backend:
- New models: SupportThread, SupportMessage (models/support.py)
- Support router: user thread/messages, admin threads/reply, Telegram outbound
- Context auto-attached: active program, points, gate status, last workout
- Rate limited: 10 messages/user/day
- Admin auth: username == 'scot' check
- Telegram: fire-and-forget sendMessage via @AureusGoldBot, never blocks
- config.py: added telegram_bot_token, telegram_chat_id

Frontend:
- Support.jsx: chat-style thread, send messages, confirmation, auto-scroll
- SupportAdmin.jsx: thread list with unread badges, thread detail with context sidebar, reply
- App.jsx: floating '?' help button with unread badge (polls every 60s), routes
- api.js: support endpoints (user + admin)

Config:
- docker-compose.yml: TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID env vars

NOT DEPLOYED — requires Telegram env vars in Coolify
This commit is contained in:
claude 2026-04-06 04:44:29 +00:00
parent 42632b1b50
commit 10f13a9f62
10 changed files with 988 additions and 4 deletions

View file

@ -1,4 +1,4 @@
import { Routes, Route, Navigate, NavLink, useNavigate } from 'react-router-dom'
import { Routes, Route, Navigate, NavLink, useNavigate, useLocation } from 'react-router-dom'
import { useState, useEffect } from 'react'
import { hasToken, clearToken, api } from './api'
import Login from './pages/Login'
@ -11,12 +11,65 @@ import Onboarding from './pages/Onboarding'
import WeekView from './pages/WeekView'
import ProgramSearch from './pages/ProgramSearch'
import ProgramDetail from './pages/ProgramDetail'
import Support from './pages/Support'
import SupportAdmin from './pages/SupportAdmin'
function ProtectedRoute({ children }) {
if (!hasToken()) return <Navigate to="/login" />
return children
}
function HelpButton() {
const [unread, setUnread] = useState(0)
const navigate = useNavigate()
const location = useLocation()
// Don't show on login/onboarding/support pages
const hidden = ['/login', '/onboarding', '/support'].some(p => location.pathname.startsWith(p))
if (hidden) return null
useEffect(() => {
if (!hasToken()) return
api.getUnreadCount()
.then(d => setUnread(d.unread || 0))
.catch(() => {})
// Poll every 60s for new replies
const interval = setInterval(() => {
api.getUnreadCount()
.then(d => setUnread(d.unread || 0))
.catch(() => {})
}, 60000)
return () => clearInterval(interval)
}, [location.pathname])
return (
<button
onClick={() => navigate('/support')}
style={{
position: 'fixed', top: '12px', right: '12px', zIndex: 90,
width: '40px', height: '40px', borderRadius: '50%',
background: 'var(--card)', boxShadow: 'var(--shadow-2)',
border: '1px solid var(--divider)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: '1.1rem', padding: 0, cursor: 'pointer',
}}
>
?
{unread > 0 && (
<span style={{
position: 'absolute', top: '-4px', right: '-4px',
background: 'var(--red)', color: '#fff', fontSize: '0.6rem', fontWeight: 800,
width: '18px', height: '18px', borderRadius: '50%',
display: 'flex', alignItems: 'center', justifyContent: 'center',
border: '2px solid var(--bg)',
}}>
{unread}
</span>
)}
</button>
)
}
function NavBar() {
return (
<nav className="nav-bar">
@ -42,6 +95,7 @@ function NavBar() {
export default function App() {
return (
<>
{hasToken() && <HelpButton />}
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/onboarding" element={<ProtectedRoute><Onboarding /></ProtectedRoute>} />
@ -53,6 +107,9 @@ export default function App() {
<Route path="/rewards" element={<ProtectedRoute><Rewards /></ProtectedRoute>} />
<Route path="/week" element={<ProtectedRoute><WeekView /></ProtectedRoute>} />
<Route path="/settings" element={<ProtectedRoute><Settings /></ProtectedRoute>} />
<Route path="/support" element={<ProtectedRoute><Support /></ProtectedRoute>} />
<Route path="/support/admin" element={<ProtectedRoute><SupportAdmin /></ProtectedRoute>} />
<Route path="/support/admin/:threadId" element={<ProtectedRoute><SupportAdmin /></ProtectedRoute>} />
</Routes>
{hasToken() && <NavBar />}
</>

View file

@ -64,12 +64,12 @@ export const api = {
createReward: (data) => request('/rewards', { method: 'POST', body: JSON.stringify(data) }),
redeemReward: (id, date) => request(`/rewards/${id}/redeem`, { method: 'POST', body: JSON.stringify({ date }) }),
// Programs (v1 — existing)
// Programs (v1)
listPrograms: () => request('/programs'),
createProgram: (data) => request('/programs', { method: 'POST', body: JSON.stringify(data) }),
getProgram: (id) => request(`/programs/${id}`),
// Program Catalog (v2 — new)
// Program Catalog (v2)
searchCatalog: (q) => request(`/programs/catalog${q ? `?q=${encodeURIComponent(q)}` : ''}`),
getCatalogProgram: (id) => request(`/programs/catalog/${id}`),
researchProgram: (name) =>
@ -79,7 +79,7 @@ export const api = {
method: 'POST', body: JSON.stringify({ start_date: startDate }),
}),
// Program Tracking (v2 — new)
// Program Tracking (v2)
getProgramSchedule: (id) => request(`/programs/${id}/schedule`),
getWorkoutDetail: (progId, workoutId) => request(`/programs/${progId}/workout/${workoutId}`),
completeWorkout: (progId, workoutId, data) =>
@ -88,6 +88,20 @@ export const api = {
}),
getProgramProgress: (id) => request(`/programs/${id}/progress`),
// Support (user)
getThread: () => request('/support/thread'),
sendSupportMessage: (body) =>
request('/support/messages', { method: 'POST', body: JSON.stringify({ body }) }),
getUnreadCount: () => request('/support/unread'),
// Support (admin)
listSupportThreads: () => request('/support/admin/threads'),
getAdminThread: (threadId) => request(`/support/admin/threads/${threadId}`),
replySupportThread: (threadId, body) =>
request(`/support/admin/threads/${threadId}/reply`, {
method: 'POST', body: JSON.stringify({ body }),
}),
// Integrations
integrationStatus: () => request('/integrations'),
stravaAuth: () => request('/integrations/strava/auth'),

View file

@ -0,0 +1,162 @@
import { useState, useEffect, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { api } from '../api'
export default function Support() {
const [messages, setMessages] = useState([])
const [loading, setLoading] = useState(true)
const [sending, setSending] = useState(false)
const [input, setInput] = useState('')
const [confirmation, setConfirmation] = useState(null)
const bottomRef = useRef(null)
const navigate = useNavigate()
useEffect(() => { loadThread() }, [])
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
async function loadThread() {
try {
const data = await api.getThread()
setMessages(data.messages || [])
} catch (err) { console.error(err) }
finally { setLoading(false) }
}
async function handleSend(e) {
e.preventDefault()
const body = input.trim()
if (!body || sending) return
setSending(true)
setConfirmation(null)
try {
const result = await api.sendSupportMessage(body)
setInput('')
setConfirmation(result.message)
await loadThread()
} catch (err) {
setConfirmation(err.message)
}
finally { setSending(false) }
}
function formatTime(iso) {
const d = new Date(iso)
const now = new Date()
const diff = now - d
if (diff < 60000) return 'Just now'
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
+ ' ' + d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
}
if (loading) return <div style={{ padding: '2rem', textAlign: 'center', color: 'var(--text-3)' }}>Loading...</div>
return (
<div style={{ display: 'flex', flexDirection: 'column', height: 'calc(100dvh - 64px)', maxWidth: '600px', margin: '0 auto' }}>
{/* Header */}
<div style={{
padding: '16px', borderBottom: '1px solid var(--divider)',
display: 'flex', alignItems: 'center', gap: '12px',
}}>
<button onClick={() => navigate('/')} style={{
background: 'transparent', color: 'var(--text-3)', padding: '4px 0', fontSize: '1.2rem',
}}></button>
<div>
<h1 style={{ fontFamily: 'var(--font-display)', fontSize: '1.2rem', fontWeight: 800, margin: 0 }}>
Support
</h1>
<p style={{ color: 'var(--text-3)', fontSize: '0.75rem', margin: 0 }}>
Ask a question we'll get back to you
</p>
</div>
</div>
{/* Messages */}
<div style={{ flex: 1, overflow: 'auto', padding: '16px', display: 'flex', flexDirection: 'column', gap: '12px' }}>
{messages.length === 0 && (
<div style={{ textAlign: 'center', color: 'var(--text-3)', fontSize: '0.85rem', marginTop: '2rem' }}>
<div style={{ fontSize: '2rem', marginBottom: '8px' }}>💬</div>
No messages yet. Ask anything about your workouts, program, or the app.
</div>
)}
{messages.map(m => (
<div key={m.id} style={{
display: 'flex',
justifyContent: m.sender === 'user' ? 'flex-end' : 'flex-start',
}}>
<div style={{
maxWidth: '80%',
padding: '10px 14px',
borderRadius: m.sender === 'user'
? 'var(--r) var(--r) 4px var(--r)'
: 'var(--r) var(--r) var(--r) 4px',
background: m.sender === 'user' ? 'var(--orange)' : 'var(--card)',
color: m.sender === 'user' ? '#fff' : 'var(--text)',
boxShadow: 'var(--shadow-1)',
fontSize: '0.88rem',
lineHeight: 1.5,
}}>
<div>{m.body}</div>
<div style={{
fontSize: '0.68rem', marginTop: '4px',
opacity: 0.7, textAlign: 'right',
}}>
{formatTime(m.created_at)}
{m.sender === 'user' && m.read_at && ' ✓'}
</div>
</div>
</div>
))}
{confirmation && (
<div style={{
textAlign: 'center', fontSize: '0.8rem', color: 'var(--green-dark)',
padding: '8px', background: 'var(--green-ghost)', borderRadius: 'var(--r-sm)',
}}>
{confirmation}
</div>
)}
<div ref={bottomRef} />
</div>
{/* Input */}
<form onSubmit={handleSend} style={{
padding: '12px 16px', borderTop: '1px solid var(--divider)',
display: 'flex', gap: '8px', background: 'var(--card)',
}}>
<input
type="text"
value={input}
onChange={e => setInput(e.target.value)}
placeholder="Type your question..."
maxLength={2000}
style={{
flex: 1, padding: '12px 14px', borderRadius: 'var(--r-full)',
border: '1px solid var(--divider)', fontFamily: 'var(--font)',
fontSize: '0.88rem', background: 'var(--bg)',
}}
/>
<button
type="submit"
disabled={!input.trim() || sending}
style={{
background: sending ? 'var(--text-3)' : 'var(--orange)',
color: '#fff', borderRadius: 'var(--r-full)',
width: '44px', height: '44px', padding: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: '1.1rem', boxShadow: 'var(--shadow-orange)',
opacity: !input.trim() ? 0.5 : 1,
}}
>
</button>
</form>
</div>
)
}

View file

@ -0,0 +1,255 @@
import { useState, useEffect, useRef } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { api } from '../api'
export default function SupportAdmin() {
const { threadId } = useParams()
// If threadId is present, show the thread detail; otherwise show thread list
if (threadId) return <AdminThread threadId={threadId} />
return <AdminThreadList />
}
function AdminThreadList() {
const [threads, setThreads] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const navigate = useNavigate()
useEffect(() => { loadThreads() }, [])
async function loadThreads() {
try {
const data = await api.listSupportThreads()
setThreads(data)
} catch (err) {
setError(err.message)
}
finally { setLoading(false) }
}
if (loading) return <div style={{ padding: '2rem', textAlign: 'center', color: 'var(--text-3)' }}>Loading...</div>
if (error) return <div style={{ padding: '2rem', textAlign: 'center', color: 'var(--red)' }}>{error}</div>
return (
<div style={{ padding: '1rem', maxWidth: '600px', margin: '0 auto' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '1.5rem' }}>
<button onClick={() => navigate('/')} style={{
background: 'transparent', color: 'var(--text-3)', padding: '4px 0', fontSize: '1.2rem',
}}></button>
<h1 style={{ fontFamily: 'var(--font-display)', fontSize: '1.4rem', fontWeight: 900, margin: 0 }}>
Support Inbox
</h1>
</div>
{threads.length === 0 && (
<p style={{ textAlign: 'center', color: 'var(--text-3)', fontSize: '0.85rem', marginTop: '3rem' }}>
No support conversations yet.
</p>
)}
{threads.map(t => (
<div
key={t.id}
onClick={() => navigate(`/support/admin/${t.id}`)}
style={{
background: 'var(--card)', borderRadius: 'var(--r)', padding: '14px 16px',
marginBottom: '0.6rem', boxShadow: 'var(--shadow-1)', cursor: 'pointer',
border: t.unread_admin > 0 ? '2px solid var(--orange-glow)' : '1px solid var(--card-border)',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<strong style={{ fontFamily: 'var(--font-display)', fontSize: '0.95rem' }}>
{t.user_name}
</strong>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
{t.unread_admin > 0 && (
<span style={{
background: 'var(--orange)', color: '#fff', fontSize: '0.65rem', fontWeight: 800,
width: '20px', height: '20px', borderRadius: '50%',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>{t.unread_admin}</span>
)}
<span style={{ fontSize: '0.72rem', color: 'var(--text-3)' }}>
{t.message_count} msgs
</span>
</div>
</div>
{t.last_message && (
<p style={{
color: 'var(--text-2)', fontSize: '0.82rem', marginTop: '4px',
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
}}>
<span style={{ color: 'var(--text-3)', fontWeight: 600 }}>
{t.last_message.sender === 'admin' ? 'You: ' : ''}
</span>
{t.last_message.body}
</p>
)}
</div>
))}
</div>
)
}
function AdminThread({ threadId }) {
const [thread, setThread] = useState(null)
const [loading, setLoading] = useState(true)
const [sending, setSending] = useState(false)
const [input, setInput] = useState('')
const bottomRef = useRef(null)
const navigate = useNavigate()
useEffect(() => { loadThread() }, [threadId])
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [thread])
async function loadThread() {
try {
const data = await api.getAdminThread(threadId)
setThread(data)
} catch (err) { console.error(err) }
finally { setLoading(false) }
}
async function handleReply(e) {
e.preventDefault()
const body = input.trim()
if (!body || sending) return
setSending(true)
try {
await api.replySupportThread(threadId, body)
setInput('')
await loadThread()
} catch (err) { alert(err.message) }
finally { setSending(false) }
}
function formatTime(iso) {
const d = new Date(iso)
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
+ ' ' + d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
}
if (loading) return <div style={{ padding: '2rem', textAlign: 'center', color: 'var(--text-3)' }}>Loading...</div>
if (!thread) return <div style={{ padding: '2rem', textAlign: 'center' }}>Thread not found</div>
const ctx = thread.latest_context || {}
return (
<div style={{ display: 'flex', flexDirection: 'column', height: 'calc(100dvh - 64px)', maxWidth: '600px', margin: '0 auto' }}>
{/* Header */}
<div style={{
padding: '14px 16px', borderBottom: '1px solid var(--divider)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<button onClick={() => navigate('/support/admin')} style={{
background: 'transparent', color: 'var(--text-3)', padding: '4px 0', fontSize: '1.2rem',
}}></button>
<h2 style={{ fontFamily: 'var(--font-display)', fontSize: '1.1rem', fontWeight: 800, margin: 0 }}>
{thread.user_name}
</h2>
</div>
{/* Context bar */}
{ctx.program_name && (
<div style={{
display: 'flex', gap: '0.5rem', flexWrap: 'wrap', marginTop: '8px', marginLeft: '36px',
}}>
<span style={ctxTag}>{ctx.program_name}</span>
{ctx.program_day && (
<span style={ctxTag}>Day {ctx.program_day}/{ctx.program_total_days}</span>
)}
{ctx.program_completion_pct !== undefined && (
<span style={ctxTag}>{ctx.program_completion_pct}%</span>
)}
<span style={{
...ctxTag,
background: ctx.gate_passed ? 'var(--green-ghost)' : 'var(--red-ghost)',
color: ctx.gate_passed ? 'var(--green-dark)' : 'var(--red)',
}}>
{ctx.points_today}/{ctx.daily_target} pts {ctx.gate_passed ? '✓' : '✗'}
</span>
{ctx.last_workout && (
<span style={ctxTag}>Last: {ctx.last_workout}</span>
)}
</div>
)}
</div>
{/* Messages */}
<div style={{ flex: 1, overflow: 'auto', padding: '16px', display: 'flex', flexDirection: 'column', gap: '12px' }}>
{(thread.messages || []).map(m => (
<div key={m.id} style={{
display: 'flex',
justifyContent: m.sender === 'admin' ? 'flex-end' : 'flex-start',
}}>
<div style={{
maxWidth: '80%',
padding: '10px 14px',
borderRadius: m.sender === 'admin'
? 'var(--r) var(--r) 4px var(--r)'
: 'var(--r) var(--r) var(--r) 4px',
background: m.sender === 'admin' ? 'var(--blue)' : 'var(--card)',
color: m.sender === 'admin' ? '#fff' : 'var(--text)',
boxShadow: 'var(--shadow-1)',
fontSize: '0.88rem',
lineHeight: 1.5,
}}>
<div>{m.body}</div>
<div style={{
fontSize: '0.68rem', marginTop: '4px',
opacity: 0.7, textAlign: 'right',
}}>
{formatTime(m.created_at)}
{m.read_at && ' ✓'}
</div>
</div>
</div>
))}
<div ref={bottomRef} />
</div>
{/* Reply input */}
<form onSubmit={handleReply} style={{
padding: '12px 16px', borderTop: '1px solid var(--divider)',
display: 'flex', gap: '8px', background: 'var(--card)',
}}>
<input
type="text"
value={input}
onChange={e => setInput(e.target.value)}
placeholder="Reply..."
style={{
flex: 1, padding: '12px 14px', borderRadius: 'var(--r-full)',
border: '1px solid var(--divider)', fontFamily: 'var(--font)',
fontSize: '0.88rem', background: 'var(--bg)',
}}
/>
<button
type="submit"
disabled={!input.trim() || sending}
style={{
background: sending ? 'var(--text-3)' : 'var(--blue)',
color: '#fff', borderRadius: 'var(--r-full)',
width: '44px', height: '44px', padding: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: '1.1rem',
opacity: !input.trim() ? 0.5 : 1,
}}
>
</button>
</form>
</div>
)
}
const ctxTag = {
fontSize: '0.68rem', fontWeight: 600, padding: '2px 8px',
borderRadius: 'var(--r-full)', background: 'var(--divider)', color: 'var(--text-2)',
}