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:
parent
42632b1b50
commit
10f13a9f62
10 changed files with 988 additions and 4 deletions
|
|
@ -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 />}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
162
frontend/src/pages/Support.jsx
Normal file
162
frontend/src/pages/Support.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
255
frontend/src/pages/SupportAdmin.jsx
Normal file
255
frontend/src/pages/SupportAdmin.jsx
Normal 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)',
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue