Files
MathApp_frontend/src/mascots/seyar.ts
AliReza 348bfecea6 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>
2026-09-26 23:35:48 +03:30

151 lines
12 KiB
TypeScript

/**
* سه‌یار: سیگما (Σ)، دلتا (Δ) و پی (π) — ماسکوت‌های اپ.
*
* هر سه از دلِ یک دفترچه‌ی بی‌نهایت آمده‌اند و هر کدام از جنسِ یک نمادِ ریاضی‌اند:
* سیگما → راهنمای درس و آزمون دلتا → هم‌تیمیِ تمرینِ سریع پی → دستیار و همراهِ دلگرمی
*
* خروجی رشته‌ی 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}">&#931;</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';
}