diff --git a/src/api/index.ts b/src/api/index.ts index c575c8a..1f83a3a 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -75,6 +75,15 @@ export const api = { home: () => http.get('/home'), nodeLessons: (nodeId: string) => http.get(`/nodes/${nodeId}/lessons`), + /** + * آزمونِ جزیره: از هر درسِ جزیره حداکثر یک سوال (پیش‌فرض ۴). ترتیب برای هر + * کاربر ثابت است، پس رفرش همان آزمون را می‌دهد. + */ + nodeQuiz: (nodeId: string) => http.get(`/nodes/${nodeId}/quiz`), + /** پایانِ آزمون، جزیره را با هر نمره‌ای تمام می‌کند و جزیره‌ی بعدی را باز. */ + nodeQuizComplete: (nodeId: string, result: { correctCount: number; totalQuestions: number }, idempotencyKey?: string) => + http.create(`/nodes/${nodeId}/quiz/complete`, result, { idempotencyKey }), + lesson: { intro: (lessonId: number) => http.get(`/lessons/${lessonId}/intro`), questions: (lessonId: number) => diff --git a/src/api/types.ts b/src/api/types.ts index ab4c75a..e10e118 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -121,6 +121,31 @@ export interface Question { 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 { correct: boolean; correctOptionId: string; @@ -184,14 +209,36 @@ export interface AskDone { suggestedFollowUps: string[]; remainingQuota: number; usedChunks: UsedChunk[]; + diagram?: Diagram | null; } export interface ConversationMessage { messageId: string; role: 'user' | 'assistant' | 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; 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 { id: number; type: string; diff --git a/src/components/Diagram.tsx b/src/components/Diagram.tsx new file mode 100644 index 0000000..5be2508 --- /dev/null +++ b/src/components/Diagram.tsx @@ -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 ; + if (spec.kind === 'fraction') return ; + if (spec.kind === 'plane') return ; + } catch { + /* شکلِ خراب نباید پاسخ را خراب کند */ + } + return null; + }, [spec]); + if (!body) return null; + return ( + // dir=ltr: محور و مختصات چپ‌به‌راست‌اند؛ در زمینه‌ی rtl «−۱» به «۱−» برمی‌گشت. + // برچسب‌های فارسی با unicode-bidi: plaintext جهتِ خودشان را می‌گیرند. +
+ {body} + {spec.title &&
{spec.title}
} +
+ ); +} + +export const Diagram = memo(DiagramBase); + +/* ─── صفحه‌ی مختصات / هندسه ───────────────────────────────────────────────── */ + +function Plane({ spec }: { spec: Extract }) { + // نقطه‌ها: polar نسبت به مبدأ یا نقطه‌ی from، با حلِ ارجاعِ زنجیره‌ای. + const named = new Map(); + const resolving = new Set(); + 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; 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( + + {ticks.map((t) => )} + {yticks.map((t) => )} + , + + + + , + + {ticks.filter((t) => t !== 0).map((t) => {fa(t)})} + {yticks.filter((t) => t !== 0).map((t) => {fa(t)})} + , + ); + } + + 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(); + } 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( + , + ); + 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(); + } 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( 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( 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( + 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(); + } + } else if (item.type === 'text') { + const [tx, ty] = P(item.at); + els.push(); + } + } + + 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(); + if (item.label) { + const last = clipped[Math.floor(clipped.length * 0.85)] ?? clipped[clipped.length - 1]; + els.push(); + } + } + + // نقطه‌ها آخر از همه، روی بقیه + const pointLabels = new Map(); + 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(); + if (label) els.push(); + }; + 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 ( + + + {els} + + ); +} + +/* ─── محورِ اعداد ─────────────────────────────────────────────────────────── */ + +function NumberLine({ spec }: { spec: Extract }) { + 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 ( + + + + {ticks.map((t) => ( + + + + {fa(t)} + + + ))} + {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 ( + + + {j.label && } + + ); + })} + {spec.points.map((p, i) => ( + + 1 ? ACCENT : MAIN} stroke="#fff" strokeWidth={1.5} /> + {p.label && !/^[-−]?[\d۰-۹.]+$/.test(p.label) && } + + ))} + + ); +} + +/* ─── کسر ─────────────────────────────────────────────────────────────────── */ + +function Fraction({ spec }: { spec: Extract }) { + const { parts, shaded } = spec; + const fill = (i: number) => (i < shaded ? '#FFC63D' : '#fff'); + if (spec.shape === 'rect') { + const w = (W - 2 * PAD) / parts; + return ( + + {Array.from({ length: parts }, (_, i) => ( + + ))} + {toFa(shaded)} از {toFa(parts)} + + ); + } + const cx = W / 2, cy = 82, r = 64; + return ( + + {parts === 1 ? ( + + ) : ( + 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 ( + + ); + }) + )} + {toFa(shaded)} از {toFa(parts)} تکه + + ); +} + +/* ─── کمکی‌ها ─────────────────────────────────────────────────────────────── */ + +function Markers() { + const head = (id: string, color: string, start = false) => ( + + + + ); + return ( + + {head('dg-arrow', ACCENT)} + {head('dg-arrow-soft', SOFT)} + {head('dg-arrow-ink', INK)} + {head('dg-arrow-ink-start', INK, true)} + + ); +} + +function Label({ x, y, color, bold = false, children }: { x: number; y: number; color: string; bold?: boolean; children: ReactNode }) { + return ( + + {children} + + ); +} + +/** برچسبِ ضلع، وسطِ پاره‌خط و به سمتِ بیرونِ شکل. */ +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 ; +} + +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('-', '−')); diff --git a/src/components/IslandPath.tsx b/src/components/IslandPath.tsx new file mode 100644 index 0000000..1ecadfa --- /dev/null +++ b/src/components/IslandPath.tsx @@ -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 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 ( +
+ (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 ( + + {current && ( + <> + 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)}` }} + > + {node.status === 'in_progress' ? 'ادامه' : 'شروع'} + + + + + )} + + {/* اسم کنارِ جزیره، نه زیرش: فضای بینِ دو جزیره مالِ دکمه‌ی «شروع» است + و اسمِ زیرِ جزیره‌ی قبلی زیرِ آن دکمه پنهان می‌شد. سمتی که جای بیشتری دارد. */} +
= 50 ? 'right-full mr-3 text-right' : 'left-full ml-3 text-left' + } ${current ? 'text-[12px] text-ink' : locked ? 'text-faint' : 'text-muted'}`} + > + {node.title} +
+
+ ); + })} +
+ ); +}); diff --git a/src/components/IslandSheet.tsx b/src/components/IslandSheet.tsx new file mode 100644 index 0000000..ec52611 --- /dev/null +++ b/src/components/IslandSheet.tsx @@ -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 ( + + {locked ? ( +
+ + + +

این جزیره هنوز قفله. جزیره‌ی قبلی رو تموم کن تا باز بشه.

+
+ ) : ( + <> +

+ {done + ? 'این جزیره رو تموم کردی! می‌تونی دوباره تمرینش کنی (امتیازِ تازه نداره).' + : 'چند سوالِ کوتاه از همین جزیره. هر جوابی بدی، آخرش جزیره‌ی بعدی باز می‌شه.'} +

+ + {done ? <> دوباره تمرین کن : <> شروعِ آزمونِ جزیره} + + + + + {showLessons && ( + lessons.loading ? ( +
دارم درس‌ها رو میارم…
+ ) : lessons.error ? ( + + ) : ( + <> +

+ {toFa(list.filter((l) => l.done).length)} از {toFa(list.length)} درس خونده شده +

+
    + {list.map((lesson) => ( +
  • + +
  • + ))} +
+ + ) + )} + + )} +
+ ); +} diff --git a/src/components/ui.tsx b/src/components/ui.tsx index 10b1edf..138f0cc 100644 --- a/src/components/ui.tsx +++ b/src/components/ui.tsx @@ -4,6 +4,7 @@ import { SeyarMascot } from '@/mascots/SeyarMascot'; import type { SeyarChar, SeyarMood } from '@/mascots/seyar'; import { friendlyError } from '@/lib/errors'; import { useNav } from '@/nav/Navigator'; +import { shade } from '@/lib/color'; /* ─── سربرگِ رنگیِ صفحه ──────────────────────────────────────────────────── */ @@ -28,7 +29,7 @@ export function ScreenHeader({ return (
@@ -85,8 +86,8 @@ export function PrimaryButton({
); @@ -251,7 +254,9 @@ function Message({ return (
- {message.text} +
: undefined}> + {message.text} +
{message.failedQuestion ? ( - -
-
-
- -
-
- {stats.map(({ icon: Icon, value, label, fg, bg, fill, to }) => ( - + )} +
+ {stats.map(({ icon: Icon, value, label, color, fill, to }) => ( + ))} +
-
- {homeError && !home ? ( - void refreshHome()} /> - ) : cont && contProgress ? ( - -
-
-

ادامه از جایی که موندی

-

{cont.node.title}

-

- {cont.unit.title} · {toFa(contProgress.done)} از {toFa(contProgress.total)} جزیره -

-
- -
-
- -
- -
- ) : ( - - -

- {!home ? 'دارم مسیرت رو میارم…' : home.units.length ? 'همه‌ی جزیره‌های این پایه رو تموم کردی! 🎉' : 'هنوز درسی برای این پایه ثبت نشده'} -

-
- )} - -
- nav.switchTab('map')} className="p-4"> - - - -
نقشه‌ی فصل‌ها
-
ببین کجای مسیری
-
- +
+ +
+ {bubble}
+
+ - {home && home.units.length > 0 && ( -
-
-

فصل‌های {gradeTitle || 'این پایه'}

- {toFa(home.units.length)} فصل -
-
- {home.units.map((unit, i) => { - const p = unitProgress(unit); - const color = unitColor(i); - const Icon = iconFor(unit.nodes[0]?.icon, i); - return ( - - ); - })} -
-
- )} - - {cont && contLesson != null && ( - - )} -
+
{unit.title.replace(/^فصل\s*\S+\s*[::]\s*/, '')}
+
+ + +
+ setOpenNode({ node, color })} + onStart={(node) => nav.push({ name: 'quiz', nodeId: node.id })} + /> +
+ + ); + })} +
+ )} + + setOpenNode(null)} /> ); } diff --git a/src/screens/OnboardingScreen.tsx b/src/screens/OnboardingScreen.tsx index debc336..93c5dec 100644 --- a/src/screens/OnboardingScreen.tsx +++ b/src/screens/OnboardingScreen.tsx @@ -23,6 +23,12 @@ const SLIDES: { char: SeyarChar; mood: SeyarMood; bg: string; sub: string; title title: 'از پی بپرس', desc: 'من پی‌ام. هر سوالی داشتی، یا جوابی رو غلط زدی، ازم بپرس؛ از روی کتابِ خودت قدم‌به‌قدم توضیح می‌دم.', }, + { + char: 'tedi', mood: 'excited', bg: '#D9820B', + sub: 'چهار همراه، یکی مالِ تو', + title: 'همراهت رو انتخاب کن', + desc: 'منم تدی! هر چی امتیازت بیشتر بشه، همراه‌های تازه باز می‌شن. از صفحه‌ی «من» هر کدوممون رو خواستی کنارت می‌ذاری.', + }, ]; export function OnboardingScreen({ onDone }: { onDone: () => void }) { diff --git a/src/screens/QuizScreen.tsx b/src/screens/QuizScreen.tsx index 0e9bb4d..19f48f5 100644 --- a/src/screens/QuizScreen.tsx +++ b/src/screens/QuizScreen.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react'; import { AnimatePresence, motion } from 'motion/react'; import confetti from 'canvas-confetti'; 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 { findNode, findNodeOfLesson, nextLessonOf, unitColor } from '@/state/curriculum'; import { useNav } from '@/nav/Navigator'; @@ -23,16 +23,35 @@ type Phase = | { kind: 'noHearts'; nextRefillAt: string | null } | { kind: 'finishing' } | { 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 nav = useNav(); const toast = useToast(); const [phase, setPhase] = useState({ kind: 'loading' }); - const [questions, setQuestions] = useState([]); + const [questions, setQuestions] = useState([]); + const nodeMode = lessonId === undefined; const [hearts, setHearts] = useState(null); const [index, setIndex] = useState(0); const [selected, setSelected] = useState(null); @@ -47,7 +66,7 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st const answerKeys = useRef(new Map()); 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 question = questions[index]; const isLast = index === questions.length - 1; @@ -58,7 +77,10 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st useEffect(() => { let alive = true; setPhase({ kind: 'loading' }); - Promise.all([api.lesson.questions(lessonId), api.hearts().catch(() => null)]) + const load: Promise = nodeMode + ? api.nodeQuiz(nodeId ?? '').then((r) => r.questions) + : api.lesson.questions(lessonId); + Promise.all([load, api.hearts().catch(() => null)]) .then(([qs, h]) => { if (!alive) return; setQuestions(qs); @@ -76,7 +98,7 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st return () => { alive = false; }; - }, [lessonId, reload]); + }, [lessonId, nodeId, nodeMode, reload]); async function choose(optionId: string) { if (!question || selected !== null || submitting) return; @@ -127,11 +149,10 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st async function finish() { setPhase({ kind: 'finishing' }); try { - const result = await api.lesson.complete( - lessonId, - { correctCount, totalQuestions: questions.length }, - completeKey.current, - ); + const payload = { correctCount, totalQuestions: questions.length }; + const result: QuizResult = nodeMode + ? await api.nodeQuizComplete(nodeId ?? '', payload, completeKey.current) + : await api.lesson.complete(lessonId, payload, completeKey.current); setPhase({ kind: 'result', result }); sounds.finish(); 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 = () => - 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 ; - if (phase.kind === 'result') return ; + if (phase.kind === 'result') return ; if (phase.kind === 'finishing' || phase.kind === 'finishError') { return ( @@ -182,7 +208,7 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st return (
- + برگشت
@@ -257,7 +283,7 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st onClick={() => void choose(option.id)} disabled={selected !== null && !answer} 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)}`} > {option.value} {answer && option.id === answer.correctOptionId && } @@ -281,7 +307,9 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: st setConfirmExit(false)} title="از تمرین بیرون بری؟"> -

جواب‌هایی که دادی ثبت شده، ولی این درس تا آخرش رو نزنی تموم‌شده حساب نمی‌شه.

+

+ جواب‌هایی که دادی ثبت شده، ولی {nodeMode ? 'این جزیره' : 'این درس'} تا آخرش رو نزنی تموم‌شده حساب نمی‌شه. +

setConfirmExit(false)}>ادامه می‌دم { setConfirmExit(false); nav.back(); }}>خروج @@ -346,17 +374,25 @@ function NoHearts({ nextRefillAt }: { nextRefillAt: string | null }) { function Result({ 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 { companion } = useAppData(); const [loadingNext, setLoadingNext] = useState(false); + const nodeMode = lessonId === undefined; + const nextNode = result.nextNodeId ?? null; + // بعد از ثبت، نقشه عوض شده؛ درسِ بعدی را از نسخه‌ی تازه‌ی نقشه می‌خوانیم. async function goNext() { + if (nodeMode) { + if (nextNode) nav.replace({ name: 'quiz', nodeId: nextNode }); + else nav.back(); + return; + } setLoadingNext(true); try { 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; if (ref && ref.node.status !== 'completed' && next != null && next !== lessonId) { nav.replace({ name: 'lesson', lessonId: next, nodeId: ref.node.id }); @@ -381,6 +417,7 @@ function Result({

{result.stars === 3 ? 'عالی بود!' : result.stars === 2 ? 'آفرین!' : 'تموم شد!'}

+ {nodeMode &&

این جزیره رو تموم کردی 🏝️

}

{toFa(correct)} جوابِ درست از {toFa(total)} سوال

@@ -396,8 +433,13 @@ function Result({
void goNext()}> - ادامه + {nodeMode && nextNode ? 'بریم جزیره‌ی بعدی' : 'ادامه'} + {nodeMode && nextNode && ( + + )}
); diff --git a/src/screens/UnitScreen.tsx b/src/screens/UnitScreen.tsx index 0c8d838..fb3a9a1 100644 --- a/src/screens/UnitScreen.tsx +++ b/src/screens/UnitScreen.tsx @@ -1,22 +1,18 @@ import { useState } from 'react'; -import { motion } from 'motion/react'; -import { Check, CheckCircle2, Circle, Lock, Play, Sparkles } from 'lucide-react'; -import { api, type HomeNode } from '@/api'; +import { Sparkles } from 'lucide-react'; +import type { HomeNode } from '@/api'; 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 { useAsync } from '@/lib/useAsync'; -import { Sheet } from '@/components/Sheet'; -import { EmptyState, ErrorState, LoadingState, PrimaryButton, ProgressBar, Screen, ScreenHeader } from '@/components/ui'; -import { ZigzagPath, zigzagPositions } from '@/components/ZigzagPath'; -import { iconFor } from '@/components/NodeIcon'; +import { IslandPath } from '@/components/IslandPath'; +import { IslandSheet } from '@/components/IslandSheet'; +import { EmptyState, ErrorState, LoadingState, ProgressBar, Screen, ScreenHeader } from '@/components/ui'; import { toFa } from '@/lib/format'; -const NODE_SIZE = 76; - /** نقشه‌ی جزیره‌های یک فصل، مثلِ مسیرِ دولینگو. */ export function UnitScreen({ unitId }: { unitId: string }) { const { home, homeError, refreshHome, companion } = useAppData(); + const nav = useNav(); const [openNode, setOpenNode] = useState(null); const found = findUnit(home, unitId); @@ -32,11 +28,10 @@ export function UnitScreen({ unitId }: { unitId: string }) { const { unit, unitIndex } = found; const color = unitColor(unitIndex); const progress = unitProgress(unit); - const points = zigzagPositions(unit.nodes.length, 125, 90); - const height = points.length ? points[points.length - 1].y + 150 : 400; + const current = unit.nodes.find((n) => n.status !== 'completed' && n.status !== 'locked') ?? null; return ( - + -
+
{unit.nodes.length === 0 ? ( ) : ( -
- (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 ( - - {current && ( - <> -
- {node.title} - -
- - - )} - - {!current && ( -
- {node.title} -
- )} -
- ); - })} -
- {progress.done === progress.total ? 'این فصل تموم شد!' : 'ادامه بده!'} + <> + nav.push({ name: 'quiz', nodeId: node.id })} + /> +
+ {progress.done === progress.total ? 'این فصل تموم شد!' : 'ادامه بده!'}
-
+ )}
- setOpenNode(null)} /> + setOpenNode(null)} /> ); } - -/** برگه‌ی جزیره: درس‌هایش، و دکمه‌ی شروعِ درسِ بعدی. */ -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 ( - - {locked ? ( -
- - - -

این جزیره هنوز قفله. جزیره‌ی قبلی رو تموم کن تا باز بشه.

-
- ) : ( - <> - {lessons.loading ? ( -
دارم درس‌ها رو میارم…
- ) : lessons.error ? ( - - ) : ( - <> -

- {toFa(doneCount)} از {toFa(list.length)} درس تموم شده -

-
    - {list.map((lesson) => { - const isNext = Number(lesson.id) === next; - return ( -
  • - -
  • - ); - })} -
- - )} - {next != null && ( - open(next)}> - {node?.status === 'completed' ? 'مرور دوباره' : 'شروعِ درسِ بعدی'} - - )} - - )} -
- ); -} diff --git a/src/state/askStore.ts b/src/state/askStore.ts index ae7cc7c..ea3f342 100644 --- a/src/state/askStore.ts +++ b/src/state/askStore.ts @@ -1,5 +1,5 @@ import { useSyncExternalStore } from 'react'; -import type { UsedChunk } from '@/api'; +import type { Diagram, UsedChunk } from '@/api'; import { KEYS, store } from '@/lib/storage'; /** @@ -13,6 +13,8 @@ export interface ChatMessage { messageId?: string; usedChunks?: UsedChunk[]; followUps?: string[]; + /** شکلِ پاسخ؛ اپ آن را خودش به SVG تبدیل می‌کند (components/Diagram). */ + diagram?: Diagram | null; feedback?: 'up' | 'down'; /** پیامِ خطا به‌جای پاسخ؛ با «دوباره» همان سوال دوباره فرستاده می‌شود. */ failedQuestion?: string; diff --git a/src/styles/index.css b/src/styles/index.css index 5635318..71062ff 100644 --- a/src/styles/index.css +++ b/src/styles/index.css @@ -23,9 +23,9 @@ --color-body: #3a3a5c; /* متنِ بدنه */ --color-muted: #9e9ec0; /* متنِ کم‌رنگ */ --color-faint: #b0b0c8; - --color-line: #ede9ff; /* حاشیه‌ی کارت */ + --color-line: #e9e6f7; /* حاشیه‌ی کارت */ --color-soft: #f0eeff; /* پس‌زمینه‌ی ملایم */ - --color-page: #f0f4ff; /* پس‌زمینه‌ی صفحه */ + --color-page: #f6f5fb; /* پس‌زمینه‌ی صفحه؛ کمی گرم‌تر از آبیِ قبلی */ } @layer base {