play whole islands, make home the path itself, draw tutor diagrams, add tedi

- Home is now the island path of every unit with one clear Start button on the
  current island; the six-card dashboard is gone.
- Start opens /nodes/{id}/quiz (4 questions) instead of one-question lessons, so
  an island takes one round instead of ~11. Lessons stay one tap away in the sheet.
- Tutor answers render the backend's validated diagram (plane, number line,
  fraction) as React SVG; function expressions go through a small parser, no eval.
- Tedi, the mobile-preview mascot, joins as the fourth companion with all six moods
  and an onboarding slide.
- Light polish: 3D buttons, header gradients, warmer page background.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-28 17:45:57 +03:30
parent b5d97c0cda
commit 355bae7035
17 changed files with 1075 additions and 347 deletions

View File

@@ -75,6 +75,15 @@ export const api = {
home: () => http.get<T.HomeResponse>('/home'), home: () => http.get<T.HomeResponse>('/home'),
nodeLessons: (nodeId: string) => http.get<T.NodeLessons>(`/nodes/${nodeId}/lessons`), nodeLessons: (nodeId: string) => http.get<T.NodeLessons>(`/nodes/${nodeId}/lessons`),
/**
* آزمونِ جزیره: از هر درسِ جزیره حداکثر یک سوال (پیش‌فرض ۴). ترتیب برای هر
* کاربر ثابت است، پس رفرش همان آزمون را می‌دهد.
*/
nodeQuiz: (nodeId: string) => http.get<T.NodeQuiz>(`/nodes/${nodeId}/quiz`),
/** پایانِ آزمون، جزیره را با هر نمره‌ای تمام می‌کند و جزیره‌ی بعدی را باز. */
nodeQuizComplete: (nodeId: string, result: { correctCount: number; totalQuestions: number }, idempotencyKey?: string) =>
http.create<T.NodeQuizComplete>(`/nodes/${nodeId}/quiz/complete`, result, { idempotencyKey }),
lesson: { lesson: {
intro: (lessonId: number) => http.get<T.LessonIntro>(`/lessons/${lessonId}/intro`), intro: (lessonId: number) => http.get<T.LessonIntro>(`/lessons/${lessonId}/intro`),
questions: (lessonId: number) => questions: (lessonId: number) =>

View File

@@ -121,6 +121,31 @@ export interface Question {
options: QuestionOption[]; options: QuestionOption[];
} }
export interface NodeQuizQuestion extends Question {
lessonId: string;
lessonTitle: string;
}
export interface NodeQuiz {
nodeId: string;
nodeTitle: string;
unitTitle: string;
/** ممکن است از requested کمتر باشد (جزیره‌ی کم‌سوال). */
questionCount: number;
requested: number;
questions: NodeQuizQuestion[];
}
export interface NodeQuizComplete {
nodeId: string;
stars: number;
xpEarned: number;
coinsEarned: number;
/** جزیره‌ی بعدی که حالا باز است؛ null یعنی آخرینِ پایه بود. */
nextNodeId: string | null;
stats: HomeStats;
}
export interface AnswerResult { export interface AnswerResult {
correct: boolean; correct: boolean;
correctOptionId: string; correctOptionId: string;
@@ -184,14 +209,36 @@ export interface AskDone {
suggestedFollowUps: string[]; suggestedFollowUps: string[];
remainingQuota: number; remainingQuota: number;
usedChunks: UsedChunk[]; usedChunks: UsedChunk[];
diagram?: Diagram | null;
} }
export interface ConversationMessage { export interface ConversationMessage {
messageId: string; messageId: string;
role: 'user' | 'assistant' | string; role: 'user' | 'assistant' | string;
text: string; text: string;
diagram?: Diagram | null;
} }
/* ─── شکلِ پاسخِ دستیار (بک‌اند: rag/services/diagram.py) ─────────────────── */
export type DiagramRef = string | [number, number];
export type DiagramPointDef = [number, number] | { polar: [number, number]; from?: DiagramRef };
export type DiagramItem =
| { type: 'circle'; center: DiagramRef; r: number; label?: string }
| { type: 'segment' | 'arrow'; from: DiagramRef; to: DiagramRef; label?: string }
| { type: 'line'; through: [DiagramRef, DiagramRef]; label?: string }
| { type: 'angle' | 'rightAngle'; vertex: DiagramRef; from: DiagramRef; to: DiagramRef; label?: string }
| { type: 'polygon'; points: DiagramRef[]; label?: string }
| { type: 'point'; point: DiagramRef; label?: string }
| { type: 'function'; expr: string; from: number; to: number; label?: string }
| { type: 'text'; at: [number, number]; text: string };
export type Diagram =
| { kind: 'plane'; title: string; axes: boolean; points: Record<string, DiagramPointDef>; items: DiagramItem[] }
| { kind: 'numberline'; title: string; min: number; max: number; points: { at: number; label: string }[]; jumps: { from: number; to: number; label: string }[] }
| { kind: 'fraction'; title: string; shape: 'circle' | 'rect'; parts: number; shaded: number };
export interface NotificationItem { export interface NotificationItem {
id: number; id: number;
type: string; type: string;

392
src/components/Diagram.tsx Normal file
View File

@@ -0,0 +1,392 @@
import { memo, useMemo, type ReactNode } from 'react';
import type { Diagram as DiagramSpec, DiagramItem, DiagramPointDef, DiagramRef } from '@/api';
import { parseExpr } from '@/lib/mathExpr';
import { toFa } from '@/lib/format';
/**
* شکلِ پاسخِ دستیار، از روی توصیفِ JSONی که بک‌اند اعتبارسنجی کرده
* (rag/services/diagram.py). مختصات همین‌جا حساب می‌شوند — مدل فقط می‌گوید
* «نقطه روی دایره با زاویه‌ی ۳۰°» — و همه‌چیز عنصرِ SVGِ React است، بدونِ
* innerHTML؛ پس متنِ مدل هیچ راهی به HTMLِ صفحه ندارد.
*
* هر ایرادی (نقطه‌ی ناشناخته، عبارتِ نامعتبر) فقط همان تکه را حذف می‌کند؛ اگر
* چیزی برای کشیدن نماند، هیچ شکلی نشان داده نمی‌شود.
*/
const INK = '#2b2b45';
const MAIN = '#4C5FD5';
const ACCENT = '#ED6956';
const SOFT = '#9e9ec0';
const W = 300;
const PAD = 22;
type XY = [number, number];
function DiagramBase({ spec }: { spec: DiagramSpec }) {
const body = useMemo(() => {
try {
if (spec.kind === 'numberline') return <NumberLine spec={spec} />;
if (spec.kind === 'fraction') return <Fraction spec={spec} />;
if (spec.kind === 'plane') return <Plane spec={spec} />;
} catch {
/* شکلِ خراب نباید پاسخ را خراب کند */
}
return null;
}, [spec]);
if (!body) return null;
return (
// dir=ltr: محور و مختصات چپ‌به‌راست‌اند؛ در زمینه‌ی rtl «−۱» به «۱−» برمی‌گشت.
// برچسب‌های فارسی با unicode-bidi: plaintext جهتِ خودشان را می‌گیرند.
<figure dir="ltr" className="mt-2 overflow-hidden rounded-2xl border border-line bg-[#fbfbff] p-2">
{body}
{spec.title && <figcaption dir="rtl" className="mt-1 text-center text-[11px] font-bold text-muted">{spec.title}</figcaption>}
</figure>
);
}
export const Diagram = memo(DiagramBase);
/* ─── صفحه‌ی مختصات / هندسه ───────────────────────────────────────────────── */
function Plane({ spec }: { spec: Extract<DiagramSpec, { kind: 'plane' }> }) {
// نقطه‌ها: polar نسبت به مبدأ یا نقطه‌ی from، با حلِ ارجاعِ زنجیره‌ای.
const named = new Map<string, XY>();
const resolving = new Set<string>();
const resolveName = (name: string): XY | null => {
if (named.has(name)) return named.get(name)!;
const def: DiagramPointDef | undefined = spec.points[name];
if (!def || resolving.has(name)) return null;
resolving.add(name);
let xy: XY | null = null;
if (Array.isArray(def)) xy = [def[0], def[1]];
else {
const origin = def.from !== undefined ? ref(def.from) : [0, 0];
if (origin) {
const [r, deg] = def.polar;
const t = (deg * Math.PI) / 180;
xy = [origin[0] + r * Math.cos(t), origin[1] + r * Math.sin(t)];
}
}
resolving.delete(name);
if (xy) named.set(name, xy);
return xy;
};
const ref = (r: DiagramRef): XY | null => (typeof r === 'string' ? resolveName(r) : [r[0], r[1]]);
Object.keys(spec.points).forEach(resolveName);
// نمودارِ تابع‌ها پیش از کادر، چون کادر را تعیین می‌کنند.
const curves: { item: Extract<DiagramItem, { type: 'function' }>; pts: XY[] }[] = [];
for (const item of spec.items) {
if (item.type !== 'function') continue;
try {
const f = parseExpr(item.expr);
const pts: XY[] = [];
for (let i = 0; i <= 160; i++) {
const x = item.from + ((item.to - item.from) * i) / 160;
const y = f(x);
if (Number.isFinite(y)) pts.push([x, y]);
}
if (pts.length > 1) curves.push({ item, pts });
} catch {
/* عبارتِ نامعتبر: همین نمودار کشیده نمی‌شود */
}
}
// کادر
const xs: number[] = [], ys: number[] = [];
const add = ([x, y]: XY) => { xs.push(x); ys.push(y); };
named.forEach(add);
for (const item of spec.items) {
if (item.type === 'circle') {
const c = ref(item.center);
if (c) { add([c[0] - item.r, c[1] - item.r]); add([c[0] + item.r, c[1] + item.r]); }
} else if (item.type === 'text') add(item.at);
else if (item.type === 'segment' || item.type === 'arrow') {
const a = ref(item.from), b = ref(item.to);
if (a) add(a);
if (b) add(b);
} else if (item.type === 'polygon') item.points.forEach((p) => { const xy = ref(p); if (xy) add(xy); });
else if (item.type === 'point') { const xy = ref(item.point); if (xy) add(xy); }
}
for (const { item, pts } of curves) {
xs.push(item.from, item.to);
const cy = pts.map((p) => p[1]).sort((a, b) => a - b);
// دُمِ نمودار (مثلاً tan) نباید کلِ کادر را بکشد: ۵ تا ۹۵ درصد.
ys.push(cy[Math.floor(cy.length * 0.05)], cy[Math.ceil(cy.length * 0.95) - 1]);
}
if (!xs.length) return null;
if (spec.axes) { xs.push(0); ys.push(0); }
let [x0, x1] = [Math.min(...xs), Math.max(...xs)];
let [y0, y1] = [Math.min(...ys), Math.max(...ys)];
const span = Math.max(x1 - x0, y1 - y0, 1e-6);
const margin = span * 0.14 + 0.2;
x0 -= margin; x1 += margin; y0 -= margin; y1 += margin;
const scale = (W - 2 * PAD) / (x1 - x0);
const height = Math.min(Math.max((y1 - y0) * scale + 2 * PAD, 150), 340);
// اگر ارتفاع سقف خورد، مقیاس را از روی ارتفاع بگیر تا دایره بیضی نشود.
const s = Math.min(scale, (height - 2 * PAD) / (y1 - y0));
const ox = (W - (x1 - x0) * s) / 2, oy = (height - (y1 - y0) * s) / 2;
const X = (x: number) => ox + (x - x0) * s;
const Y = (y: number) => oy + (y1 - y) * s;
const P = ([x, y]: XY): XY => [X(x), Y(y)];
const els: ReactNode[] = [];
let key = 0;
const k = () => key++;
if (spec.axes) {
const ticks = niceTicks(x0, x1), yticks = niceTicks(y0, y1);
els.push(
<g key={k()} stroke="#d9d7ea" strokeWidth={1}>
{ticks.map((t) => <line key={`gx${t}`} x1={X(t)} x2={X(t)} y1={Y(y0)} y2={Y(y1)} strokeDasharray="2 4" />)}
{yticks.map((t) => <line key={`gy${t}`} y1={Y(t)} y2={Y(t)} x1={X(x0)} x2={X(x1)} strokeDasharray="2 4" />)}
</g>,
<g key={k()} stroke={SOFT} strokeWidth={1.4} markerEnd="url(#dg-arrow-soft)">
<line x1={X(x0)} y1={Y(0)} x2={X(x1)} y2={Y(0)} />
<line x1={X(0)} y1={Y(y0)} x2={X(0)} y2={Y(y1)} />
</g>,
<g key={k()} fill={SOFT} fontSize={9} textAnchor="middle">
{ticks.filter((t) => t !== 0).map((t) => <text key={`tx${t}`} x={X(t)} y={Y(0) + 11}>{fa(t)}</text>)}
{yticks.filter((t) => t !== 0).map((t) => <text key={`ty${t}`} x={X(0) - 8} y={Y(t) + 3}>{fa(t)}</text>)}
</g>,
);
}
const centroid: XY = (() => {
const all = [...named.values()];
if (!all.length) return [0, 0];
return [all.reduce((a, p) => a + p[0], 0) / all.length, all.reduce((a, p) => a + p[1], 0) / all.length];
})();
for (const item of spec.items) {
if (item.type === 'circle') {
const c = ref(item.center);
if (!c) continue;
const [cx, cy] = P(c);
els.push(<circle key={k()} cx={cx} cy={cy} r={item.r * s} fill="none" stroke={MAIN} strokeWidth={2.2} />);
} else if (item.type === 'segment' || item.type === 'arrow') {
const a = ref(item.from), b = ref(item.to);
if (!a || !b) continue;
const [ax, ay] = P(a), [bx, by] = P(b);
els.push(
<line key={k()} x1={ax} y1={ay} x2={bx} y2={by} stroke={item.type === 'arrow' ? ACCENT : INK} strokeWidth={2.2}
strokeLinecap="round" markerEnd={item.type === 'arrow' ? 'url(#dg-arrow)' : undefined} />,
);
if (item.label) els.push(sideLabel(k(), [ax, ay], [bx, by], P(centroid), item.label));
} else if (item.type === 'line') {
const a = ref(item.through[0]), b = ref(item.through[1]);
if (!a || !b || (a[0] === b[0] && a[1] === b[1])) continue;
const [dx, dy] = [b[0] - a[0], b[1] - a[1]];
const t = (x1 - x0 + y1 - y0) * 2 / Math.hypot(dx, dy);
const [px, py] = P([a[0] - dx * t, a[1] - dy * t]), [qx, qy] = P([a[0] + dx * t, a[1] + dy * t]);
els.push(<line key={k()} x1={px} y1={py} x2={qx} y2={qy} stroke={MAIN} strokeWidth={1.8} />);
} else if (item.type === 'polygon') {
const pts = item.points.map(ref).filter((p): p is XY => p !== null).map(P);
if (pts.length < 3) continue;
els.push(<polygon key={k()} points={pts.map((p) => p.join(',')).join(' ')} fill="#4C5FD514" stroke={INK} strokeWidth={2.2} strokeLinejoin="round" />);
} else if (item.type === 'angle' || item.type === 'rightAngle') {
const v = ref(item.vertex), a = ref(item.from), b = ref(item.to);
if (!v || !a || !b) continue;
const [vx, vy] = P(v), [ax, ay] = P(a), [bx, by] = P(b);
const ta = Math.atan2(ay - vy, ax - vx), tb = Math.atan2(by - vy, bx - vx);
let d = tb - ta;
while (d <= -Math.PI) d += 2 * Math.PI;
while (d > Math.PI) d -= 2 * Math.PI;
const r = Math.min(24, Math.hypot(ax - vx, ay - vy) * 0.4, Math.hypot(bx - vx, by - vy) * 0.4);
if (item.type === 'rightAngle') {
const ua: XY = [Math.cos(ta), Math.sin(ta)], ub: XY = [Math.cos(tb), Math.sin(tb)];
const q = Math.min(r, 12);
const p1: XY = [vx + ua[0] * q, vy + ua[1] * q], p3: XY = [vx + ub[0] * q, vy + ub[1] * q];
const p2: XY = [p1[0] + ub[0] * q, p1[1] + ub[1] * q];
els.push(<polyline key={k()} points={[p1, p2, p3].map((p) => p.join(',')).join(' ')} fill="none" stroke={ACCENT} strokeWidth={1.8} />);
} else {
const end: XY = [vx + r * Math.cos(ta + d), vy + r * Math.sin(ta + d)];
const start: XY = [vx + r * Math.cos(ta), vy + r * Math.sin(ta)];
els.push(
<path key={k()} d={`M${vx},${vy} L${start[0]},${start[1]} A${r},${r} 0 0 ${d > 0 ? 1 : 0} ${end[0]},${end[1]} Z`}
fill="#ED695626" stroke={ACCENT} strokeWidth={1.8} />,
);
}
if (item.label) {
const mid = ta + d / 2, lr = r + 13;
els.push(<Label key={k()} x={vx + lr * Math.cos(mid)} y={vy + lr * Math.sin(mid) + 4} color={ACCENT}>{item.label}</Label>);
}
} else if (item.type === 'text') {
const [tx, ty] = P(item.at);
els.push(<Label key={k()} x={tx} y={ty} color={INK}>{item.text}</Label>);
}
}
for (const { item, pts } of curves) {
const clipped = pts.filter(([, y]) => y >= y0 - (y1 - y0) && y <= y1 + (y1 - y0));
const d = clipped.map((p, i) => `${i ? 'L' : 'M'}${X(p[0]).toFixed(1)},${Y(p[1]).toFixed(1)}`).join(' ');
els.push(<path key={k()} d={d} fill="none" stroke={MAIN} strokeWidth={2.4} strokeLinejoin="round" />);
if (item.label) {
const last = clipped[Math.floor(clipped.length * 0.85)] ?? clipped[clipped.length - 1];
els.push(<Label key={k()} x={X(last[0])} y={Y(last[1]) - 8} color={MAIN}>{item.label}</Label>);
}
}
// نقطه‌ها آخر از همه، روی بقیه
const pointLabels = new Map<string, string>();
const extraPoints: { xy: XY; label: string }[] = [];
for (const item of spec.items) {
if (item.type !== 'point') continue;
if (typeof item.point === 'string') pointLabels.set(item.point, item.label ?? item.point);
else extraPoints.push({ xy: item.point, label: item.label ?? '' });
}
const drawPoint = (xy: XY, label: string) => {
const [px, py] = P(xy);
const [cx, cy] = P(centroid);
let [dx, dy] = [px - cx, py - cy];
const n = Math.hypot(dx, dy) || 1;
[dx, dy] = [(dx / n) * 13, (dy / n) * 13 || -13];
els.push(<circle key={k()} cx={px} cy={py} r={3.6} fill={INK} />);
if (label) els.push(<Label key={k()} x={px + dx} y={py + dy + 4} color={INK} bold>{label}</Label>);
};
named.forEach((xy, name) => {
if (name.startsWith('_')) return;
const isOriginOnAxes = spec.axes && xy[0] === 0 && xy[1] === 0 && !pointLabels.has(name);
drawPoint(xy, isOriginOnAxes ? '' : pointLabels.get(name) ?? name);
});
extraPoints.forEach((p) => drawPoint(p.xy, p.label));
return (
<svg viewBox={`0 0 ${W} ${height}`} className="mx-auto block w-full max-w-[320px]" role="img" aria-label={spec.title || 'شکل'}>
<Markers />
{els}
</svg>
);
}
/* ─── محورِ اعداد ─────────────────────────────────────────────────────────── */
function NumberLine({ spec }: { spec: Extract<DiagramSpec, { kind: 'numberline' }> }) {
const { min, max } = spec;
const height = spec.jumps.length ? 120 : 80;
const base = height - 38;
const X = (v: number) => PAD + ((v - min) / (max - min)) * (W - 2 * PAD);
const step = niceStep(max - min, 12);
const ticks: number[] = [];
for (let t = Math.ceil(min / step) * step; t <= max + 1e-9; t += step) ticks.push(Math.round(t * 1e6) / 1e6);
const labelled = new Set(spec.points.map((p) => p.at));
return (
<svg viewBox={`0 0 ${W} ${height}`} className="mx-auto block w-full max-w-[340px]" role="img" aria-label={spec.title || 'محورِ اعداد'}>
<Markers />
<line x1={PAD - 12} y1={base} x2={W - PAD + 12} y2={base} stroke={INK} strokeWidth={2} markerEnd="url(#dg-arrow-ink)" markerStart="url(#dg-arrow-ink-start)" />
{ticks.map((t) => (
<g key={t}>
<line x1={X(t)} x2={X(t)} y1={base - 5} y2={base + 5} stroke={INK} strokeWidth={t === 0 ? 2 : 1.3} />
<text x={X(t)} y={base + 19} fontSize={labelled.has(t) ? 11 : 10} fontWeight={labelled.has(t) ? 800 : 400} fill={labelled.has(t) ? INK : SOFT} textAnchor="middle">
{fa(t)}
</text>
</g>
))}
{spec.jumps.map((j, i) => {
const a = X(j.from), b = X(j.to);
const lift = Math.min(46, Math.abs(b - a) * 0.5 + 14);
return (
<g key={`j${i}`}>
<path d={`M${a},${base - 6} Q${(a + b) / 2},${base - 6 - lift * 1.4} ${b},${base - 6}`} fill="none" stroke={ACCENT} strokeWidth={2.2} markerEnd="url(#dg-arrow)" />
{j.label && <Label x={(a + b) / 2} y={base - 10 - lift * 0.75} color={ACCENT}>{j.label}</Label>}
</g>
);
})}
{spec.points.map((p, i) => (
<g key={`p${i}`}>
<circle cx={X(p.at)} cy={base} r={5.5} fill={i === spec.points.length - 1 && spec.points.length > 1 ? ACCENT : MAIN} stroke="#fff" strokeWidth={1.5} />
{p.label && !/^[-−]?[\d۰-۹.]+$/.test(p.label) && <Label x={X(p.at)} y={base + 33} color={INK}>{p.label}</Label>}
</g>
))}
</svg>
);
}
/* ─── کسر ─────────────────────────────────────────────────────────────────── */
function Fraction({ spec }: { spec: Extract<DiagramSpec, { kind: 'fraction' }> }) {
const { parts, shaded } = spec;
const fill = (i: number) => (i < shaded ? '#FFC63D' : '#fff');
if (spec.shape === 'rect') {
const w = (W - 2 * PAD) / parts;
return (
<svg viewBox={`0 0 ${W} 90`} className="mx-auto block w-full max-w-[320px]" role="img" aria-label={spec.title || 'کسر'}>
{Array.from({ length: parts }, (_, i) => (
<rect key={i} x={W - PAD - (i + 1) * w} y={18} width={w} height={46} fill={fill(i)} stroke={INK} strokeWidth={2} />
))}
<text x={W / 2} y={84} fontSize={12} fontWeight={800} fill={INK} textAnchor="middle">{toFa(shaded)} از {toFa(parts)}</text>
</svg>
);
}
const cx = W / 2, cy = 82, r = 64;
return (
<svg viewBox={`0 0 ${W} 170`} className="mx-auto block w-full max-w-[260px]" role="img" aria-label={spec.title || 'کسر'}>
{parts === 1 ? (
<circle cx={cx} cy={cy} r={r} fill={fill(0)} stroke={INK} strokeWidth={2} />
) : (
Array.from({ length: parts }, (_, i) => {
const a0 = -Math.PI / 2 + (2 * Math.PI * i) / parts, a1 = -Math.PI / 2 + (2 * Math.PI * (i + 1)) / parts;
const large = a1 - a0 > Math.PI ? 1 : 0;
return (
<path key={i} d={`M${cx},${cy} L${cx + r * Math.cos(a0)},${cy + r * Math.sin(a0)} A${r},${r} 0 ${large} 1 ${cx + r * Math.cos(a1)},${cy + r * Math.sin(a1)} Z`}
fill={fill(i)} stroke={INK} strokeWidth={2} strokeLinejoin="round" />
);
})
)}
<text x={cx} y={164} fontSize={12} fontWeight={800} fill={INK} textAnchor="middle">{toFa(shaded)} از {toFa(parts)} تکه</text>
</svg>
);
}
/* ─── کمکی‌ها ─────────────────────────────────────────────────────────────── */
function Markers() {
const head = (id: string, color: string, start = false) => (
<marker id={id} viewBox="0 0 10 10" refX={start ? 2 : 8} refY="5" markerWidth="7" markerHeight="7" orient={start ? 'auto-start-reverse' : 'auto'}>
<path d="M0,0 L10,5 L0,10 z" fill={color} />
</marker>
);
return (
<defs>
{head('dg-arrow', ACCENT)}
{head('dg-arrow-soft', SOFT)}
{head('dg-arrow-ink', INK)}
{head('dg-arrow-ink-start', INK, true)}
</defs>
);
}
function Label({ x, y, color, bold = false, children }: { x: number; y: number; color: string; bold?: boolean; children: ReactNode }) {
return (
<text x={x} y={y} fontSize={11} fontWeight={bold ? 800 : 700} fill={color} textAnchor="middle"
stroke="#fbfbff" strokeWidth={3} paintOrder="stroke" style={{ unicodeBidi: 'plaintext' }}>
{children}
</text>
);
}
/** برچسبِ ضلع، وسطِ پاره‌خط و به سمتِ بیرونِ شکل. */
function sideLabel(key: number, a: XY, b: XY, center: XY, text: string) {
const mx = (a[0] + b[0]) / 2, my = (a[1] + b[1]) / 2;
let nx = -(b[1] - a[1]), ny = b[0] - a[0];
const n = Math.hypot(nx, ny) || 1;
nx /= n; ny /= n;
if ((mx - center[0]) * nx + (my - center[1]) * ny < 0) { nx = -nx; ny = -ny; }
return <Label key={key} x={mx + nx * 13} y={my + ny * 13 + 4} color={MAIN}>{text}</Label>;
}
function niceStep(span: number, maxTicks: number): number {
const raw = span / maxTicks;
const pow = 10 ** Math.floor(Math.log10(raw || 1));
const m = raw / pow;
return (m <= 1 ? 1 : m <= 2 ? 2 : m <= 5 ? 5 : 10) * pow;
}
function niceTicks(lo: number, hi: number): number[] {
const step = niceStep(hi - lo, 8);
const out: number[] = [];
for (let t = Math.ceil(lo / step) * step; t <= hi; t += step) out.push(Math.round(t * 1e6) / 1e6);
return out;
}
const fa = (n: number) => toFa(String(n).replace('-', '−'));

View File

@@ -0,0 +1,107 @@
import { forwardRef } from 'react';
import { motion } from 'motion/react';
import { Check, Lock, Play, Star } from 'lucide-react';
import type { HomeNode } from '@/api';
import { ZigzagPath, zigzagPositions } from '@/components/ZigzagPath';
import { iconFor } from '@/components/NodeIcon';
import { shade } from '@/lib/color';
const NODE_SIZE = 76;
const SPACING = 128;
const TOP = 70;
/**
* مسیرِ جزیره‌های یک فصل، مثلِ دولینگو. جزیره‌ی فعلی یک دکمه‌ی «شروع» بالای
* سرش دارد که مستقیم آزمون را باز می‌کند؛ زدنِ خودِ جزیره برگه‌ی جزئیات را.
*
* `currentRef` به جزیره‌ی فعلی وصل می‌شود تا خانه بتواند تا آنجا اسکرول کند.
*/
export const IslandPath = forwardRef<HTMLDivElement, {
nodes: HomeNode[];
color: string;
/** جزیره‌ای که باید «شروع» بخورد؛ null یعنی در این فصل نیست. */
currentId: string | null;
onOpen: (node: HomeNode) => void;
onStart: (node: HomeNode) => void;
}>(function IslandPath({ nodes, color, currentId, onOpen, onStart }, currentRef) {
const points = zigzagPositions(nodes.length, SPACING, TOP);
const height = points.length ? points[points.length - 1].y + 80 : 120;
return (
<div className="relative mx-auto w-full max-w-[400px]" style={{ height }}>
<ZigzagPath points={points} height={height} colorOf={(i) => (nodes[i].status === 'completed' ? color : null)} />
{nodes.map((node, i) => {
const locked = node.status === 'locked';
const done = node.status === 'completed';
const current = node.id === currentId;
const Icon = iconFor(node.icon, i);
return (
<motion.div
key={node.id}
ref={current ? currentRef : undefined}
initial={{ opacity: 0, scale: 0.7 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: Math.min(i, 8) * 0.04, type: 'spring', stiffness: 220, damping: 16 }}
className="absolute -translate-x-1/2 -translate-y-1/2"
style={{ left: `${points[i].x}%`, top: points[i].y }}
>
{current && (
<>
<motion.button
type="button"
onClick={() => onStart(node)}
animate={{ y: [0, -5, 0] }}
transition={{ duration: 1.6, repeat: Infinity, ease: 'easeInOut' }}
className="absolute -top-[62px] left-1/2 z-20 flex -translate-x-1/2 items-center gap-1.5 whitespace-nowrap rounded-2xl px-4 py-2 text-sm font-black text-white"
style={{ backgroundColor: color, boxShadow: `0 4px 0 ${shade(color, -0.25)}` }}
>
<Play size={14} fill="white" /> {node.status === 'in_progress' ? 'ادامه' : 'شروع'}
<span className="absolute -bottom-1.5 left-1/2 h-3 w-3 -translate-x-1/2 rotate-45" style={{ backgroundColor: color }} />
</motion.button>
<motion.div
animate={{ scale: [1, 1.2, 1], opacity: [0.3, 0.06, 0.3] }}
transition={{ duration: 1.8, repeat: Infinity }}
className="absolute inset-[-12px] rounded-full"
style={{ backgroundColor: color }}
/>
</>
)}
<button
type="button"
aria-label={`${node.title}${locked ? ' (قفل)' : done ? ' (تمام‌شده)' : ''}`}
onClick={() => onOpen(node)}
className="relative z-10 flex items-center justify-center rounded-full transition-transform active:translate-y-1"
style={{
width: NODE_SIZE,
height: NODE_SIZE,
backgroundColor: locked ? '#e7e6ef' : done ? color : '#fff',
border: current ? `5px solid ${color}` : 'none',
boxShadow: locked ? '0 6px 0 #cfcedb' : done ? `0 7px 0 ${shade(color, -0.25)}` : `0 7px 0 ${shade(color, -0.25)}`,
}}
>
{locked ? (
<Lock size={24} strokeWidth={2.5} className="text-[#a9a8b8]" />
) : done ? (
<Check size={32} color="white" strokeWidth={3.5} />
) : (
<Icon size={30} style={{ color }} strokeWidth={2.5} />
)}
{done && (
<Star size={16} className="absolute -right-1 -top-1 text-[#FFC53D]" fill="#FFC53D" />
)}
</button>
{/* اسم کنارِ جزیره، نه زیرش: فضای بینِ دو جزیره مالِ دکمه‌ی «شروع» است
و اسمِ زیرِ جزیره‌ی قبلی زیرِ آن دکمه پنهان می‌شد. سمتی که جای بیشتری دارد. */}
<div
className={`absolute top-1/2 w-28 -translate-y-1/2 text-[11px] font-bold leading-4 ${
points[i].x >= 50 ? 'right-full mr-3 text-right' : 'left-full ml-3 text-left'
} ${current ? 'text-[12px] text-ink' : locked ? 'text-faint' : 'text-muted'}`}
>
<span className="line-clamp-2">{node.title}</span>
</div>
</motion.div>
);
})}
</div>
);
});

View File

@@ -0,0 +1,108 @@
import { useState } from 'react';
import { BookOpen, CheckCircle2, ChevronDown, Circle, Lock, Play, RotateCw } from 'lucide-react';
import { api, type HomeNode } from '@/api';
import { useNav } from '@/nav/Navigator';
import { useAsync } from '@/lib/useAsync';
import { Sheet } from '@/components/Sheet';
import { ErrorState, PrimaryButton } from '@/components/ui';
import { toFa } from '@/lib/format';
/**
* برگه‌ی جزیره. کارِ اصلی یک دکمه است: «شروعِ آزمونِ جزیره». فهرستِ درس‌ها
* (معرفیِ هر نوع مسئله) زیرِ آن و بسته است، برای بچه‌ای که اول می‌خواهد بخواند.
*/
export function IslandSheet({ node, color, onClose }: { node: HomeNode | null; color: string; onClose: () => void }) {
const nav = useNav();
const [showLessons, setShowLessons] = useState(false);
const lessons = useAsync(
() => (node && showLessons ? api.nodeLessons(node.id) : Promise.resolve(null)),
[node?.id, showLessons],
);
const locked = node?.status === 'locked';
const done = node?.status === 'completed';
const close = () => {
setShowLessons(false);
onClose();
};
const start = () => {
if (!node) return;
close();
nav.push({ name: 'quiz', nodeId: node.id });
};
const openLesson = (lessonId: number) => {
if (!node) return;
close();
nav.push({ name: 'lesson', lessonId, nodeId: node.id });
};
// فقط درس‌هایی که سوال دارند در lessonIds هستند.
const playable = new Set(node?.lessonIds ?? []);
const list = lessons.data?.lessons.filter((l) => playable.has(Number(l.id))) ?? [];
return (
<Sheet open={node !== null} onClose={close} title={node?.title}>
{locked ? (
<div className="flex flex-col items-center gap-3 py-4 text-center">
<span className="flex h-14 w-14 items-center justify-center rounded-full bg-soft">
<Lock size={24} className="text-muted" />
</span>
<p className="text-sm leading-7 text-muted">این جزیره هنوز قفله. جزیره‌ی قبلی رو تموم کن تا باز بشه.</p>
</div>
) : (
<>
<p className="mb-4 text-sm leading-7 text-body">
{done
? 'این جزیره رو تموم کردی! می‌تونی دوباره تمرینش کنی (امتیازِ تازه نداره).'
: 'چند سوالِ کوتاه از همین جزیره. هر جوابی بدی، آخرش جزیره‌ی بعدی باز می‌شه.'}
</p>
<PrimaryButton color={color} onClick={start}>
{done ? <><RotateCw size={18} /> دوباره تمرین کن</> : <><Play size={18} fill="white" /> شروعِ آزمونِ جزیره</>}
</PrimaryButton>
<button
type="button"
onClick={() => setShowLessons((s) => !s)}
className="mt-3 flex w-full items-center justify-center gap-1.5 py-2 text-sm font-bold text-muted"
aria-expanded={showLessons}
>
<BookOpen size={15} /> اول درس‌هاش رو بخونم
<ChevronDown size={15} className={`transition-transform ${showLessons ? 'rotate-180' : ''}`} />
</button>
{showLessons && (
lessons.loading ? (
<div className="py-4 text-center text-sm text-muted">دارم درس‌ها رو میارم…</div>
) : lessons.error ? (
<ErrorState error={lessons.error} onRetry={lessons.reload} />
) : (
<>
<p className="mb-2 mt-1 text-xs text-muted">
{toFa(list.filter((l) => l.done).length)} از {toFa(list.length)} درس خونده شده
</p>
<ul className="flex max-h-60 flex-col gap-1.5 overflow-y-auto">
{list.map((lesson) => (
<li key={lesson.id}>
<button
type="button"
onClick={() => openLesson(Number(lesson.id))}
className="flex w-full items-center gap-3 rounded-2xl border border-line bg-white px-3 py-2.5 text-right text-sm"
>
{lesson.done ? (
<CheckCircle2 size={18} className="flex-shrink-0 text-mint" />
) : (
<Circle size={18} className="flex-shrink-0 text-faint" />
)}
<span className={`flex-1 ${lesson.done ? 'text-muted' : 'text-ink'}`}>{lesson.title}</span>
</button>
</li>
))}
</ul>
</>
)
)}
</>
)}
</Sheet>
);
}

View File

@@ -4,6 +4,7 @@ import { SeyarMascot } from '@/mascots/SeyarMascot';
import type { SeyarChar, SeyarMood } from '@/mascots/seyar'; import type { SeyarChar, SeyarMood } from '@/mascots/seyar';
import { friendlyError } from '@/lib/errors'; import { friendlyError } from '@/lib/errors';
import { useNav } from '@/nav/Navigator'; import { useNav } from '@/nav/Navigator';
import { shade } from '@/lib/color';
/* ─── سربرگِ رنگیِ صفحه ──────────────────────────────────────────────────── */ /* ─── سربرگِ رنگیِ صفحه ──────────────────────────────────────────────────── */
@@ -28,7 +29,7 @@ export function ScreenHeader({
return ( return (
<header <header
className="relative flex-shrink-0 overflow-hidden rounded-b-[32px] px-5 pb-6 pt-safe text-white" className="relative flex-shrink-0 overflow-hidden rounded-b-[32px] px-5 pb-6 pt-safe text-white"
style={{ backgroundColor: color }} style={{ backgroundColor: color, backgroundImage: `linear-gradient(160deg, ${shade(color, 0.12)} 0%, ${color} 55%, ${shade(color, -0.06)} 100%)` }}
> >
<div className="pointer-events-none absolute -left-12 -top-12 h-40 w-40 rounded-full bg-white/10" /> <div className="pointer-events-none absolute -left-12 -top-12 h-40 w-40 rounded-full bg-white/10" />
<div className="pointer-events-none absolute -right-10 top-10 h-28 w-28 rounded-full bg-white/10" /> <div className="pointer-events-none absolute -right-10 top-10 h-28 w-28 rounded-full bg-white/10" />
@@ -85,8 +86,8 @@ export function PrimaryButton({
<button <button
type="button" type="button"
disabled={disabled || loading} disabled={disabled || loading}
className={`flex w-full items-center justify-center gap-2 rounded-2xl py-4 text-base font-black text-white transition-transform active:scale-[0.98] disabled:opacity-50 disabled:active:scale-100 ${className}`} className={`flex w-full items-center justify-center gap-2 rounded-2xl py-4 text-base font-black text-white transition-[transform,box-shadow] active:translate-y-[3px] active:shadow-none disabled:opacity-50 disabled:active:translate-y-0 ${className}`}
style={{ backgroundColor: color, boxShadow: `0 10px 24px -8px ${color}88`, ...style }} style={{ backgroundColor: color, boxShadow: `0 4px 0 ${shade(color, -0.22)}`, ...style }}
{...rest} {...rest}
> >
{loading ? <Spinner /> : children} {loading ? <Spinner /> : children}
@@ -98,7 +99,7 @@ export function SecondaryButton({ className = '', children, ...rest }: ButtonHTM
return ( return (
<button <button
type="button" type="button"
className={`flex w-full items-center justify-center gap-2 rounded-2xl border-2 border-line bg-white py-3.5 text-sm font-bold text-body transition-transform active:scale-[0.98] disabled:opacity-50 ${className}`} className={`flex w-full items-center justify-center gap-2 rounded-2xl border-2 border-b-4 border-line bg-white py-3.5 text-sm font-bold text-body transition-transform active:translate-y-[2px] active:border-b-2 disabled:opacity-50 ${className}`}
{...rest} {...rest}
> >
{children} {children}

12
src/lib/color.ts Normal file
View File

@@ -0,0 +1,12 @@
/** رنگِ hex را روشن (amount > 0) یا تیره (amount < 0) می‌کند؛ برای سایه‌ی دکمه‌های سه‌بعدی. */
export function shade(hex: string, amount: number): string {
const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
if (!m) return hex;
const n = parseInt(m[1], 16);
const channel = (c: number) => {
const v = amount < 0 ? c * (1 + amount) : c + (255 - c) * amount;
return Math.max(0, Math.min(255, Math.round(v)));
};
const r = channel((n >> 16) & 255), g = channel((n >> 8) & 255), b = channel(n & 255);
return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, '0')}`;
}

110
src/lib/mathExpr.ts Normal file
View File

@@ -0,0 +1,110 @@
/**
* پارسرِ کوچکِ عبارتِ ریاضی برای نمودارِ دستیار (`x^2-2*x`، `sin(x)+1`).
*
* عمداً eval یا new Function نیست: عبارت از مدل می‌آید و مدل متنِ کاربر را
* خوانده است. فقط عدد، x، pi، e، + − * / ^، پرانتز و چند تابعِ شناخته‌شده
* پذیرفته می‌شود؛ هر چیزِ دیگر خطا است و نمودار کشیده نمی‌شود. ضربِ ضمنی
* (2x، 3(x+1)، x sin(x)) هم پشتیبانی می‌شود چون مدل گاهی می‌نویسدش.
*/
type Node =
| { k: 'num'; v: number }
| { k: 'x' }
| { k: 'neg'; a: Node }
| { k: 'bin'; op: '+' | '-' | '*' | '/' | '^'; a: Node; b: Node }
| { k: 'fn'; f: string; a: Node };
const FUNCS: Record<string, (v: number) => number> = {
sin: (v) => Math.sin(v), cos: (v) => Math.cos(v), tan: (v) => Math.tan(v),
sqrt: (v) => Math.sqrt(v), abs: (v) => Math.abs(v), log: (v) => Math.log10(v), ln: (v) => Math.log(v),
};
function tokenize(src: string): string[] {
const s = src.replace(/\s+/g, '').replace(/[−–]/g, '-').replace(/[×·]/g, '*').replace(/÷/g, '/').replace(/²/g, '^2').replace(/³/g, '^3').replace(/π/g, 'pi');
const out: string[] = [];
let i = 0;
while (i < s.length) {
const rest = s.slice(i);
const num = /^(\d+\.?\d*|\.\d+)/.exec(rest);
if (num) { out.push(num[0]); i += num[0].length; continue; }
const word = /^[a-z]+/.exec(rest);
if (word) {
const w = word[0];
if (w === 'x' || w === 'pi' || w === 'e' || w in FUNCS) { out.push(w); i += w.length; continue; }
// «xsin» یا «pix»: نامِ چسبیده را تکه‌تکه کن
const known = ['sqrt', 'sin', 'cos', 'tan', 'abs', 'log', 'ln', 'pi', 'x', 'e'].find((k) => w.startsWith(k));
if (!known) throw new Error(`نامِ ناشناخته: ${w}`);
out.push(known); i += known.length; continue;
}
if ('+-*/^()'.includes(s[i])) { out.push(s[i]); i++; continue; }
throw new Error(`نویسه‌ی ناشناخته: ${s[i]}`);
}
return out;
}
export function parseExpr(src: string): (x: number) => number {
if (src.length > 120) throw new Error('عبارت بلند است');
const t = tokenize(src);
let p = 0;
const peek = () => t[p];
const eat = (tok?: string) => {
const v = t[p++];
if (tok !== undefined && v !== tok) throw new Error(`انتظارِ ${tok}`);
return v;
};
const startsAtom = (tok: string | undefined) => tok !== undefined && (/^[\d.]/.test(tok) || tok === 'x' || tok === 'pi' || tok === 'e' || tok === '(' || tok in FUNCS);
function expr(): Node {
let a = term();
while (peek() === '+' || peek() === '-') { const op = eat() as '+' | '-'; a = { k: 'bin', op, a, b: term() }; }
return a;
}
function term(): Node {
let a = unary();
for (;;) {
if (peek() === '*' || peek() === '/') { const op = eat() as '*' | '/'; a = { k: 'bin', op, a, b: unary() }; }
else if (startsAtom(peek())) a = { k: 'bin', op: '*', a, b: power() }; // ضربِ ضمنی
else return a;
}
}
function unary(): Node {
if (peek() === '-') { eat(); return { k: 'neg', a: unary() }; }
if (peek() === '+') { eat(); return unary(); }
return power();
}
function power(): Node {
const base = atom();
if (peek() === '^') { eat(); return { k: 'bin', op: '^', a: base, b: unary() }; }
return base;
}
function atom(): Node {
const tok = eat();
if (tok === undefined) throw new Error('عبارت ناتمام است');
if (/^[\d.]/.test(tok)) return { k: 'num', v: Number(tok) };
if (tok === 'x') return { k: 'x' };
if (tok === 'pi') return { k: 'num', v: Math.PI };
if (tok === 'e') return { k: 'num', v: Math.E };
if (tok in FUNCS) {
if (peek() === '(') { eat('('); const a = expr(); eat(')'); return { k: 'fn', f: tok, a }; }
return { k: 'fn', f: tok, a: power() };
}
if (tok === '(') { const a = expr(); eat(')'); return a; }
throw new Error(`غیرمنتظره: ${tok}`);
}
const tree = expr();
if (p !== t.length) throw new Error('عبارت اضافه دارد');
const ev = (n: Node, x: number): number => {
switch (n.k) {
case 'num': return n.v;
case 'x': return x;
case 'neg': return -ev(n.a, x);
case 'fn': return FUNCS[n.f](ev(n.a, x));
case 'bin': {
const a = ev(n.a, x), b = ev(n.b, x);
return n.op === '+' ? a + b : n.op === '-' ? a - b : n.op === '*' ? a * b : n.op === '/' ? a / b : Math.pow(a, b);
}
}
};
return (x) => ev(tree, x);
}

View File

@@ -1,5 +1,5 @@
/** /**
* سه‌یار: سیگما (Σ)، دلتا (Δ) و پی (π) — ماسکوت‌های اپ. * همراه‌های اپ: سه‌یار — سیگما (Σ)، دلتا (Δ) و پی (π) — و تدی.
* *
* هر سه از دلِ یک دفترچه‌ی بی‌نهایت آمده‌اند و هر کدام از جنسِ یک نمادِ ریاضی‌اند: * هر سه از دلِ یک دفترچه‌ی بی‌نهایت آمده‌اند و هر کدام از جنسِ یک نمادِ ریاضی‌اند:
* سیگما → راهنمای درس و آزمون دلتا → هم‌تیمیِ تمرینِ سریع پی → دستیار و همراهِ دلگرمی * سیگما → راهنمای درس و آزمون دلتا → هم‌تیمیِ تمرینِ سریع پی → دستیار و همراهِ دلگرمی
@@ -8,13 +8,14 @@
* ورودی‌ها همه از همین فایل‌اند و هیچ متنِ کاربر واردِ SVG نمی‌شود. * ورودی‌ها همه از همین فایل‌اند و هیچ متنِ کاربر واردِ SVG نمی‌شود.
*/ */
export type SeyarChar = 'sigma' | 'delta' | 'pi'; export type SeyarChar = 'sigma' | 'delta' | 'pi' | 'tedi';
export type SeyarMood = 'idle' | 'happy' | 'excited' | 'sad' | 'angry' | 'depressed'; export type SeyarMood = 'idle' | 'happy' | 'excited' | 'sad' | 'angry' | 'depressed';
const PALETTE: Record<SeyarChar, Record<string, string>> = { const PALETTE: Record<SeyarChar, Record<string, string>> = {
sigma: { body: "#4C5FD5", inner: "#7A8CFF", belly: "#EAF0FF", ink: "#232355", cheek: "#FFB2C0", mouth: "#FF6B6B", tear: "#8EC9FF", badge: "#FFD23F" }, sigma: { body: "#4C5FD5", inner: "#7A8CFF", belly: "#EAF0FF", ink: "#232355", cheek: "#FFB2C0", mouth: "#FF6B6B", tear: "#8EC9FF", badge: "#FFD23F" },
delta: { body: "#FF7A3D", inner: "#FFB25E", belly: "#FFE7C7", ink: "#6E2A12", cheek: "#FF8FA3", mouth: "#D6402A", tear: "#8EC9FF", spike: "#FFEB3B" }, delta: { body: "#FF7A3D", inner: "#FFB25E", belly: "#FFE7C7", ink: "#6E2A12", cheek: "#FF8FA3", mouth: "#D6402A", tear: "#8EC9FF", spike: "#FFEB3B" },
pi: { body: "#9B7BFF", inner: "#C6B3FF", belly: "#F1ECFF", ink: "#3B2B66", cheek: "#FFC2D1", mouth: "#FF6F91", tear: "#5EC8E8" }, pi: { body: "#9B7BFF", inner: "#C6B3FF", belly: "#F1ECFF", ink: "#3B2B66", cheek: "#FFC2D1", mouth: "#FF6F91", tear: "#5EC8E8" },
tedi: { body: "#FFC63D", inner: "#FF9E42", belly: "#FFE9B0", ink: "#4A3B32", cheek: "#FFAEB0", mouth: "#E7573F", tear: "#7EC8E3" },
}; };
const Y_SHIFT: Record<SeyarMood, number> = { idle: 0, happy: 0, excited: -4, sad: 3, angry: 0, depressed: 9 }; const Y_SHIFT: Record<SeyarMood, number> = { idle: 0, happy: 0, excited: -4, sad: 3, angry: 0, depressed: 9 };
const ARM_DELTA: Record<SeyarMood, number> = { idle: 0, happy: 2, excited: 46, sad: -4, angry: -8, depressed: -10 }; const ARM_DELTA: Record<SeyarMood, number> = { idle: 0, happy: 2, excited: 46, sad: -4, angry: -8, depressed: -10 };
@@ -122,19 +123,81 @@ function seyarPiSVG(mood: SeyarMood): string {
</g></svg>`; </g></svg>`;
} }
/**
* تدی — شکلکِ زردِ پیش‌نمایشِ موبایل (mascot-pack، ریپوی بک‌اند، پوشه‌ی
* mobile-preview). بدن، گوش‌ها، پاها و دست‌ها عیناً از همان پک است؛ چهره‌ی
* «عصبانی» و «ناامید» را پک نداشت و با همان زبانِ طراحیِ سه‌یار اضافه شد.
*/
function tediSVG(mood: SeyarMood): string {
const C = PALETTE.tedi;
const S = `stroke="${C.ink}" stroke-width="5"`;
const dy = Y_SHIFT[mood];
const restArmL = `<rect x="20" y="112" width="24" height="46" rx="12" fill="${C.body}" ${S} transform="rotate(14 32 135)"/>`;
const restArmR = `<rect x="156" y="112" width="24" height="46" rx="12" fill="${C.body}" ${S} transform="rotate(-14 168 135)"/>`;
const upArmL = `<rect x="14" y="70" width="24" height="50" rx="12" fill="${C.body}" ${S} transform="rotate(28 26 95)"/>`;
const upArmR = `<rect x="162" y="70" width="24" height="50" rx="12" fill="${C.body}" ${S} transform="rotate(-28 174 95)"/>`;
const droopArmL = `<rect x="24" y="118" width="24" height="46" rx="12" fill="${C.body}" ${S} transform="rotate(4 36 141)"/>`;
const droopArmR = `<rect x="152" y="118" width="24" height="46" rx="12" fill="${C.body}" ${S} transform="rotate(-4 164 141)"/>`;
let arms = restArmL + restArmR, hands = '', eyes = '', mouth = '', cheeks = '', extra = '';
if (mood === 'happy' || mood === 'excited') {
eyes = `<path d="M66 100 q10 -13 20 0" fill="none" ${S} stroke-linecap="round"/><path d="M114 100 q10 -13 20 0" fill="none" ${S} stroke-linecap="round"/>`;
mouth = `<path d="M78 122 q22 34 44 0 q-22 12 -44 0z" fill="${C.mouth}" ${S} stroke-linejoin="round"/>`;
cheeks = `<ellipse cx="64" cy="122" rx="12" ry="8" fill="${C.cheek}"/><ellipse cx="136" cy="122" rx="12" ry="8" fill="${C.cheek}"/>`;
if (mood === 'excited') {
arms = upArmL + upArmR;
hands = `<circle cx="18" cy="66" r="12" fill="${C.body}" ${S}/><circle cx="182" cy="66" r="12" fill="${C.body}" ${S}/>`;
extra = `<g stroke="${C.inner}" stroke-width="5" stroke-linecap="round"><path d="M40 30 l-8 -12"/><path d="M160 30 l8 -12"/><path d="M100 8 v-12"/></g>`;
}
} else if (mood === 'sad') {
eyes = `<circle cx="78" cy="104" r="8" fill="${C.ink}"/><circle cx="122" cy="104" r="8" fill="${C.ink}"/>`
+ `<path d="M68 94 q10 -6 20 -2" fill="none" ${S} stroke-linecap="round"/><path d="M112 92 q10 -4 20 2" fill="none" ${S} stroke-linecap="round"/>`;
mouth = `<path d="M82 142 q18 -18 36 0" fill="none" ${S} stroke-linecap="round"/>`;
extra = `<path d="M78 112 q-4 12 0 18 q4 -6 0 -18z" fill="${C.tear}"/>`;
} else if (mood === 'angry') {
eyes = `<path d="M68 98 l16 5 l-5 9 z" fill="${C.ink}"/><path d="M132 98 l-16 5 l5 9 z" fill="${C.ink}"/>`
+ `<path d="M64 88 l22 7" ${S} stroke-linecap="round"/><path d="M136 88 l-22 7" ${S} stroke-linecap="round"/>`;
mouth = `<path d="M82 136 l6 -6 l6 6 l6 -6 l6 6 l6 -6 l6 6" fill="none" ${S} stroke-linecap="round" stroke-linejoin="round"/>`;
cheeks = `<ellipse cx="62" cy="124" rx="10" ry="6" fill="${C.mouth}" opacity=".5"/><ellipse cx="138" cy="124" rx="10" ry="6" fill="${C.mouth}" opacity=".5"/>`;
extra = `<g stroke="${C.ink}" stroke-width="4" stroke-linecap="round" opacity=".75"><path d="M30 20 q4 -8 -2 -14"/><path d="M170 20 q-4 -8 2 -14"/></g>`;
} else if (mood === 'depressed') {
arms = droopArmL + droopArmR;
eyes = `<path d="M68 104 q10 5 20 0" fill="none" stroke="${C.ink}" stroke-width="6" stroke-linecap="round"/><path d="M112 104 q10 5 20 0" fill="none" stroke="${C.ink}" stroke-width="6" stroke-linecap="round"/>`;
mouth = `<path d="M88 136 q12 6 24 1" fill="none" ${S} stroke-linecap="round"/>`;
extra = `<g><ellipse cx="100" cy="-6" rx="26" ry="10" fill="#B9C2CC" opacity=".9"/><ellipse cx="84" cy="0" rx="16" ry="8" fill="#B9C2CC" opacity=".9"/><ellipse cx="118" cy="0" rx="15" ry="8" fill="#B9C2CC" opacity=".9"/>`
+ `<g stroke="#8FA0B3" stroke-width="3.5" stroke-linecap="round" opacity=".8"><line x1="86" y1="10" x2="82" y2="20"/><line x1="100" y1="12" x2="96" y2="24"/><line x1="114" y1="10" x2="110" y2="22"/></g></g>`;
} else {
eyes = `<circle cx="78" cy="102" r="9" fill="${C.ink}"/><circle cx="122" cy="102" r="9" fill="${C.ink}"/>`
+ `<circle cx="81" cy="99" r="3" fill="#fff"/><circle cx="125" cy="99" r="3" fill="#fff"/>`;
mouth = `<path d="M84 128 q16 18 32 0" fill="none" ${S} stroke-linecap="round"/>`;
}
return `<svg viewBox="-14 -34 228 250" xmlns="http://www.w3.org/2000/svg"><g transform="translate(0 ${dy})">
${extra}
<ellipse cx="74" cy="185" rx="20" ry="13" fill="${C.inner}" ${S}/><ellipse cx="126" cy="185" rx="20" ry="13" fill="${C.inner}" ${S}/>
${arms}
<circle cx="56" cy="46" r="24" fill="${C.body}" ${S}/><circle cx="144" cy="46" r="24" fill="${C.body}" ${S}/>
<circle cx="56" cy="46" r="12" fill="${C.inner}"/><circle cx="144" cy="46" r="12" fill="${C.inner}"/>
<ellipse cx="100" cy="116" rx="72" ry="70" fill="${C.body}" ${S}/>
<ellipse cx="100" cy="133" rx="44" ry="45" fill="${C.belly}"/>
${hands}${cheeks}<g>${eyes}</g>${mouth}
</g></svg>`;
}
export const SEYAR_SVG: Record<SeyarChar, (mood: SeyarMood) => string> = { export const SEYAR_SVG: Record<SeyarChar, (mood: SeyarMood) => string> = {
sigma: seyarSigmaSVG, delta: seyarDeltaSVG, pi: seyarPiSVG, sigma: seyarSigmaSVG, delta: seyarDeltaSVG, pi: seyarPiSVG, tedi: tediSVG,
}; };
export const SEYAR_INFO: Record<SeyarChar, { name: string; symbol: string; role: string }> = { export const SEYAR_INFO: Record<SeyarChar, { name: string; symbol: string; role: string }> = {
sigma: { name: "سیگما", symbol: "Σ", role: "راهنمای آزمون و کوییز" }, sigma: { name: "سیگما", symbol: "Σ", role: "راهنمای آزمون و کوییز" },
delta: { name: "دلتا", symbol: "Δ", role: "هم‌تیمیِ تمرین‌های سریع" }, delta: { name: "دلتا", symbol: "Δ", role: "هم‌تیمیِ تمرین‌های سریع" },
pi: { name: "پی", symbol: "π", role: "همراهِ دلگرمی" }, pi: { name: "پی", symbol: "π", role: "همراهِ دلگرمی" },
tedi: { name: "تدی", symbol: "★", role: "رفیقِ همیشگیِ تدریسینو" },
}; };
/** slugِ کاراکترِ سرور (sigma/delta/pi) را به یکی از سه‌یار نگاشت می‌کند. */ /** slugِ کاراکترِ سرور (sigma/delta/pi/tedi) را به یکی از همراه‌ها نگاشت می‌کند. */
export function seyarFromSlug(slug: string | null | undefined): SeyarChar | null { export function seyarFromSlug(slug: string | null | undefined): SeyarChar | null {
const s = (slug ?? '').toLowerCase(); const s = (slug ?? '').toLowerCase();
if (s === 'sigma' || s === 'delta' || s === 'pi') return s; if (s === 'sigma' || s === 'delta' || s === 'pi' || s === 'tedi') return s;
return null; return null;
} }

View File

@@ -7,7 +7,8 @@ export type Route =
| { name: 'profile' } | { name: 'profile' }
| { name: 'unit'; unitId: string } | { name: 'unit'; unitId: string }
| { name: 'lesson'; lessonId: number; nodeId?: string } | { name: 'lesson'; lessonId: number; nodeId?: string }
| { name: 'quiz'; lessonId: number; nodeId?: string } /** بدونِ lessonId یعنی آزمونِ کلِ جزیره (nodeId لازم است). */
| { name: 'quiz'; lessonId?: number; nodeId?: string }
| { name: 'notifications' } | { name: 'notifications' }
| { name: 'leaderboard' } | { name: 'leaderboard' }
| { name: 'characters' } | { name: 'characters' }

View File

@@ -7,6 +7,7 @@ import { askStore, useAskState, type ChatMessage } from '@/state/askStore';
import { SeyarMascot } from '@/mascots/SeyarMascot'; import { SeyarMascot } from '@/mascots/SeyarMascot';
import { useToast } from '@/components/Toast'; import { useToast } from '@/components/Toast';
import { Screen, ScreenHeader } from '@/components/ui'; import { Screen, ScreenHeader } from '@/components/ui';
import { Diagram } from '@/components/Diagram';
import { friendlyError } from '@/lib/errors'; import { friendlyError } from '@/lib/errors';
import { toFa } from '@/lib/format'; import { toFa } from '@/lib/format';
@@ -39,7 +40,7 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
askStore.setHistory( askStore.setHistory(
r.messages r.messages
.filter((m) => m.role === 'user' || m.role === 'assistant') .filter((m) => m.role === 'user' || m.role === 'assistant')
.map((m) => ({ role: m.role as ChatMessage['role'], text: m.text, messageId: m.messageId })), .map((m) => ({ role: m.role as ChatMessage['role'], text: m.text, messageId: m.messageId, diagram: m.diagram ?? null })),
), ),
) )
.catch(() => askStore.reset(null)); .catch(() => askStore.reset(null));
@@ -85,6 +86,7 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
messageId: done.messageId, messageId: done.messageId,
usedChunks: done.usedChunks, usedChunks: done.usedChunks,
followUps: done.suggestedFollowUps, followUps: done.suggestedFollowUps,
diagram: done.diagram ?? null,
}); });
askStore.setConversation(done.conversationId); askStore.setConversation(done.conversationId);
setQuota(done.remainingQuota); setQuota(done.remainingQuota);
@@ -188,7 +190,7 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
); );
} }
function Bubble({ role, children }: { role: 'user' | 'assistant'; children: React.ReactNode }) { function Bubble({ role, children, extra }: { role: 'user' | 'assistant'; children: React.ReactNode; extra?: React.ReactNode }) {
const mine = role === 'user'; const mine = role === 'user';
return ( return (
<div className={`flex gap-2 ${mine ? 'flex-row-reverse' : ''}`}> <div className={`flex gap-2 ${mine ? 'flex-row-reverse' : ''}`}>
@@ -203,6 +205,7 @@ function Bubble({ role, children }: { role: 'user' | 'assistant'; children: Reac
}`} }`}
> >
{children} {children}
{extra}
</div> </div>
</div> </div>
); );
@@ -251,7 +254,9 @@ function Message({
return ( return (
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Bubble role="assistant">{message.text}</Bubble> <Bubble role="assistant" extra={message.diagram ? <div className="whitespace-normal"><Diagram spec={message.diagram} /></div> : undefined}>
{message.text}
</Bubble>
<div className="flex flex-wrap items-center gap-2 pr-10"> <div className="flex flex-wrap items-center gap-2 pr-10">
{message.failedQuestion ? ( {message.failedQuestion ? (
<button type="button" disabled={busy} onClick={() => onRetry(message)} className="flex items-center gap-1 rounded-full bg-white px-3 py-1 text-xs font-bold text-coral ring-1 ring-line disabled:opacity-50"> <button type="button" disabled={busy} onClick={() => onRetry(message)} className="flex items-center gap-1 rounded-full bg-white px-3 py-1 text-xs font-bold text-coral ring-1 ring-line disabled:opacity-50">

View File

@@ -1,180 +1,139 @@
import { Bell, Brain, ChevronLeft, Coins, Flame, Map, Play, Star, Trophy } from 'lucide-react'; import { useEffect, useRef, useState } from 'react';
import { Bell, ChevronLeft, Coins, Flame, Star } from 'lucide-react';
import type { HomeNode } from '@/api';
import { useAppData } from '@/state/AppData'; import { useAppData } from '@/state/AppData';
import { findContinue, nextLessonOf, unitColor, unitProgress } from '@/state/curriculum'; import { findContinue, unitColor, unitProgress } from '@/state/curriculum';
import { useNav } from '@/nav/Navigator'; import { useNav } from '@/nav/Navigator';
import { SeyarMascot } from '@/mascots/SeyarMascot'; import { SeyarMascot } from '@/mascots/SeyarMascot';
import { Card, ErrorState, ProgressBar, Screen } from '@/components/ui'; import { IslandPath } from '@/components/IslandPath';
import { iconFor } from '@/components/NodeIcon'; import { IslandSheet } from '@/components/IslandSheet';
import { EmptyState, ErrorState, LoadingState, Screen } from '@/components/ui';
import { shade } from '@/lib/color';
import { toFa } from '@/lib/format'; import { toFa } from '@/lib/format';
/**
* خانه = خودِ مسیر، مثلِ دولینگو: همه‌ی فصل‌های پایه پشتِ هم، و جزیره‌ی فعلی با
* یک دکمه‌ی «شروع» روی سرش. قبلاً خانه داشبوردی از شش کارت بود (ادامه، نقشه،
* دستیار، فصل‌ها، چالش) و بچه نمی‌دانست کدام را بزند؛ حالا یک کارِ واضح دارد.
*/
export function HomeScreen() { export function HomeScreen() {
const { profile, gradeTitle, summary, home, homeError, companion, unreadCount, refreshHome } = useAppData(); const { profile, gradeTitle, summary, home, homeError, companion, unreadCount, refreshHome } = useAppData();
const nav = useNav(); const nav = useNav();
const [openNode, setOpenNode] = useState<{ node: HomeNode; color: string } | null>(null);
const currentRef = useRef<HTMLDivElement>(null);
const scrolled = useRef(false);
const cont = findContinue(home); const cont = findContinue(home);
const contLesson = cont ? nextLessonOf(cont.node) : null;
const contColor = cont ? unitColor(cont.unitIndex) : '#ED6956';
const contProgress = cont ? unitProgress(cont.unit) : null;
const openLesson = (quiz = false) => { // بارِ اول تا جزیره‌ی فعلی اسکرول کن، نه هر بار که نقشه تازه می‌شود.
if (!cont || contLesson == null) return; useEffect(() => {
nav.push({ name: quiz ? 'quiz' : 'lesson', lessonId: contLesson, nodeId: cont.node.id }); if (!home || scrolled.current) return;
}; scrolled.current = true;
const frame = requestAnimationFrame(() => currentRef.current?.scrollIntoView({ block: 'center' }));
return () => cancelAnimationFrame(frame);
}, [home]);
const name = profile.name || 'دوستِ من';
const bubble = !home
? 'دارم مسیرت رو میارم…'
: !home.units.length
? 'هنوز درسی برای این پایه ثبت نشده.'
: cont
? <>سلام {name}! 👋 نوبتِ جزیره‌ی <b>«{cont.node.title}»</b>ـه. دکمه‌ی <b>شروع</b> رو بزن.</>
: <>آفرین {name}! همه‌ی جزیره‌های این پایه رو تموم کردی 🎉</>;
const stats = [ const stats = [
{ icon: Flame, value: summary?.streak.current ?? 0, label: 'روزِ پیاپی', fg: '#FF7A3D', bg: '#FFF1E6', fill: true, to: 'streak' as const }, { icon: Flame, value: summary?.streak.current ?? 0, label: 'روزِ پیاپی', color: '#FF7A3D', fill: true, to: 'streak' as const },
{ icon: Star, value: summary?.xp ?? 0, label: 'امتیاز', fg: '#E8A400', bg: '#FFF8E1', fill: false, to: 'leaderboard' as const }, { icon: Star, value: summary?.xp ?? 0, label: 'امتیاز', color: '#E8A400', fill: true, to: 'leaderboard' as const },
{ icon: Coins, value: summary?.coins ?? 0, label: 'سکه', fg: '#9B7BFF', bg: '#F5F1FF', fill: false, to: 'characters' as const }, { icon: Coins, value: summary?.coins ?? 0, label: 'سکه', color: '#9B7BFF', fill: false, to: 'characters' as const },
]; ];
return ( return (
<Screen> <Screen>
<div className="flex-1 overflow-y-auto"> <header className="relative z-20 flex-shrink-0 rounded-b-[28px] bg-coral px-4 pb-4 pt-safe text-white shadow-md shadow-coral/20">
<header className="relative overflow-hidden rounded-b-[32px] bg-coral px-5 pb-11 pt-safe text-white"> <div className="flex items-center gap-2 pt-4">
<div className="pointer-events-none absolute -left-10 -top-10 h-48 w-48 rounded-full bg-white/5" />
<div className="relative flex items-center justify-between pt-8">
<div className="min-w-0">
<p className="text-sm text-white/75">سلام {profile.name || 'دوستِ من'}!</p>
<h1 className="mt-0.5 text-xl font-black leading-snug">آماده‌ای ریاضی رو ادامه بدیم؟</h1>
{gradeTitle && ( {gradeTitle && (
<span className="mt-1.5 inline-block rounded-full bg-white/15 px-2.5 py-0.5 text-xs text-white/85">{gradeTitle}</span> <button type="button" onClick={() => nav.switchTab('profile')} className="rounded-full bg-white/20 px-3 py-1.5 text-xs font-black">
)} {gradeTitle}
</div>
<div className="flex flex-shrink-0 items-center gap-2">
<button type="button" aria-label="جدولِ امتیازها" onClick={() => nav.push({ name: 'leaderboard' })} className="flex h-10 w-10 items-center justify-center rounded-full bg-white/15">
<Trophy size={18} />
</button> </button>
<button type="button" aria-label="اعلان‌ها" onClick={() => nav.push({ name: 'notifications' })} className="relative flex h-10 w-10 items-center justify-center rounded-full bg-white/15"> )}
<Bell size={18} /> <div className="mr-auto flex items-center gap-1.5">
{stats.map(({ icon: Icon, value, label, color, fill, to }) => (
<button
key={label}
type="button"
aria-label={`${label}: ${toFa(value)}`}
onClick={() => nav.push({ name: to })}
className="flex items-center gap-1 rounded-full bg-white px-2.5 py-1.5 text-xs font-black text-ink shadow-sm"
>
<Icon size={15} style={{ color }} fill={fill ? color : 'none'} strokeWidth={2.4} />
{toFa(value)}
</button>
))}
<button type="button" aria-label="اعلان‌ها" onClick={() => nav.push({ name: 'notifications' })} className="relative flex h-8 w-8 items-center justify-center rounded-full bg-white/20">
<Bell size={16} />
{unreadCount > 0 && ( {unreadCount > 0 && (
<span className="absolute -left-0.5 -top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-[#FFD23F] px-1 text-[9px] font-black text-ink"> <span className="absolute -left-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-[#FFD23F] px-1 text-[9px] font-black text-ink">
{unreadCount > 9 ? '۹+' : toFa(unreadCount)} {unreadCount > 9 ? '۹+' : toFa(unreadCount)}
</span> </span>
)} )}
</button> </button>
</div> </div>
</div> </div>
<div className="mt-3 flex items-end gap-2">
<SeyarMascot char={companion} mood={cont ? 'happy' : home ? 'excited' : 'idle'} size={64} bob />
<div className="relative mb-2 flex-1 rounded-2xl rounded-br-sm bg-white px-3.5 py-2.5 text-[13px] leading-6 text-ink shadow-sm">
{bubble}
</div>
</div>
</header> </header>
<div className="relative z-10 -mt-7 px-4"> <div className="flex-1 overflow-y-auto">
<div className="flex rounded-3xl border border-line bg-white shadow-lg shadow-black/10"> {!home ? (
{stats.map(({ icon: Icon, value, label, fg, bg, fill, to }) => ( homeError ? <ErrorState error={homeError} onRetry={() => void refreshHome()} /> : <LoadingState text="دارم مسیرت رو میارم…" />
<button key={label} type="button" onClick={() => nav.push({ name: to })} className="flex flex-1 flex-col items-center py-3.5"> ) : home.units.length === 0 ? (
<span className="mb-1.5 flex h-10 w-10 items-center justify-center rounded-full" style={{ backgroundColor: bg }}> <EmptyState text="هنوز درسی برای این پایه ثبت نشده" />
<Icon size={18} style={{ color: fg }} strokeWidth={2.4} fill={fill ? fg : 'none'} />
</span>
<span className="text-lg font-black leading-none text-ink">{toFa(value)}</span>
<span className="mt-1 text-[10px] text-muted">{label}</span>
</button>
))}
</div>
</div>
<div className="flex flex-col gap-4 px-4 pb-6 pt-4">
{homeError && !home ? (
<ErrorState error={homeError} onRetry={() => void refreshHome()} />
) : cont && contProgress ? (
<Card className="shadow-md shadow-coral/10">
<div className="mb-3 flex items-center justify-between">
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-muted">ادامه از جایی که موندی</p>
<h3 className="mt-0.5 text-base font-black text-ink">{cont.node.title}</h3>
<p className="mt-0.5 truncate text-xs text-muted">
{cont.unit.title} · {toFa(contProgress.done)} از {toFa(contProgress.total)} جزیره
</p>
</div>
<SeyarMascot char={companion} mood="happy" size={62} className="mr-3" />
</div>
<div className="mb-3">
<ProgressBar percent={contProgress.percent} color={contColor} />
</div>
<button
type="button"
onClick={() => openLesson()}
className="flex w-full items-center justify-center gap-2 rounded-2xl py-3 text-sm font-bold text-white active:scale-[0.98]"
style={{ backgroundColor: contColor }}
>
<Play size={15} fill="white" /> ادامه بده
</button>
</Card>
) : ( ) : (
<Card className="flex items-center gap-3 p-5"> <div className="flex flex-col gap-2 px-4 pb-10 pt-4">
<SeyarMascot char={companion} mood={home ? 'excited' : 'idle'} size={56} />
<p className="text-sm font-medium text-muted">
{!home ? 'دارم مسیرت رو میارم…' : home.units.length ? 'همه‌ی جزیره‌های این پایه رو تموم کردی! 🎉' : 'هنوز درسی برای این پایه ثبت نشده'}
</p>
</Card>
)}
<div className="grid grid-cols-2 gap-3">
<Card onClick={() => nav.switchTab('map')} className="p-4">
<span className="mb-2.5 flex h-10 w-10 items-center justify-center rounded-xl bg-[#EAF2F6]">
<Map size={20} className="text-steel" />
</span>
<div className="text-sm font-bold text-ink">نقشه‌ی فصل‌ها</div>
<div className="mt-0.5 text-[11px] text-muted">ببین کجای مسیری</div>
</Card>
<button
type="button"
onClick={() => nav.switchTab('ask')}
className="rounded-3xl bg-violet p-4 text-right shadow-md shadow-violet/30 active:scale-[0.98]"
>
<span className="mb-2.5 flex h-10 w-10 items-center justify-center rounded-xl bg-white/15">
<Brain size={20} className="text-white" />
</span>
<div className="text-sm font-bold text-white">از پی بپرس</div>
<div className="mt-0.5 text-[11px] text-white/75">هر جا گیر کردی</div>
</button>
</div>
{home && home.units.length > 0 && (
<section>
<div className="mb-3 flex items-baseline justify-between">
<h2 className="text-sm font-black text-ink">فصل‌های {gradeTitle || 'این پایه'}</h2>
<span className="text-[11px] text-muted">{toFa(home.units.length)} فصل</span>
</div>
<div className="no-scrollbar -mx-4 flex gap-3 overflow-x-auto px-4 pb-1">
{home.units.map((unit, i) => { {home.units.map((unit, i) => {
const p = unitProgress(unit);
const color = unitColor(i); const color = unitColor(i);
const Icon = iconFor(unit.nodes[0]?.icon, i); const p = unitProgress(unit);
const isCurrentUnit = cont?.unit.id === unit.id;
return ( return (
<section key={unit.id} aria-label={unit.title}>
<button <button
key={unit.id}
type="button" type="button"
onClick={() => nav.push({ name: 'unit', unitId: unit.id })} onClick={() => nav.push({ name: 'unit', unitId: unit.id })}
className="w-[140px] flex-shrink-0 rounded-2xl border border-line bg-white p-3.5 text-right shadow-sm active:scale-95" className="flex w-full items-center gap-3 rounded-2xl px-4 py-3 text-right text-white active:translate-y-0.5"
style={{ backgroundColor: color, boxShadow: `0 4px 0 ${shade(color, -0.25)}` }}
> >
<span className="mb-2.5 flex h-10 w-10 items-center justify-center rounded-xl" style={{ backgroundColor: `${color}1f` }}>
<Icon size={19} style={{ color }} />
</span>
<div className="line-clamp-2 min-h-[2.5em] text-[12.5px] font-bold leading-snug text-ink">{unit.title}</div>
<div className="mt-1 text-[10px] text-muted">{toFa(p.done)} از {toFa(p.total)} جزیره</div>
<div className="mt-2">
<ProgressBar percent={p.percent} color={color} track="#f0eeff" height={6} />
</div>
</button>
);
})}
</div>
</section>
)}
{cont && contLesson != null && (
<button
type="button"
onClick={() => openLesson(true)}
className="flex items-center gap-3 rounded-3xl bg-orange p-4 text-right shadow-md shadow-orange/25 active:scale-[0.98]"
>
<SeyarMascot char="delta" mood="excited" size={64} />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="text-sm font-black text-white">یه سؤال دیگه… فقط یکی!</div> <div className="text-[11px] font-bold text-white/80">
<div className="mt-0.5 truncate text-[11px] text-white/80">چالشِ سریع با دلتا · {cont.node.title}</div> فصلِ {toFa(i + 1)} · {toFa(p.done)} از {toFa(p.total)} جزیره
{isCurrentUnit && <span className="mr-1.5 rounded-full bg-white/25 px-2 py-0.5">الان اینجایی</span>}
</div>
<div className="mt-0.5 truncate text-[15px] font-black">{unit.title.replace(/^فصل\s*\S+\s*[::]\s*/, '')}</div>
</div> </div>
<ChevronLeft size={18} className="flex-shrink-0 text-white/80" /> <ChevronLeft size={18} className="flex-shrink-0 text-white/80" />
</button> </button>
<div className="pt-12">
<IslandPath
ref={isCurrentUnit ? currentRef : undefined}
nodes={unit.nodes}
color={color}
currentId={isCurrentUnit ? cont!.node.id : null}
onOpen={(node) => setOpenNode({ node, color })}
onStart={(node) => nav.push({ name: 'quiz', nodeId: node.id })}
/>
</div>
</section>
);
})}
</div>
)} )}
</div> </div>
</div>
<IslandSheet node={openNode?.node ?? null} color={openNode?.color ?? '#ED6956'} onClose={() => setOpenNode(null)} />
</Screen> </Screen>
); );
} }

View File

@@ -23,6 +23,12 @@ const SLIDES: { char: SeyarChar; mood: SeyarMood; bg: string; sub: string; title
title: 'از پی بپرس', title: 'از پی بپرس',
desc: 'من پی‌ام. هر سوالی داشتی، یا جوابی رو غلط زدی، ازم بپرس؛ از روی کتابِ خودت قدم‌به‌قدم توضیح می‌دم.', desc: 'من پی‌ام. هر سوالی داشتی، یا جوابی رو غلط زدی، ازم بپرس؛ از روی کتابِ خودت قدم‌به‌قدم توضیح می‌دم.',
}, },
{
char: 'tedi', mood: 'excited', bg: '#D9820B',
sub: 'چهار همراه، یکی مالِ تو',
title: 'همراهت رو انتخاب کن',
desc: 'منم تدی! هر چی امتیازت بیشتر بشه، همراه‌های تازه باز می‌شن. از صفحه‌ی «من» هر کدوممون رو خواستی کنارت می‌ذاری.',
},
]; ];
export function OnboardingScreen({ onDone }: { onDone: () => void }) { export function OnboardingScreen({ onDone }: { onDone: () => void }) {

View File

@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react';
import { AnimatePresence, motion } from 'motion/react'; import { AnimatePresence, motion } from 'motion/react';
import confetti from 'canvas-confetti'; import confetti from 'canvas-confetti';
import { Brain, Check, ChevronLeft, Coins, Flag, Heart, Infinity as InfinityIcon, Star, X } from 'lucide-react'; import { Brain, Check, ChevronLeft, Coins, Flag, Heart, Infinity as InfinityIcon, Star, X } from 'lucide-react';
import { api, ApiError, type AnswerResult, type Hearts, type LessonComplete, type Question, type ReportReason } from '@/api'; import { api, ApiError, type AnswerResult, type Hearts, type Question, type ReportReason } from '@/api';
import { useAppData } from '@/state/AppData'; import { useAppData } from '@/state/AppData';
import { findNode, findNodeOfLesson, nextLessonOf, unitColor } from '@/state/curriculum'; import { findNode, findNodeOfLesson, nextLessonOf, unitColor } from '@/state/curriculum';
import { useNav } from '@/nav/Navigator'; import { useNav } from '@/nav/Navigator';
@@ -23,16 +23,35 @@ type Phase =
| { kind: 'noHearts'; nextRefillAt: string | null } | { kind: 'noHearts'; nextRefillAt: string | null }
| { kind: 'finishing' } | { kind: 'finishing' }
| { kind: 'finishError'; error: unknown } | { kind: 'finishError'; error: unknown }
| { kind: 'result'; result: LessonComplete }; | { kind: 'result'; result: QuizResult };
/** تمرینِ یک درس: سوال به سوال، با قلب، و در پایان ثبتِ نتیجه. */ /** نتیجه‌ی مشترکِ پایانِ درس و پایانِ جزیره. */
export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: string }) { interface QuizResult {
stars: number;
xpEarned: number;
coinsEarned: number;
/** فقط در آزمونِ جزیره. */
nextNodeId?: string | null;
}
type PlayQuestion = Question & { lessonId?: string };
/**
* تمرین: سوال به سوال، با قلب، و در پایان ثبتِ نتیجه.
*
* دو حالت دارد. با `lessonId` تمرینِ همان یک درس است (هر درس معمولاً فقط یک
* سوال دارد). بدونِ آن آزمونِ کلِ جزیره است (`/nodes/{id}/quiz`، ۴ سوال از
* درس‌های مختلف) که با تمام شدنش جزیره تمام و جزیره‌ی بعدی باز می‌شود؛ مسیرِ
* اصلیِ بازی همین است.
*/
export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: string }) {
const { home, companion, refreshHome, refreshSummary } = useAppData(); const { home, companion, refreshHome, refreshSummary } = useAppData();
const nav = useNav(); const nav = useNav();
const toast = useToast(); const toast = useToast();
const [phase, setPhase] = useState<Phase>({ kind: 'loading' }); const [phase, setPhase] = useState<Phase>({ kind: 'loading' });
const [questions, setQuestions] = useState<Question[]>([]); const [questions, setQuestions] = useState<PlayQuestion[]>([]);
const nodeMode = lessonId === undefined;
const [hearts, setHearts] = useState<Hearts | null>(null); const [hearts, setHearts] = useState<Hearts | null>(null);
const [index, setIndex] = useState(0); const [index, setIndex] = useState(0);
const [selected, setSelected] = useState<string | null>(null); const [selected, setSelected] = useState<string | null>(null);
@@ -47,7 +66,7 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
const answerKeys = useRef(new Map<string, string>()); const answerKeys = useRef(new Map<string, string>());
const completeKey = useRef(newIdempotencyKey()); const completeKey = useRef(newIdempotencyKey());
const ref = findNode(home, nodeId) ?? findNodeOfLesson(home, lessonId); const ref = findNode(home, nodeId) ?? (lessonId !== undefined ? findNodeOfLesson(home, lessonId) : null);
const color = ref ? unitColor(ref.unitIndex) : '#ED6956'; const color = ref ? unitColor(ref.unitIndex) : '#ED6956';
const question = questions[index]; const question = questions[index];
const isLast = index === questions.length - 1; const isLast = index === questions.length - 1;
@@ -58,7 +77,10 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
setPhase({ kind: 'loading' }); setPhase({ kind: 'loading' });
Promise.all([api.lesson.questions(lessonId), api.hearts().catch(() => null)]) const load: Promise<PlayQuestion[]> = nodeMode
? api.nodeQuiz(nodeId ?? '').then((r) => r.questions)
: api.lesson.questions(lessonId);
Promise.all([load, api.hearts().catch(() => null)])
.then(([qs, h]) => { .then(([qs, h]) => {
if (!alive) return; if (!alive) return;
setQuestions(qs); setQuestions(qs);
@@ -76,7 +98,7 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
return () => { return () => {
alive = false; alive = false;
}; };
}, [lessonId, reload]); }, [lessonId, nodeId, nodeMode, reload]);
async function choose(optionId: string) { async function choose(optionId: string) {
if (!question || selected !== null || submitting) return; if (!question || selected !== null || submitting) return;
@@ -127,11 +149,10 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
async function finish() { async function finish() {
setPhase({ kind: 'finishing' }); setPhase({ kind: 'finishing' });
try { try {
const result = await api.lesson.complete( const payload = { correctCount, totalQuestions: questions.length };
lessonId, const result: QuizResult = nodeMode
{ correctCount, totalQuestions: questions.length }, ? await api.nodeQuizComplete(nodeId ?? '', payload, completeKey.current)
completeKey.current, : await api.lesson.complete(lessonId, payload, completeKey.current);
);
setPhase({ kind: 'result', result }); setPhase({ kind: 'result', result });
sounds.finish(); sounds.finish();
if (result.stars >= 2) void confetti({ particleCount: 90, spread: 70, origin: { y: 0.35 } }); if (result.stars >= 2) void confetti({ particleCount: 90, spread: 70, origin: { y: 0.35 } });
@@ -143,7 +164,12 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
} }
const askWhy = () => const askWhy = () =>
nav.push({ name: 'ask', lessonId, questionId: question?.id, prompt: 'چرا جوابم غلط بود؟ اشتباهم کجاست؟' }); nav.push({
name: 'ask',
lessonId: lessonId ?? (question?.lessonId ? Number(question.lessonId) : undefined),
questionId: question?.id,
prompt: 'چرا جوابم غلط بود؟ اشتباهم کجاست؟',
});
/* ─── حالت‌های غیرِ بازی ─────────────────────────────────────────────── */ /* ─── حالت‌های غیرِ بازی ─────────────────────────────────────────────── */
@@ -159,7 +185,7 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
); );
} }
if (phase.kind === 'noHearts') return <NoHearts nextRefillAt={phase.nextRefillAt} />; if (phase.kind === 'noHearts') return <NoHearts nextRefillAt={phase.nextRefillAt} />;
if (phase.kind === 'result') return <Result result={phase.result} total={questions.length} correct={correctCount} lessonId={lessonId} nodeId={ref?.node.id} color={color} />; if (phase.kind === 'result') return <Result result={phase.result} total={questions.length} correct={correctCount} lessonId={lessonId} nodeId={ref?.node.id ?? nodeId} color={color} />;
if (phase.kind === 'finishing' || phase.kind === 'finishError') { if (phase.kind === 'finishing' || phase.kind === 'finishError') {
return ( return (
<Screen> <Screen>
@@ -182,7 +208,7 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
return ( return (
<Screen> <Screen>
<div className="flex flex-1 flex-col justify-center gap-4 px-8"> <div className="flex flex-1 flex-col justify-center gap-4 px-8">
<ErrorState text="این درس هنوز سوالی نداره" /> <ErrorState text={nodeMode ? 'این جزیره هنوز سوالی نداره' : 'این درس هنوز سوالی نداره'} />
<SecondaryButton onClick={nav.back}>برگشت</SecondaryButton> <SecondaryButton onClick={nav.back}>برگشت</SecondaryButton>
</div> </div>
</Screen> </Screen>
@@ -257,7 +283,7 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
onClick={() => void choose(option.id)} onClick={() => void choose(option.id)}
disabled={selected !== null && !answer} disabled={selected !== null && !answer}
whileTap={{ scale: selected === null ? 0.95 : 1 }} whileTap={{ scale: selected === null ? 0.95 : 1 }}
className={`flex min-h-14 items-center justify-center gap-1.5 rounded-2xl border-2 px-3 py-3 font-black transition-colors ${shortOptions ? 'text-xl' : 'text-base'} ${optionClass(option.id)}`} className={`flex min-h-14 items-center justify-center gap-1.5 rounded-2xl border-2 border-b-4 px-3 py-3 font-black transition-colors ${shortOptions ? 'text-xl' : 'text-base'} ${optionClass(option.id)}`}
> >
<span dir="auto">{option.value}</span> <span dir="auto">{option.value}</span>
{answer && option.id === answer.correctOptionId && <Check size={16} className="text-green-500" strokeWidth={3} />} {answer && option.id === answer.correctOptionId && <Check size={16} className="text-green-500" strokeWidth={3} />}
@@ -281,7 +307,9 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st
</div> </div>
<Sheet open={confirmExit} onClose={() => setConfirmExit(false)} title="از تمرین بیرون بری؟"> <Sheet open={confirmExit} onClose={() => setConfirmExit(false)} title="از تمرین بیرون بری؟">
<p className="mb-5 text-sm leading-7 text-muted">جواب‌هایی که دادی ثبت شده، ولی این درس تا آخرش رو نزنی تموم‌شده حساب نمی‌شه.</p> <p className="mb-5 text-sm leading-7 text-muted">
جواب‌هایی که دادی ثبت شده، ولی {nodeMode ? 'این جزیره' : 'این درس'} تا آخرش رو نزنی تموم‌شده حساب نمی‌شه.
</p>
<div className="flex flex-col gap-2.5"> <div className="flex flex-col gap-2.5">
<PrimaryButton color={color} onClick={() => setConfirmExit(false)}>ادامه می‌دم</PrimaryButton> <PrimaryButton color={color} onClick={() => setConfirmExit(false)}>ادامه می‌دم</PrimaryButton>
<SecondaryButton onClick={() => { setConfirmExit(false); nav.back(); }}>خروج</SecondaryButton> <SecondaryButton onClick={() => { setConfirmExit(false); nav.back(); }}>خروج</SecondaryButton>
@@ -346,17 +374,25 @@ function NoHearts({ nextRefillAt }: { nextRefillAt: string | null }) {
function Result({ function Result({
result, total, correct, lessonId, nodeId, color, result, total, correct, lessonId, nodeId, color,
}: { result: LessonComplete; total: number; correct: number; lessonId: number; nodeId?: string; color: string }) { }: { result: QuizResult; total: number; correct: number; lessonId?: number; nodeId?: string; color: string }) {
const nav = useNav(); const nav = useNav();
const { companion } = useAppData(); const { companion } = useAppData();
const [loadingNext, setLoadingNext] = useState(false); const [loadingNext, setLoadingNext] = useState(false);
const nodeMode = lessonId === undefined;
const nextNode = result.nextNodeId ?? null;
// بعد از ثبت، نقشه عوض شده؛ درسِ بعدی را از نسخه‌ی تازه‌ی نقشه می‌خوانیم. // بعد از ثبت، نقشه عوض شده؛ درسِ بعدی را از نسخه‌ی تازه‌ی نقشه می‌خوانیم.
async function goNext() { async function goNext() {
if (nodeMode) {
if (nextNode) nav.replace({ name: 'quiz', nodeId: nextNode });
else nav.back();
return;
}
setLoadingNext(true); setLoadingNext(true);
try { try {
const fresh = await api.home(); const fresh = await api.home();
const ref = findNode(fresh, nodeId) ?? findNodeOfLesson(fresh, lessonId); const ref = findNode(fresh, nodeId) ?? findNodeOfLesson(fresh, lessonId!);
const next = ref ? nextLessonOf(ref.node) : null; const next = ref ? nextLessonOf(ref.node) : null;
if (ref && ref.node.status !== 'completed' && next != null && next !== lessonId) { if (ref && ref.node.status !== 'completed' && next != null && next !== lessonId) {
nav.replace({ name: 'lesson', lessonId: next, nodeId: ref.node.id }); nav.replace({ name: 'lesson', lessonId: next, nodeId: ref.node.id });
@@ -381,6 +417,7 @@ function Result({
</div> </div>
<div> <div>
<h2 className="text-2xl font-black text-ink">{result.stars === 3 ? 'عالی بود!' : result.stars === 2 ? 'آفرین!' : 'تموم شد!'}</h2> <h2 className="text-2xl font-black text-ink">{result.stars === 3 ? 'عالی بود!' : result.stars === 2 ? 'آفرین!' : 'تموم شد!'}</h2>
{nodeMode && <p className="mt-1 text-sm font-bold" style={{ color }}>این جزیره رو تموم کردی 🏝️</p>}
<p className="mt-1 text-sm text-muted">{toFa(correct)} جوابِ درست از {toFa(total)} سوال</p> <p className="mt-1 text-sm text-muted">{toFa(correct)} جوابِ درست از {toFa(total)} سوال</p>
</div> </div>
<div className="flex w-full gap-3"> <div className="flex w-full gap-3">
@@ -396,8 +433,13 @@ function Result({
</div> </div>
</div> </div>
<PrimaryButton color={color} loading={loadingNext} onClick={() => void goNext()}> <PrimaryButton color={color} loading={loadingNext} onClick={() => void goNext()}>
ادامه <ChevronLeft size={18} /> {nodeMode && nextNode ? 'بریم جزیره‌ی بعدی' : 'ادامه'} <ChevronLeft size={18} />
</PrimaryButton> </PrimaryButton>
{nodeMode && nextNode && (
<button type="button" onClick={nav.back} className="-mt-2 py-2 text-sm font-bold text-muted">
فعلاً بسه، برگردیم به نقشه
</button>
)}
</div> </div>
</Screen> </Screen>
); );

View File

@@ -1,22 +1,18 @@
import { useState } from 'react'; import { useState } from 'react';
import { motion } from 'motion/react'; import { Sparkles } from 'lucide-react';
import { Check, CheckCircle2, Circle, Lock, Play, Sparkles } from 'lucide-react'; import type { HomeNode } from '@/api';
import { api, type HomeNode } from '@/api';
import { useAppData } from '@/state/AppData'; import { useAppData } from '@/state/AppData';
import { findUnit, nextLessonOf, unitColor, unitProgress } from '@/state/curriculum'; import { findUnit, unitColor, unitProgress } from '@/state/curriculum';
import { useNav } from '@/nav/Navigator'; import { useNav } from '@/nav/Navigator';
import { useAsync } from '@/lib/useAsync'; import { IslandPath } from '@/components/IslandPath';
import { Sheet } from '@/components/Sheet'; import { IslandSheet } from '@/components/IslandSheet';
import { EmptyState, ErrorState, LoadingState, PrimaryButton, ProgressBar, Screen, ScreenHeader } from '@/components/ui'; import { EmptyState, ErrorState, LoadingState, ProgressBar, Screen, ScreenHeader } from '@/components/ui';
import { ZigzagPath, zigzagPositions } from '@/components/ZigzagPath';
import { iconFor } from '@/components/NodeIcon';
import { toFa } from '@/lib/format'; import { toFa } from '@/lib/format';
const NODE_SIZE = 76;
/** نقشه‌ی جزیره‌های یک فصل، مثلِ مسیرِ دولینگو. */ /** نقشه‌ی جزیره‌های یک فصل، مثلِ مسیرِ دولینگو. */
export function UnitScreen({ unitId }: { unitId: string }) { export function UnitScreen({ unitId }: { unitId: string }) {
const { home, homeError, refreshHome, companion } = useAppData(); const { home, homeError, refreshHome, companion } = useAppData();
const nav = useNav();
const [openNode, setOpenNode] = useState<HomeNode | null>(null); const [openNode, setOpenNode] = useState<HomeNode | null>(null);
const found = findUnit(home, unitId); const found = findUnit(home, unitId);
@@ -32,11 +28,10 @@ export function UnitScreen({ unitId }: { unitId: string }) {
const { unit, unitIndex } = found; const { unit, unitIndex } = found;
const color = unitColor(unitIndex); const color = unitColor(unitIndex);
const progress = unitProgress(unit); const progress = unitProgress(unit);
const points = zigzagPositions(unit.nodes.length, 125, 90); const current = unit.nodes.find((n) => n.status !== 'completed' && n.status !== 'locked') ?? null;
const height = points.length ? points[points.length - 1].y + 150 : 400;
return ( return (
<Screen className="bg-[#f7f8ff]"> <Screen className="bg-page">
<ScreenHeader <ScreenHeader
title={unit.title} title={unit.title}
subtitle={unit.blurb || `${toFa(progress.done)} از ${toFa(progress.total)} جزیره`} subtitle={unit.blurb || `${toFa(progress.done)} از ${toFa(progress.total)} جزیره`}
@@ -53,160 +48,29 @@ export function UnitScreen({ unitId }: { unitId: string }) {
</div> </div>
</ScreenHeader> </ScreenHeader>
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto pb-6 pt-10">
{unit.nodes.length === 0 ? ( {unit.nodes.length === 0 ? (
<EmptyState text="این فصل هنوز جزیره‌ای نداره" /> <EmptyState text="این فصل هنوز جزیره‌ای نداره" />
) : ( ) : (
<div className="relative mx-auto w-full max-w-[400px]" style={{ height }}>
<ZigzagPath points={points} height={height} colorOf={(i) => (unit.nodes[i].status === 'completed' ? color : null)} />
{unit.nodes.map((node, i) => {
const locked = node.status === 'locked';
const done = node.status === 'completed';
const current = !locked && !done;
const Icon = iconFor(node.icon, i);
return (
<motion.div
key={node.id}
initial={{ opacity: 0, scale: 0.7 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: i * 0.04, type: 'spring', stiffness: 220, damping: 16 }}
className="absolute -translate-x-1/2 -translate-y-1/2"
style={{ left: `${points[i].x}%`, top: points[i].y }}
>
{current && (
<> <>
<div <IslandPath
className="absolute -top-14 left-1/2 z-20 -translate-x-1/2 whitespace-nowrap rounded-2xl border-2 bg-white px-3 py-1.5 text-[11px] font-black shadow-lg" nodes={unit.nodes}
style={{ color, borderColor: color }} color={color}
> currentId={current?.id ?? null}
{node.title} onOpen={setOpenNode}
<span className="absolute -bottom-2 left-1/2 h-3 w-3 -translate-x-1/2 rotate-45 border-b-2 border-r-2 bg-white" style={{ borderColor: color }} /> onStart={(node) => nav.push({ name: 'quiz', nodeId: node.id })}
</div>
<motion.div
animate={{ scale: [1, 1.18, 1], opacity: [0.25, 0.05, 0.25] }}
transition={{ duration: 1.8, repeat: Infinity }}
className="absolute inset-[-13px] rounded-full"
style={{ backgroundColor: color }}
/> />
</> <div className="mt-6 flex flex-col items-center">
)} <span className="mb-1 text-[11px] font-bold text-muted">{progress.done === progress.total ? 'این فصل تموم شد!' : 'ادامه بده!'}</span>
<button
type="button"
aria-label={`${node.title}${locked ? ' (قفل)' : done ? ' (تمام‌شده)' : ''}`}
onClick={() => setOpenNode(node)}
className="relative z-10 flex items-center justify-center rounded-full transition-transform active:translate-y-1"
style={{
width: NODE_SIZE,
height: NODE_SIZE,
backgroundColor: locked ? '#e5e4ec' : done ? color : '#fff',
border: current ? `4px solid ${color}` : 'none',
boxShadow: locked ? '0 6px 0 #cccbd6' : done ? `0 7px 0 ${color}99` : `0 7px 0 ${color}55, 0 0 0 5px white`,
}}
>
{locked ? (
<Lock size={24} strokeWidth={2.5} className="text-[#aaa9b8]" />
) : done ? (
<Check size={31} color="white" strokeWidth={3.5} />
) : (
<Icon size={28} style={{ color }} strokeWidth={2.5} />
)}
</button>
{!current && (
<div className="absolute left-1/2 top-full mt-2.5 w-28 -translate-x-1/2 truncate rounded-lg bg-[#f7f8ff]/90 text-center text-[10px] font-bold text-muted">
{node.title}
</div>
)}
</motion.div>
);
})}
<div className="absolute left-1/2 flex -translate-x-1/2 flex-col items-center" style={{ top: height - 70 }}>
<span className="mb-1 text-[10px] font-bold text-[#aaa9bd]">{progress.done === progress.total ? 'این فصل تموم شد!' : 'ادامه بده!'}</span>
<span className="flex h-11 w-11 items-center justify-center rounded-2xl" style={{ backgroundColor: `${color}18` }}> <span className="flex h-11 w-11 items-center justify-center rounded-2xl" style={{ backgroundColor: `${color}18` }}>
<Sparkles size={20} style={{ color }} /> <Sparkles size={20} style={{ color }} />
</span> </span>
</div> </div>
</div> </>
)} )}
</div> </div>
<NodeSheet node={openNode} color={color} onClose={() => setOpenNode(null)} /> <IslandSheet node={openNode} color={color} onClose={() => setOpenNode(null)} />
</Screen> </Screen>
); );
} }
/** برگه‌ی جزیره: درس‌هایش، و دکمه‌ی شروعِ درسِ بعدی. */
function NodeSheet({ node, color, onClose }: { node: HomeNode | null; color: string; onClose: () => void }) {
const nav = useNav();
const lessons = useAsync(() => (node ? api.nodeLessons(node.id) : Promise.resolve(null)), [node?.id]);
const next = node ? nextLessonOf(node) : null;
const locked = node?.status === 'locked';
const open = (lessonId: number) => {
if (!node) return;
onClose();
nav.push({ name: 'lesson', lessonId, nodeId: node.id });
};
// فقط درس‌هایی که سوال دارند در lessonIds هستند؛ بقیه هنوز آزمون ندارند.
const playable = new Set(node?.lessonIds ?? []);
const list = lessons.data?.lessons.filter((l) => playable.has(Number(l.id))) ?? [];
const doneCount = list.filter((l) => l.done).length;
return (
<Sheet open={node !== null} onClose={onClose} title={node?.title}>
{locked ? (
<div className="flex flex-col items-center gap-3 py-4 text-center">
<span className="flex h-14 w-14 items-center justify-center rounded-full bg-soft">
<Lock size={24} className="text-muted" />
</span>
<p className="text-sm leading-7 text-muted">این جزیره هنوز قفله. جزیره‌ی قبلی رو تموم کن تا باز بشه.</p>
</div>
) : (
<>
{lessons.loading ? (
<div className="py-6 text-center text-sm text-muted">دارم درس‌ها رو میارم…</div>
) : lessons.error ? (
<ErrorState error={lessons.error} onRetry={lessons.reload} />
) : (
<>
<p className="mb-3 text-xs text-muted">
{toFa(doneCount)} از {toFa(list.length)} درس تموم شده
</p>
<ul className="mb-5 flex max-h-72 flex-col gap-1.5 overflow-y-auto">
{list.map((lesson) => {
const isNext = Number(lesson.id) === next;
return (
<li key={lesson.id}>
<button
type="button"
onClick={() => open(Number(lesson.id))}
className={`flex w-full items-center gap-3 rounded-2xl border px-3 py-2.5 text-right text-sm ${
isNext ? 'border-2 bg-white font-bold' : 'border-line bg-white'
}`}
style={isNext ? { borderColor: color } : undefined}
>
{lesson.done ? (
<CheckCircle2 size={18} className="flex-shrink-0 text-mint" />
) : isNext ? (
<Play size={16} className="flex-shrink-0" style={{ color }} fill={color} />
) : (
<Circle size={18} className="flex-shrink-0 text-faint" />
)}
<span className={`flex-1 ${lesson.done ? 'text-muted' : 'text-ink'}`}>{lesson.title}</span>
</button>
</li>
);
})}
</ul>
</>
)}
{next != null && (
<PrimaryButton color={color} onClick={() => open(next)}>
<Play size={18} fill="white" /> {node?.status === 'completed' ? 'مرور دوباره' : 'شروعِ درسِ بعدی'}
</PrimaryButton>
)}
</>
)}
</Sheet>
);
}

View File

@@ -1,5 +1,5 @@
import { useSyncExternalStore } from 'react'; import { useSyncExternalStore } from 'react';
import type { UsedChunk } from '@/api'; import type { Diagram, UsedChunk } from '@/api';
import { KEYS, store } from '@/lib/storage'; import { KEYS, store } from '@/lib/storage';
/** /**
@@ -13,6 +13,8 @@ export interface ChatMessage {
messageId?: string; messageId?: string;
usedChunks?: UsedChunk[]; usedChunks?: UsedChunk[];
followUps?: string[]; followUps?: string[];
/** شکلِ پاسخ؛ اپ آن را خودش به SVG تبدیل می‌کند (components/Diagram). */
diagram?: Diagram | null;
feedback?: 'up' | 'down'; feedback?: 'up' | 'down';
/** پیامِ خطا به‌جای پاسخ؛ با «دوباره» همان سوال دوباره فرستاده می‌شود. */ /** پیامِ خطا به‌جای پاسخ؛ با «دوباره» همان سوال دوباره فرستاده می‌شود. */
failedQuestion?: string; failedQuestion?: string;

View File

@@ -23,9 +23,9 @@
--color-body: #3a3a5c; /* متنِ بدنه */ --color-body: #3a3a5c; /* متنِ بدنه */
--color-muted: #9e9ec0; /* متنِ کم‌رنگ */ --color-muted: #9e9ec0; /* متنِ کم‌رنگ */
--color-faint: #b0b0c8; --color-faint: #b0b0c8;
--color-line: #ede9ff; /* حاشیه‌ی کارت */ --color-line: #e9e6f7; /* حاشیه‌ی کارت */
--color-soft: #f0eeff; /* پس‌زمینه‌ی ملایم */ --color-soft: #f0eeff; /* پس‌زمینه‌ی ملایم */
--color-page: #f0f4ff; /* پس‌زمینه‌ی صفحه */ --color-page: #f6f5fb; /* پس‌زمینه‌ی صفحه؛ کمی گرم‌تر از آبیِ قبلی */
} }
@layer base { @layer base {