Files
MathApp_frontend/src/screens/SupportScreen.tsx
AliReza 0e56d9c8aa math in Persian text: lists read right-to-left, every content text goes through MathText, chat input picks its direction per line
Measured by rendering every question, option and several tutor answers in
Chrome and comparing the on-screen x of each number (2026-09-29):
- A list inside a sentence ('از −۳ به −۲، −۱، ۰ می‌رسیم', 'الگویِ ۲، ۵، ۸')
  was locked as one left-to-right run, so all 482 read backwards. '،' and
  '؛' outside brackets now split the run; sets and coordinates in brackets
  stay one run. Locked runs are isolates (isolate-override), otherwise the
  comma between two of them was still pulled left-to-right.
- Without MathText 704 of 931 minus signs and 602 of 983 expressions were
  misplaced; lesson and island titles, the lesson intro, unit blurb,
  notification bodies and ticket messages now go through it.
- The chat box takes its direction from each line's first letter, so a
  line of pure math stays left-to-right while typing. A line that starts
  in Persian can still show its math reordered inside the box; a textarea
  cannot isolate part of its text. The sent message renders correctly.

Four expressions with Persian words inside them ('f(x) = (ریشه دوم x) + ۵۰')
remain ambiguous.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 23:46:18 +03:30

190 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from 'react';
import { Bug, MessageCircle, Send } from 'lucide-react';
import { api, type TicketSummary } from '@/api';
import { useAsync } from '@/lib/useAsync';
import { useToast } from '@/components/Toast';
import { InlineError, PrimaryButton, Screen, ScreenBody, ScreenHeader, Sep } from '@/components/ui';
import { friendlyError } from '@/lib/errors';
import { toFa } from '@/lib/format';
import { MathText } from '@/lib/MathText';
const CATEGORIES = [
{ id: 'learning', label: 'درس و سوال‌ها' },
{ id: 'payment', label: 'پرداخت و اشتراک' },
{ id: 'account', label: 'حساب و ورود' },
{ id: 'other', label: 'چیزِ دیگه' },
];
const STATUS_LABEL: Record<string, string> = {
open: 'باز',
in_progress: 'در حالِ بررسی',
answered: 'پاسخ داده شد',
closed: 'بسته',
};
export function SupportScreen() {
const toast = useToast();
const [mode, setMode] = useState<'ticket' | 'bug'>('ticket');
const [category, setCategory] = useState('learning');
const [subject, setSubject] = useState('');
const [text, setText] = useState('');
const [sending, setSending] = useState(false);
const [error, setError] = useState('');
// فهرست از سرور می‌آید، پس تیکت و پاسخش با عوض شدنِ گوشی هم دیده می‌شود.
const tickets = useAsync(() => api.support.tickets(), []);
async function submit() {
if (!subject.trim() || !text.trim()) return;
setSending(true);
setError('');
try {
if (mode === 'bug') {
await api.support.bugReport(subject.trim(), text.trim());
toast('گزارشت رسید؛ ممنون!', 'success');
} else {
const t = await api.support.createTicket(subject.trim(), category);
await api.support.reply(t.ticketId, text.trim());
tickets.reload();
toast('پیامت رسید؛ جواب همین‌جا و در اعلان‌ها میاد.', 'success');
}
setSubject('');
setText('');
} catch (err) {
setError(friendlyError(err, 'ارسال نشد، دوباره امتحان کن'));
} finally {
setSending(false);
}
}
return (
<Screen>
<ScreenHeader title="پشتیبانی" subtitle="سوال، مشکل یا پیشنهاد داری؟ بنویس." color="#4C5FD5" back mascot={{ char: 'sigma', mood: 'happy', size: 56 }} />
<ScreenBody className="flex flex-col gap-4">
<div className="flex gap-1 rounded-2xl bg-soft p-1">
{([['ticket', 'پیام به پشتیبانی', MessageCircle], ['bug', 'گزارشِ خطای اپ', Bug]] as const).map(([id, label, Icon]) => (
<button
key={id}
type="button"
onClick={() => setMode(id)}
className={`flex flex-1 items-center justify-center gap-1.5 rounded-xl py-2.5 text-xs font-bold ${mode === id ? 'bg-white text-indigo shadow-sm' : 'text-muted'}`}
>
<Icon size={14} /> {label}
</button>
))}
</div>
{mode === 'ticket' && (
<div className="flex flex-wrap gap-2">
{CATEGORIES.map((c) => (
<button
key={c.id}
type="button"
onClick={() => setCategory(c.id)}
className={`rounded-full border-2 px-3 py-1.5 text-xs font-bold ${category === c.id ? 'border-indigo bg-indigo text-white' : 'border-line bg-white text-body'}`}
>
{c.label}
</button>
))}
</div>
)}
<input
value={subject}
onChange={(e) => setSubject(e.target.value.slice(0, 120))}
placeholder={mode === 'bug' ? 'چی خراب شد؟ (خلاصه)' : 'موضوع'}
className="w-full rounded-2xl border-2 border-line bg-white px-4 py-3.5 text-sm outline-none focus:border-indigo"
/>
<textarea
value={text}
onChange={(e) => setText(e.target.value.slice(0, 2000))}
rows={5}
placeholder={mode === 'bug' ? 'چه کار کردی و چه اتفاقی افتاد؟' : 'پیامت رو بنویس…'}
className="w-full resize-none rounded-2xl border-2 border-line bg-white px-4 py-3.5 text-sm leading-7 outline-none focus:border-indigo"
/>
{error && <InlineError>{error}</InlineError>}
<PrimaryButton color="#4C5FD5" loading={sending} disabled={!subject.trim() || !text.trim()} onClick={() => void submit()}>
<Send size={16} className="-scale-x-100" /> ارسال
</PrimaryButton>
{!!tickets.data?.length && (
<section>
<h2 className="mb-2 mt-2 text-sm font-black text-ink">پیام‌های قبلیِ تو</h2>
<div className="flex flex-col gap-2">
{tickets.data.map((t) => <TicketCard key={t.ticketId} summary={t} />)}
</div>
</section>
)}
</ScreenBody>
</Screen>
);
}
function TicketCard({ summary }: { summary: TicketSummary }) {
const toast = useToast();
const [open, setOpen] = useState(false);
// پیام‌ها فقط وقتی کارت باز شود گرفته می‌شوند؛ فهرست خودش خلاصه را دارد.
const detail = useAsync(() => (open ? api.support.ticket(summary.ticketId) : Promise.resolve(null)), [open, summary.ticketId]);
const [reply, setReply] = useState('');
const [sending, setSending] = useState(false);
const t = detail.data;
const status = t?.status ?? summary.status;
const staffReplies = t ? t.messages.filter((m) => m.sender !== 'user').length : summary.supportReplies;
async function send() {
if (!reply.trim()) return;
setSending(true);
try {
await api.support.reply(summary.ticketId, reply.trim());
setReply('');
detail.reload();
} catch (err) {
toast(friendlyError(err), 'error');
} finally {
setSending(false);
}
}
return (
<div className="rounded-2xl border border-line bg-white">
<button type="button" onClick={() => setOpen((o) => !o)} className="flex w-full items-center gap-3 p-3.5 text-right">
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-bold text-ink">{summary.subject}</span>
<span className="text-[11px] text-muted">
{STATUS_LABEL[status] ?? status}
{staffReplies > 0 && <><Sep />{toFa(staffReplies)} پاسخ</>}
</span>
</span>
{staffReplies > 0 && !open && <span className="h-2 w-2 flex-shrink-0 rounded-full bg-indigo" aria-hidden />}
</button>
{open && (
<div className="flex flex-col gap-2 border-t border-line p-3.5">
{!t && (detail.error
? <InlineError>{friendlyError(detail.error)}</InlineError>
: <div className="h-10 animate-pulse rounded-2xl bg-soft" />)}
{t?.messages.map((m) => (
<div
key={m.id}
className={`whitespace-pre-line rounded-2xl px-3.5 py-2.5 text-xs leading-6 ${m.sender === 'user' ? 'mr-8 bg-soft text-body' : 'ml-8 bg-[#EEF1FF] text-ink'}`}
>
<MathText text={m.text} />
</div>
))}
{t && status !== 'closed' && (
<div className="mt-1 flex gap-2">
<input
value={reply}
onChange={(e) => setReply(e.target.value.slice(0, 2000))}
placeholder="جواب بده…"
className="flex-1 rounded-full bg-soft px-4 py-2 text-xs outline-none"
/>
<button type="button" disabled={!reply.trim() || sending} onClick={() => void send()} className="rounded-full bg-indigo px-3 text-xs font-bold text-white disabled:opacity-40">
ارسال
</button>
</div>
)}
</div>
)}
</div>
);
}