forked from Tadrisino/MathApp_frontend
rebuild the app on a typed, modular structure wired to the real API
The whole app lived in one 3,071-line App.tsx on a Figma scaffold (60 unused packages, 45 unused UI components, no type-check). The live site could not reach the backend, Persian-keyboard digits were erased from the phone field, guests were signed out after an hour, "mark read" hit a method the server does not accept (405), and iOS Safari blocked the payment window. What changed: - src/ split into api/ (one typed client: timeouts, GET retry, single-flight refresh, SSE), screens/ (one file each), components/, state/, nav/, lib/. - The API address is configuration only: public/config.js at runtime, then VITE_API_BASE_URL at build time, then same-origin /api. No domain in code. - Responsive shell instead of a fixed 390x844 phone frame; self-hosted Vazirmatn instead of Google Fonts; strict TypeScript, `npm run build` type-checks first. - Everything on the handoff list: tutor answers keep their line breaks, have thumbs up/down, follow-ups, "from the book" badge, retry, history that survives tab switches and reloads, "why was I wrong" from a wrong answer with questionId; question reports; hearts with maxHearts/unlimited and a no-hearts screen with the refill time; result screen and a visible error when lesson completion fails; subscription status, badge, discount code before the gateway and the payment-return message; guest refresh token; 6-digit OTP with resend timer; age saved as a Jalali birthYear; PATCH read-all; leaderboard refetched on open with "you" highlighted; selected companion shown across the app; logout that revokes the refresh token. - New screens for endpoints that had none: profile, streak calendar, support tickets and bug reports, parents, invite-a-friend (with ?ref= tracking), guest-to-phone upgrade. - Browser/phone back button walks the screen stack. Checked with a headless-Chrome walkthrough against a local backend at 390x844 and 360x740 (fresh guest: onboarding, setup, a full lesson, map, island sheet, every tab and sub-screen, one real tutor question, reload, logout): no console errors, no failed API calls. Real SMS login and a real Zarinpal payment were not exercised. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
150
src/mascots/seyar.ts
Normal file
150
src/mascots/seyar.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* سهیار: سیگما (Σ)، دلتا (Δ) و پی (π) — ماسکوتهای اپ.
|
||||
*
|
||||
* هر سه از دلِ یک دفترچهی بینهایت آمدهاند و هر کدام از جنسِ یک نمادِ ریاضیاند:
|
||||
* سیگما → راهنمای درس و آزمون دلتا → همتیمیِ تمرینِ سریع پی → دستیار و همراهِ دلگرمی
|
||||
*
|
||||
* خروجی رشتهی SVG است (نه JSX) تا هر سه با یک موتورِ چهره ساخته شوند.
|
||||
* ورودیها همه از همین فایلاند و هیچ متنِ کاربر واردِ SVG نمیشود.
|
||||
*/
|
||||
|
||||
export type SeyarChar = 'sigma' | 'delta' | 'pi';
|
||||
export type SeyarMood = 'idle' | 'happy' | 'excited' | 'sad' | 'angry' | 'depressed';
|
||||
|
||||
const PALETTE: Record<SeyarChar, Record<string, string>> = {
|
||||
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" },
|
||||
pi: { body: "#9B7BFF", inner: "#C6B3FF", belly: "#F1ECFF", ink: "#3B2B66", cheek: "#FFC2D1", mouth: "#FF6F91", tear: "#5EC8E8" },
|
||||
};
|
||||
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 };
|
||||
|
||||
function face(mood: SeyarMood, ex: number, ey: number, mx: number, my: number, ink: string, mouthC: string, tearC: string, cheekC: string, spread: number) {
|
||||
const lx = ex, rx = ex + spread;
|
||||
let eyes = "", mouth = "", cheeks = "", extra = "";
|
||||
if (mood === "idle") {
|
||||
eyes = `<circle cx="${lx}" cy="${ey}" r="9" fill="${ink}"/><circle cx="${rx}" cy="${ey}" r="9" fill="${ink}"/>`
|
||||
+ `<circle cx="${lx + 3}" cy="${ey - 3}" r="3" fill="#fff"/><circle cx="${rx + 3}" cy="${ey - 3}" r="3" fill="#fff"/>`;
|
||||
mouth = `<path d="M${mx - 14} ${my} q14 10 28 0" fill="none" stroke="${ink}" stroke-width="5" stroke-linecap="round"/>`;
|
||||
} else if (mood === "happy") {
|
||||
eyes = `<path d="M${lx - 11} ${ey} q11 -14 22 0" fill="none" stroke="${ink}" stroke-width="5.5" stroke-linecap="round"/>`
|
||||
+ `<path d="M${rx - 11} ${ey} q11 -14 22 0" fill="none" stroke="${ink}" stroke-width="5.5" stroke-linecap="round"/>`;
|
||||
mouth = `<path d="M${mx - 20} ${my - 4} q20 26 40 0 q-20 10 -40 0z" fill="${mouthC}" stroke="${ink}" stroke-width="4.5" stroke-linejoin="round"/>`;
|
||||
cheeks = `<ellipse cx="${lx - 14}" cy="${my - 14}" rx="11" ry="7" fill="${cheekC}"/><ellipse cx="${rx + 14}" cy="${my - 14}" rx="11" ry="7" fill="${cheekC}"/>`;
|
||||
} else if (mood === "excited") {
|
||||
eyes = `<circle cx="${lx}" cy="${ey}" r="11" fill="${ink}"/><circle cx="${rx}" cy="${ey}" r="11" fill="${ink}"/>`
|
||||
+ `<circle cx="${lx + 4}" cy="${ey - 4}" r="4" fill="#fff"/><circle cx="${rx + 4}" cy="${ey - 4}" r="4" fill="#fff"/>`
|
||||
+ `<path d="M${lx - 16} ${ey - 16} l3 6 l6 -2 l-4 5 l4 5 l-6 -2 l-3 6 l-1 -6 l-6 1 l5 -4 l-5 -4 l6 1z" fill="${tearC}" opacity=".85"/>`;
|
||||
mouth = `<ellipse cx="${mx}" cy="${my + 2}" rx="16" ry="14" fill="${mouthC}" stroke="${ink}" stroke-width="4.5"/>`;
|
||||
cheeks = `<ellipse cx="${lx - 14}" cy="${my - 12}" rx="12" ry="8" fill="${cheekC}"/><ellipse cx="${rx + 14}" cy="${my - 12}" rx="12" ry="8" fill="${cheekC}"/>`;
|
||||
extra = `<g stroke="${tearC}" stroke-width="4" stroke-linecap="round"><path d="M${lx - 30} ${ey - 24} l-7 -10"/><path d="M${rx + 30} ${ey - 24} l7 -10"/></g>`;
|
||||
} else if (mood === "sad") {
|
||||
eyes = `<circle cx="${lx}" cy="${ey + 2}" r="8" fill="${ink}"/><circle cx="${rx}" cy="${ey + 2}" r="8" fill="${ink}"/>`
|
||||
+ `<path d="M${lx - 10} ${ey - 10} q9 -6 18 -2" fill="none" stroke="${ink}" stroke-width="5" stroke-linecap="round"/>`
|
||||
+ `<path d="M${rx - 8} ${ey - 12} q9 -4 18 2" fill="none" stroke="${ink}" stroke-width="5" stroke-linecap="round"/>`;
|
||||
mouth = `<path d="M${mx - 16} ${my + 8} q16 -14 32 0" fill="none" stroke="${ink}" stroke-width="5" stroke-linecap="round"/>`;
|
||||
extra = `<path d="M${lx - 2} ${ey + 10} q-4 12 0 18 q4 -6 0 -18z" fill="${tearC}"/>`;
|
||||
} else if (mood === "angry") {
|
||||
eyes = `<path d="M${lx - 9} ${ey - 4} l14 4 l-4 8 z" fill="${ink}"/><path d="M${rx + 9} ${ey - 4} l-14 4 l4 8 z" fill="${ink}"/>`
|
||||
+ `<path d="M${lx - 12} ${ey - 12} l16 4" stroke="${ink}" stroke-width="5" stroke-linecap="round"/>`
|
||||
+ `<path d="M${rx + 12} ${ey - 12} l-16 4" stroke="${ink}" stroke-width="5" stroke-linecap="round"/>`;
|
||||
mouth = `<path d="M${mx - 14} ${my + 4} l6 -6 l6 6 l6 -6 l6 6 l6 -6" fill="none" stroke="${ink}" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>`;
|
||||
cheeks = `<ellipse cx="${lx - 14}" cy="${my - 14}" rx="9" ry="6" fill="${mouthC}" opacity=".55"/><ellipse cx="${rx + 14}" cy="${my - 14}" rx="9" ry="6" fill="${mouthC}" opacity=".55"/>`;
|
||||
extra = `<g stroke="${ink}" stroke-width="4" stroke-linecap="round" opacity=".8"><path d="M${lx - 34} ${ey - 30} q4 -8 -2 -14"/><path d="M${rx + 34} ${ey - 30} q-4 -8 2 -14"/></g>`;
|
||||
} else if (mood === "depressed") {
|
||||
eyes = `<path d="M${lx - 10} ${ey} q10 5 20 0" fill="none" stroke="${ink}" stroke-width="6" stroke-linecap="round"/>`
|
||||
+ `<path d="M${rx - 10} ${ey} q10 5 20 0" fill="none" stroke="${ink}" stroke-width="6" stroke-linecap="round"/>`;
|
||||
mouth = `<path d="M${mx - 12} ${my} q12 6 24 1" fill="none" stroke="${ink}" stroke-width="4.5" stroke-linecap="round"/>`;
|
||||
extra = `<g><ellipse cx="${mx}" cy="${ey - 58}" rx="26" ry="10" fill="#B9C2CC" opacity=".9"/>`
|
||||
+ `<ellipse cx="${mx - 16}" cy="${ey - 52}" rx="16" ry="8" fill="#B9C2CC" opacity=".9"/>`
|
||||
+ `<ellipse cx="${mx + 18}" cy="${ey - 52}" rx="15" ry="8" fill="#B9C2CC" opacity=".9"/>`
|
||||
+ `<g stroke="#8FA0B3" stroke-width="3.5" stroke-linecap="round" opacity=".8">`
|
||||
+ `<line x1="${mx - 14}" y1="${ey - 42}" x2="${mx - 18}" y2="${ey - 32}"/>`
|
||||
+ `<line x1="${mx}" y1="${ey - 40}" x2="${mx - 4}" y2="${ey - 28}"/>`
|
||||
+ `<line x1="${mx + 14}" y1="${ey - 42}" x2="${mx + 10}" y2="${ey - 30}"/></g></g>`;
|
||||
}
|
||||
return { eyes, mouth, cheeks, extra };
|
||||
}
|
||||
|
||||
function seyarSigmaSVG(mood: SeyarMood): string {
|
||||
const C = PALETTE.sigma;
|
||||
const f = face(mood, 80, 86, 100, 114, C.ink, C.mouth, C.tear, C.cheek, 40);
|
||||
const a = 12 + ARM_DELTA[mood], dy = Y_SHIFT[mood];
|
||||
return `<svg viewBox="-14 -34 228 250" xmlns="http://www.w3.org/2000/svg"><g transform="translate(0 ${dy})">
|
||||
<line x1="100" y1="18" x2="100" y2="-8" stroke="${C.ink}" stroke-width="6" stroke-linecap="round"/>
|
||||
<circle cx="100" cy="-14" r="9" fill="${C.badge}" stroke="${C.ink}" stroke-width="5"/>
|
||||
<rect x="52" y="176" width="26" height="16" rx="8" fill="${C.inner}" stroke="${C.ink}" stroke-width="5"/>
|
||||
<rect x="122" y="176" width="26" height="16" rx="8" fill="${C.inner}" stroke="${C.ink}" stroke-width="5"/>
|
||||
<rect x="14" y="96" width="24" height="52" rx="12" fill="${C.body}" stroke="${C.ink}" stroke-width="5" transform="rotate(${a} 26 122)"/>
|
||||
<rect x="162" y="96" width="24" height="52" rx="12" fill="${C.body}" stroke="${C.ink}" stroke-width="5" transform="rotate(${-a} 174 122)"/>
|
||||
<rect x="20" y="70" width="14" height="34" rx="7" fill="${C.inner}" stroke="${C.ink}" stroke-width="5"/>
|
||||
<rect x="166" y="70" width="14" height="34" rx="7" fill="${C.inner}" stroke="${C.ink}" stroke-width="5"/>
|
||||
<rect x="34" y="24" width="132" height="150" rx="38" fill="${C.body}" stroke="${C.ink}" stroke-width="6"/>
|
||||
<rect x="54" y="44" width="92" height="98" rx="24" fill="${C.belly}" stroke="${C.ink}" stroke-width="4"/>
|
||||
${f.extra}<g>${f.eyes}</g>${f.cheeks}${f.mouth}
|
||||
<circle cx="100" cy="160" r="14" fill="${C.badge}" stroke="${C.ink}" stroke-width="4"/>
|
||||
<text x="100" y="167" font-size="18" text-anchor="middle" font-family="Georgia,serif" font-weight="bold" fill="${C.ink}">Σ</text>
|
||||
</g></svg>`;
|
||||
}
|
||||
|
||||
function seyarDeltaSVG(mood: SeyarMood): string {
|
||||
const C = PALETTE.delta;
|
||||
const f = face(mood, 84, 108, 100, 128, C.ink, C.mouth, C.tear, C.cheek, 32);
|
||||
const a = 16 + ARM_DELTA[mood], dy = Y_SHIFT[mood];
|
||||
return `<svg viewBox="-14 -34 228 250" xmlns="http://www.w3.org/2000/svg"><g transform="translate(0 ${dy})">
|
||||
<ellipse cx="76" cy="182" rx="18" ry="12" fill="${C.inner}" stroke="${C.ink}" stroke-width="5"/>
|
||||
<ellipse cx="124" cy="182" rx="18" ry="12" fill="${C.inner}" stroke="${C.ink}" stroke-width="5"/>
|
||||
<rect x="18" y="106" width="22" height="46" rx="11" fill="${C.body}" stroke="${C.ink}" stroke-width="5" transform="rotate(${a} 29 129)"/>
|
||||
<rect x="160" y="106" width="22" height="46" rx="11" fill="${C.body}" stroke="${C.ink}" stroke-width="5" transform="rotate(${-a} 171 129)"/>
|
||||
<path d="M62 46 L48 16 L70 34 Z" fill="${C.spike}" stroke="${C.ink}" stroke-width="5" stroke-linejoin="round"/>
|
||||
<path d="M138 46 L152 16 L130 34 Z" fill="${C.spike}" stroke="${C.ink}" stroke-width="5" stroke-linejoin="round"/>
|
||||
<path d="M100 30 Q112 30 118 42 L160 156 Q166 172 150 174 L50 174 Q34 172 40 156 L82 42 Q88 30 100 30 Z" fill="${C.body}" stroke="${C.ink}" stroke-width="6" stroke-linejoin="round"/>
|
||||
<path d="M100 74 Q108 74 112 84 L136 148 Q140 158 128 158 L72 158 Q60 158 64 148 L88 84 Q92 74 100 74 Z" fill="${C.belly}"/>
|
||||
${f.extra}<g>${f.eyes}</g>${f.cheeks}${f.mouth}
|
||||
</g></svg>`;
|
||||
}
|
||||
|
||||
function seyarPiSVG(mood: SeyarMood): string {
|
||||
const C = PALETTE.pi;
|
||||
const f = face(mood, 80, 112, 100, 138, C.ink, C.mouth, C.tear, C.cheek, 40);
|
||||
const a = 18 + ARM_DELTA[mood], dy = Y_SHIFT[mood];
|
||||
return `<svg viewBox="-14 -34 228 250" xmlns="http://www.w3.org/2000/svg"><g transform="translate(0 ${dy})">
|
||||
<ellipse cx="76" cy="184" rx="19" ry="12" fill="${C.inner}" stroke="${C.ink}" stroke-width="5"/>
|
||||
<ellipse cx="124" cy="184" rx="19" ry="12" fill="${C.inner}" stroke="${C.ink}" stroke-width="5"/>
|
||||
<rect x="16" y="112" width="22" height="48" rx="11" fill="${C.body}" stroke="${C.ink}" stroke-width="5" transform="rotate(${a} 27 136)"/>
|
||||
<rect x="162" y="112" width="22" height="48" rx="11" fill="${C.body}" stroke="${C.ink}" stroke-width="5" transform="rotate(${-a} 173 136)"/>
|
||||
<g stroke="${C.ink}" stroke-width="7" stroke-linecap="round">
|
||||
<line x1="78" y1="22" x2="122" y2="22"/><line x1="86" y1="22" x2="84" y2="44"/><line x1="114" y1="22" x2="116" y2="44"/>
|
||||
</g>
|
||||
<ellipse cx="100" cy="118" rx="74" ry="72" fill="${C.body}" stroke="${C.ink}" stroke-width="6"/>
|
||||
<ellipse cx="100" cy="134" rx="46" ry="46" fill="${C.belly}"/>
|
||||
${f.extra}<g>${f.eyes}</g>${f.cheeks}${f.mouth}
|
||||
</g></svg>`;
|
||||
}
|
||||
|
||||
export const SEYAR_SVG: Record<SeyarChar, (mood: SeyarMood) => string> = {
|
||||
sigma: seyarSigmaSVG, delta: seyarDeltaSVG, pi: seyarPiSVG,
|
||||
};
|
||||
export const SEYAR_INFO: Record<SeyarChar, { name: string; symbol: string; role: string }> = {
|
||||
sigma: { name: "سیگما", symbol: "Σ", role: "راهنمای آزمون و کوییز" },
|
||||
delta: { name: "دلتا", symbol: "Δ", role: "همتیمیِ تمرینهای سریع" },
|
||||
pi: { name: "پی", symbol: "π", role: "همراهِ دلگرمی" },
|
||||
};
|
||||
|
||||
/** slugِ کاراکترِ سرور (sigma/delta/pi) را به یکی از سهیار نگاشت میکند. */
|
||||
export function seyarFromSlug(slug: string | null | undefined): SeyarChar | null {
|
||||
const s = (slug ?? '').toLowerCase();
|
||||
if (s === 'sigma' || s === 'delta' || s === 'pi') return s;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** حالتِ ماسکوت که سرور میدهد («happy»، «encourage»، …) را به حالتِ سهیار نگاشت میکند. */
|
||||
export function moodFromServer(server: string | null | undefined): SeyarMood {
|
||||
const s = (server ?? '').toLowerCase();
|
||||
if (s.includes('excited') || s.includes('celebrat')) return 'excited';
|
||||
if (s.includes('happy') || s.includes('encourage')) return 'happy';
|
||||
if (s.includes('sad')) return 'sad';
|
||||
if (s.includes('angry')) return 'angry';
|
||||
if (s.includes('depress') || s.includes('tired')) return 'depressed';
|
||||
return 'idle';
|
||||
}
|
||||
Reference in New Issue
Block a user