forked from Tadrisino/MathApp_frontend
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:
@@ -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) =>
|
||||||
|
|||||||
@@ -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
392
src/components/Diagram.tsx
Normal 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('-', '−'));
|
||||||
107
src/components/IslandPath.tsx
Normal file
107
src/components/IslandPath.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
});
|
||||||
108
src/components/IslandSheet.tsx
Normal file
108
src/components/IslandSheet.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
12
src/lib/color.ts
Normal 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
110
src/lib/mathExpr.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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' }
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }) {
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user