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

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 { friendlyError } from '@/lib/errors';
import { useNav } from '@/nav/Navigator';
import { shade } from '@/lib/color';
/* ─── سربرگِ رنگیِ صفحه ──────────────────────────────────────────────────── */
@@ -28,7 +29,7 @@ export function ScreenHeader({
return (
<header
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 -right-10 top-10 h-28 w-28 rounded-full bg-white/10" />
@@ -85,8 +86,8 @@ export function PrimaryButton({
<button
type="button"
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}`}
style={{ backgroundColor: color, boxShadow: `0 10px 24px -8px ${color}88`, ...style }}
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 4px 0 ${shade(color, -0.22)}`, ...style }}
{...rest}
>
{loading ? <Spinner /> : children}
@@ -98,7 +99,7 @@ export function SecondaryButton({ className = '', children, ...rest }: ButtonHTM
return (
<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}
>
{children}