The selected companion everywhere, each with a small trait; next-APK notes

- state/companion.ts: one store for the chosen companion (also in localStorage so splash/login show it).
  BrandMark (the orange «π» tile) now shows the companion's symbol (Σ Δ π ω) in its colour; screen-header
  mascots, loaders and empty/error states default to it; the assistant is «از {name} بپرس» with its face,
  colour and wording; the shop, lesson and quiz «ask» buttons name it; the active tab takes its tint.
- Traits, all client-side and light: Σ sigma keeps a running tally of correct answers in the quiz header;
  Δ delta shows a flame «×N» chip and an extra buzz for answers in a row; π pi gives a short solving tip
  after a wrong answer; ω omega throws gold confetti at the end of every island whatever the score.
  Each companion's «correct» sound is pitched differently; confetti uses its colours. Characters screen
  lists each trait.
- android/README.md: what MainActivity.kt has that the installed APK lacks (vibrate bridge, media gesture,
  in-app gateway + paysInApp), what to check on a phone after building, signing owner, Bazaar policy note.

Checked in headless Chrome with Sigma, Delta and Omega selected (home, assistant, shop, characters, quiz).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-10 18:51:19 +03:30
parent 737414f246
commit 8a61cb25b8
21 changed files with 256 additions and 69 deletions

View File

@@ -5,15 +5,40 @@
محصولِ دیجیتال را فقط از راهِ خودش می‌پذیرد. باقیِ اپ همان فرانتِ وب است و با دیپلویِ محصولِ دیجیتال را فقط از راهِ خودش می‌پذیرد. باقیِ اپ همان فرانتِ وب است و با دیپلویِ
فرانت به‌روز می‌شود، بی‌آن‌که نسخه‌ی تازه‌ی اپ لازم باشد. فرانت به‌روز می‌شود، بی‌آن‌که نسخه‌ی تازه‌ی اپ لازم باشد.
> ⚠️ **این پروژه هنوز یک بار هم build نشده.** روی ماشینی نوشته شد که Android SDK نداشت > **ساخت و امضا با هم‌تیمی است (MH-W90، m.hassanabadi)** با روشِ امضای خودش (`my-release-key.keystore` +
> (۲۰۲۶-۱۰-۰۳). APIِ Poolakey از README خودِ کتابخانه است. اولین کار: باز کردن در Android > propertyهای Gradle)؛ تصمیمِ مالک این است که همان روش بماند. پیش از دست زدن به امضا از مالک بپرس و هرگز
> Studio، build، و یک خریدِ واقعی روی گوشی. > keystore را commit نکن. `applicationId` واقعی `com.tadrisino.math.app` است (از APKِ ساخته‌شده با aapt2).
> هیچ خریدِ واقعیِ بازار هنوز آزموده نشده.
## نسخه‌ی بعدیِ APK — چه چیزی در کد هست و در اپِ نصب‌شده نیست
این‌ها در `MainActivity.kt` نوشته شده‌اند (۱۴۰۵-۰۷، `468f5b2` و `737414f`) ولی هیچ‌کدام build نشده‌اند؛ اپی که الان روی
گوشی‌هاست بدونِ آن‌هاست. فرانت با هر دو نسخه کار می‌کند (اگر پل نبود، راهِ قدیم را می‌رود).
| تغییر | چرا | بعد از build روی گوشی بسنج |
|---|---|---|
| `TadrisinoAndroid.vibrate(patternJson)` با Vibratorِ گوشی | `navigator.vibrate` در WebView همیشه کار نمی‌کند؛ مالک گفت اپ لرزش ندارد | انتخابِ گزینه‌ی آزمون، جوابِ درست/غلط، پایانِ جزیره و صفحه‌ی «روزِ پیاپی» باید بلرزند؛ با خاموش کردنِ «صداها» در پروفایل نه |
| `mediaPlaybackRequiresUserGesture = false` | صداهای رابط بعد از پاسخِ سرور پخش می‌شوند، نه در لحظه‌ی لمس | صدای جوابِ درست/غلط و پایانِ تمرین |
| `isPaymentPage` + `paysInApp()` | درگاهِ زرین‌پال داخلِ خودِ اپ باز می‌شود. اپِ فعلی آن را به مرورگرِ گوشی می‌دهد که Referer نمی‌فرستد و زرین‌پال بی‌Referer «دسترسی از این دامنه مجاز نمی‌باشد» می‌دهد؛ فعلاً فرانت از راهِ `pay.html` (با `http://`) دورش می‌زند | «پرداخت» با کارتِ بانکی: صفحه‌ی زرین‌پال و بانک داخلِ اپ، و بعد از پرداخت برگشت به اپ با پیامِ «پرداخت موفق بود» (PaymentResult). دکمه‌ی برگشت وسطِ صفحه‌ی بانک چه می‌کند را هم ببین |
پیشنهادهایی که هنوز نوشته نشده‌اند:
- **«برای خروج دوباره بزن»**: دکمه‌ی برگشتِ گوشی حالا قدم‌به‌قدم در خودِ وب عقب می‌رود (`src/nav/Navigator.tsx`) و
از خانه یک‌راست بیرون می‌رود. اگر تأییدِ خروج خواستید، در `handleOnBackPressed` وقتی `!webView.canGoBack()` یک
Toast بدهید و فقط برگشتِ دوم در دو ثانیه `finish()` کند. در وب شدنی نیست (کروم ورودیِ history بی‌لمس را رد می‌کند).
- `versionCode`/`versionName` را برای هر نسخه بالا ببرید؛ هنوز ۱ و 1.0.0 است.
قانونِ بازار: داخلِ اپِ بازار حالا پرداخت با درگاه هم پیشنهاد می‌شود (خواستِ مالک). بازار پرداختِ محتوای دیجیتال بیرون از
پرداختِ درون‌برنامه‌ای را محدود می‌کند؛ پیش از انتشار در بازار این را با بازار یا مالک روشن کنید.
ساخت روی مکِ مالک: SDK در `~/Library/Android/sdk`، JDK 17 (`/opt/homebrew/opt/openjdk@17`)، و چون Google Maven از
ایران بی‌VPN ۴۰۴ می‌دهد، `~/.gradle/init.d/google-mirror.gradle` آینه‌ی Aliyun را اضافه می‌کند؛ ساختِ اول ~۹ دقیقه.
## پیش از build — سه چیز از پیشخانِ بازار (سنیور / حسابِ شرکت) ## پیش از build — سه چیز از پیشخانِ بازار (سنیور / حسابِ شرکت)
| چه | کجا گذاشته می‌شود | وضعیت | | چه | کجا گذاشته می‌شود | وضعیت |
|---|---|---| |---|---|---|
| اسمِ پکیج | `applicationId` در `app/build.gradle.kts` | `com.tadrisino.app` حدس است؛ تأیید نشده | | اسمِ پکیج | `applicationId` در `app/build.gradle.kts` | `com.tadrisino.math.app` (APKِ ساخته‌شده)؛ ثبتش در پیشخان تأیید نشده |
| کلیدِ RSAِ عمومیِ اپ | `BAZAAR_RSA_KEY` در `app/build.gradle.kts` | ندارد؛ بدونِ آن خرید شروع نمی‌شود | | کلیدِ RSAِ عمومیِ اپ | `BAZAAR_RSA_KEY` در `app/build.gradle.kts` | ندارد؛ بدونِ آن خرید شروع نمی‌شود |
| SKUِ هر بسته (محصولِ **مصرف‌شدنی**) | پنلِ ادمینِ بک‌اند: `/admin/subscription/plan/` ← «Bazaar sku» | هیچ‌کدام تعریف نشده | | SKUِ هر بسته (محصولِ **مصرف‌شدنی**) | پنلِ ادمینِ بک‌اند: `/admin/subscription/plan/` ← «Bazaar sku» | هیچ‌کدام تعریف نشده |

View File

@@ -1,4 +1,6 @@
import { sounds } from '@/lib/sound'; import { sounds } from '@/lib/sound';
import { useCompanion } from '@/state/companion';
import { onWhite } from '@/lib/color';
import { Brain, ShoppingBag, Home, BookOpen, UserRound, type LucideIcon } from 'lucide-react'; import { Brain, ShoppingBag, Home, BookOpen, UserRound, type LucideIcon } from 'lucide-react';
import type { TabName } from '@/nav/routes'; import type { TabName } from '@/nav/routes';
@@ -11,6 +13,8 @@ const ITEMS: { id: TabName; icon: LucideIcon; label: string }[] = [
]; ];
export function BottomNav({ active, onChange }: { active: TabName | null; onChange: (tab: TabName) => void }) { export function BottomNav({ active, onChange }: { active: TabName | null; onChange: (tab: TabName) => void }) {
// تبِ روشن به رنگِ همراهِ انتخابی (تیره‌شده تا روی سفید خوانا بماند؛ امگای طلایی بی‌آن کم‌رنگ بود).
const tint = onWhite(useCompanion().color);
return ( return (
<nav className="flex flex-shrink-0 items-center justify-around border-t border-line bg-white px-2 pb-safe pt-1.5"> <nav className="flex flex-shrink-0 items-center justify-around border-t border-line bg-white px-2 pb-safe pt-1.5">
{ITEMS.map(({ id, icon: Icon, label }) => { {ITEMS.map(({ id, icon: Icon, label }) => {
@@ -24,9 +28,10 @@ export function BottomNav({ active, onChange }: { active: TabName | null; onChan
onChange(id); onChange(id);
}} }}
aria-current={on ? 'page' : undefined} aria-current={on ? 'page' : undefined}
className={`flex min-w-14 flex-col items-center gap-0.5 rounded-xl px-2 pb-1.5 transition-[color,transform] active:scale-90 ${on ? 'text-coral' : 'text-muted'}`} className={`flex min-w-14 flex-col items-center gap-0.5 rounded-xl px-2 pb-1.5 transition-[color,transform] active:scale-90 ${on ? '' : 'text-muted'}`}
style={on ? { color: tint } : undefined}
> >
<span className={`rounded-xl p-1.5 transition-colors ${on ? 'bg-[#fde9e5]' : ''}`}> <span className="rounded-xl p-1.5 transition-colors" style={on ? { backgroundColor: `${tint}1F` } : undefined}>
<Icon size={20} strokeWidth={on ? 2.5 : 1.9} /> <Icon size={20} strokeWidth={on ? 2.5 : 1.9} />
</span> </span>
<span className="text-[11px] font-bold">{label}</span> <span className="text-[11px] font-bold">{label}</span>

View File

@@ -1,20 +1,28 @@
import { useId } from 'react';
import { useCompanion } from '@/state/companion';
/** /**
* نشانِ تدریسینو: یک کاشیِ گرد با «π» و جرقه‌ی کوچکِ چشمک‌زن، کنارِ نامِ تدریسینو. * نشانِ تدریسینو: یک کاشیِ گرد با نمادِ همراهِ انتخابیِ کاربر (Σ، Δ، π، ω) به رنگِ همان همراه، و جرقه‌ی کوچکِ
* خودِ SVG است (بی‌فایلِ تصویر)، پس در هر اندازه تیز است و چیزی دانلود نمی‌شود. * چشمک‌زن، کنارِ نامِ تدریسینو. پیش‌تر همیشه «π»ِ نارنجی بود و با همراهِ کاربر جور نبود (خواستِ مالک، ۱۴۰۵-۰۷).
* خودِ SVG است (بی‌فایلِ تصویر).
*/ */
export function BrandMark({ size = 28, tone = 'light', withName = true }: { size?: number; tone?: 'light' | 'dark'; withName?: boolean }) { export function BrandMark({ size = 28, tone = 'light', withName = true }: { size?: number; tone?: 'light' | 'dark'; withName?: boolean }) {
const ink = tone === 'light' ? '#fff' : '#3C3F8F'; const ink = tone === 'light' ? '#fff' : '#3C3F8F';
const c = useCompanion();
const gid = useId();
return ( return (
<span className="inline-flex items-center gap-1.5" aria-label="تدریسینو"> <span className="inline-flex items-center gap-1.5" aria-label="تدریسینو">
<svg width={size} height={size} viewBox="0 0 32 32" aria-hidden="true"> <svg width={size} height={size} viewBox="0 0 32 32" aria-hidden="true">
<defs> <defs>
<linearGradient id="bm-g" x1="0" y1="0" x2="1" y2="1"> <linearGradient id={gid} x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stopColor="#FF9A5C" /> <stop offset="0" stopColor={c.light} />
<stop offset="1" stopColor="#ED6956" /> <stop offset="1" stopColor={c.color} />
</linearGradient> </linearGradient>
</defs> </defs>
<rect x="1" y="1" width="30" height="30" rx="9" fill="url(#bm-g)" stroke={tone === 'light' ? 'rgba(255,255,255,.55)' : 'none'} /> <rect x="1" y="1" width="30" height="30" rx="9" fill={`url(#${gid})`} stroke={tone === 'light' ? 'rgba(255,255,255,.55)' : 'none'} />
<path d="M9 11.5h14M12.5 11.5v10M19 11.5v8.2c0 1.2.7 1.8 1.8 1.8" stroke="#fff" strokeWidth="2.6" strokeLinecap="round" fill="none" /> <text x="16" y="17" textAnchor="middle" dominantBaseline="central" fill="#fff" fontSize="19" fontWeight="900" fontFamily="Vazirmatn, system-ui, sans-serif">
{c.symbol}
</text>
<path className="spark" d="M25 4.5l.9 1.9 1.9.9-1.9.9-.9 1.9-.9-1.9-1.9-.9 1.9-.9z" fill="#FFD23F" /> <path className="spark" d="M25 4.5l.9 1.9 1.9.9-1.9.9-.9 1.9-.9-1.9-1.9-.9 1.9-.9z" fill="#FFD23F" />
</svg> </svg>
{withName && <span className="font-black tracking-tight" style={{ color: ink, fontSize: size * 0.62 }}>تدریسینو</span>} {withName && <span className="font-black tracking-tight" style={{ color: ink, fontSize: size * 0.62 }}>تدریسینو</span>}

View File

@@ -7,6 +7,7 @@ import { useNav } from '@/nav/Navigator';
import { SeyarMascot } from '@/mascots/SeyarMascot'; import { SeyarMascot } from '@/mascots/SeyarMascot';
import { PrimaryButton, Screen } from '@/components/ui'; import { PrimaryButton, Screen } from '@/components/ui';
import { toFa } from '@/lib/format'; import { toFa } from '@/lib/format';
import { useCompanion } from '@/state/companion';
/** /**
* «قلب‌هات تموم شد»: با قلبِ صفر سرور هیچ جوابی نمی‌پذیرد (۴۰۳ با code=no_hearts) و پایانِ درس یا * «قلب‌هات تموم شد»: با قلبِ صفر سرور هیچ جوابی نمی‌پذیرد (۴۰۳ با code=no_hearts) و پایانِ درس یا
@@ -56,6 +57,7 @@ export function NoHeartsScreen({
/** قلب برگشت و کاربر «ادامه بده» زد. */ /** قلب برگشت و کاربر «ادامه بده» زد. */
onRefilled: () => void; onRefilled: () => void;
}) { }) {
const companionName = useCompanion().name;
const nav = useNav(); const nav = useNav();
const { companion } = useAppData(); const { companion } = useAppData();
const now = useNow(); const now = useNow();
@@ -174,7 +176,7 @@ export function NoHeartsScreen({
<Brain size={20} /> <Brain size={20} />
</span> </span>
<span className="flex-1"> <span className="flex-1">
<span className="block text-sm font-black text-ink">تا اون موقع از «پی» بپرس</span> <span className="block text-sm font-black text-ink">تا اون موقع از «{companionName}» بپرس</span>
<span className="block text-xs leading-6 text-muted">جایی که گیر کردی رو با هم مرور کنیم؛ قلب خرج نمی‌کنه.</span> <span className="block text-xs leading-6 text-muted">جایی که گیر کردی رو با هم مرور کنیم؛ قلب خرج نمی‌کنه.</span>
</span> </span>
</button> </button>

View File

@@ -5,6 +5,7 @@ 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 { onWhite, shade } from '@/lib/color'; import { onWhite, shade } from '@/lib/color';
import { useCompanion } from '@/state/companion';
/* ─── سربرگِ رنگیِ صفحه ──────────────────────────────────────────────────── */ /* ─── سربرگِ رنگیِ صفحه ──────────────────────────────────────────────────── */
@@ -22,12 +23,14 @@ export function ScreenHeader({
color: string; color: string;
/** دکمه‌ی بازگشت؛ true یعنی صفحه‌ی قبل در پشته. */ /** دکمه‌ی بازگشت؛ true یعنی صفحه‌ی قبل در پشته. */
back?: boolean | (() => void); back?: boolean | (() => void);
mascot?: { char: SeyarChar; mood?: SeyarMood; size?: number }; /** بی `char` همراهِ انتخابیِ کاربر است؛ همه‌ی صفحه‌ها همین را می‌خواهند. */
mascot?: { char?: SeyarChar; mood?: SeyarMood; size?: number };
/** رنگ را همان‌طور که هست بگذار (بی‌تیره‌کردن برای کنتراست)؛ انتخابِ مالک برای چند صفحه. */ /** رنگ را همان‌طور که هست بگذار (بی‌تیره‌کردن برای کنتراست)؛ انتخابِ مالک برای چند صفحه. */
exact?: boolean; exact?: boolean;
children?: ReactNode; children?: ReactNode;
}) { }) {
const nav = useNav(); const nav = useNav();
const companion = useCompanion().char;
const onBack = typeof back === 'function' ? back : nav.back; const onBack = typeof back === 'function' ? back : nav.back;
const bg = exact ? color : onWhite(color); const bg = exact ? color : onWhite(color);
return ( return (
@@ -52,7 +55,7 @@ export function ScreenHeader({
<h1 className="text-xl font-black leading-snug">{title}</h1> <h1 className="text-xl font-black leading-snug">{title}</h1>
{subtitle && <div className="mt-1 text-xs text-white/75">{subtitle}</div>} {subtitle && <div className="mt-1 text-xs text-white/75">{subtitle}</div>}
</div> </div>
{mascot && <SeyarMascot char={mascot.char} mood={mascot.mood ?? 'happy'} size={mascot.size ?? 64} />} {mascot && <SeyarMascot char={mascot.char ?? companion} mood={mascot.mood ?? 'happy'} size={mascot.size ?? 64} />}
</div> </div>
{children} {children}
</div> </div>
@@ -128,10 +131,11 @@ export function Spinner({ size = 20, className = '' }: { size?: number; classNam
/* ─── حالت‌های بارگذاری / خطا / خالی ───────────────────────────────────────── */ /* ─── حالت‌های بارگذاری / خطا / خالی ───────────────────────────────────────── */
function StateBox({ char, mood, text, children }: { char: SeyarChar; mood: SeyarMood; text: ReactNode; children?: ReactNode }) { function StateBox({ char, mood, text, children }: { char?: SeyarChar; mood: SeyarMood; text: ReactNode; children?: ReactNode }) {
const companion = useCompanion().char;
return ( return (
<div className="flex flex-col items-center justify-center gap-3 px-8 py-16 text-center"> <div className="flex flex-col items-center justify-center gap-3 px-8 py-16 text-center">
<SeyarMascot char={char} mood={mood} size={72} /> <SeyarMascot char={char ?? companion} mood={mood} size={72} />
<p className="text-sm leading-7 text-muted">{text}</p> <p className="text-sm leading-7 text-muted">{text}</p>
{children} {children}
</div> </div>
@@ -143,7 +147,7 @@ function StateBox({ char, mood, text, children }: { char: SeyarChar; mood: Seyar
* همان چند فریم، پرشِ ریزِ «لودر ← محتوا» می‌ساخت؛ حالا تا آن لحظه جای خالی می‌ماند. اگر پاسخ * همان چند فریم، پرشِ ریزِ «لودر ← محتوا» می‌ساخت؛ حالا تا آن لحظه جای خالی می‌ماند. اگر پاسخ
* کند باشد کاربر باز هم لودر را می‌بیند. در مرورگر با شبکه‌ی کند آزموده نشده. * کند باشد کاربر باز هم لودر را می‌بیند. در مرورگر با شبکه‌ی کند آزموده نشده.
*/ */
export function LoadingState({ text = 'یه لحظه صبر کن…', char = 'sigma' as SeyarChar, delayMs = 300 }) { export function LoadingState({ text = 'یه لحظه صبر کن…', char, delayMs = 300 }: { text?: string; char?: SeyarChar; delayMs?: number }) {
const [show, setShow] = useState(delayMs <= 0); const [show, setShow] = useState(delayMs <= 0);
useEffect(() => { useEffect(() => {
if (delayMs <= 0) return; if (delayMs <= 0) return;
@@ -155,7 +159,7 @@ export function LoadingState({ text = 'یه لحظه صبر کن…', char = 'si
export function ErrorState({ error, onRetry, text }: { error?: unknown; onRetry?: () => void; text?: string }) { export function ErrorState({ error, onRetry, text }: { error?: unknown; onRetry?: () => void; text?: string }) {
return ( return (
<StateBox char="pi" mood="sad" text={text ?? friendlyError(error)}> <StateBox mood="sad" text={text ?? friendlyError(error)}>
{onRetry && ( {onRetry && (
<button <button
type="button" type="button"
@@ -169,7 +173,7 @@ export function ErrorState({ error, onRetry, text }: { error?: unknown; onRetry?
); );
} }
export function EmptyState({ text, char = 'pi' as SeyarChar }: { text: string; char?: SeyarChar }) { export function EmptyState({ text, char }: { text: string; char?: SeyarChar }) {
return <StateBox char={char} mood="idle" text={text} />; return <StateBox char={char} mood="idle" text={text} />;
} }

View File

@@ -130,11 +130,18 @@ export const sounds = {
note(880, 0, 0.05, 0.025, 'triangle'); note(880, 0, 0.05, 0.025, 'triangle');
buzz(8); buzz(8);
}, },
correct: () => { /** `pitch`: نیم‌پرده بالاتر برای همراهِ انتخابی (state/companion)؛ هر همراه صدای خودش را دارد. */
note(E5, 0, 0.12, 0.05); correct: (pitch = 0) => {
note(C6, 0.09, 0.22, 0.05); const k = 2 ** (pitch / 12);
note(E5 * k, 0, 0.12, 0.05);
note(C6 * k, 0.09, 0.22, 0.05);
buzz(25); buzz(25);
}, },
/** دلتا: جوابِ درستِ پشتِ هم یک لرزشِ دوتایی و یک نتِ بالاتر اضافه دارد. */
combo: () => {
note(G6, 0.2, 0.15, 0.03);
buzz([20, 40, 30]);
},
wrong: () => { wrong: () => {
note(311, 0, 0.16, 0.045, 'triangle'); note(311, 0, 0.16, 0.045, 'triangle');
note(233, 0.12, 0.24, 0.045, 'triangle'); note(233, 0.12, 0.24, 0.045, 'triangle');

View File

@@ -28,4 +28,5 @@ export const KEYS = {
conversationId: 'tadrisino.conversationId', conversationId: 'tadrisino.conversationId',
sound: 'tadrisino.sound', sound: 'tadrisino.sound',
referralCode: 'tadrisino.referralCode', referralCode: 'tadrisino.referralCode',
companion: 'tadrisino.companion',
} as const; } as const;

View File

@@ -13,12 +13,14 @@ import { MathText } from '@/lib/MathText';
import { friendlyError } from '@/lib/errors'; import { friendlyError } from '@/lib/errors';
import { toFa } from '@/lib/format'; import { toFa } from '@/lib/format';
import { useGateHold } from '@/lib/useAsync'; import { useGateHold } from '@/lib/useAsync';
import { iAm, useCompanion } from '@/state/companion';
const STARTERS = ['این فصل درباره‌ی چیه؟', 'یه مثالِ ساده برام حل کن', 'چطور برای امتحان تمرین کنم؟']; const STARTERS = ['این فصل درباره‌ی چیه؟', 'یه مثالِ ساده برام حل کن', 'چطور برای امتحان تمرین کنم؟'];
/** «از پی بپرس»: دستیارِ ریاضی که از روی کتابِ پایه‌ی کاربر جواب می‌دهد. */ /** «از {همراه} بپرس»: دستیارِ ریاضی که از روی کتابِ پایه‌ی کاربر جواب می‌دهد؛ چهره و نامش همراهِ انتخابیِ کاربر است. */
export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number; questionId?: string; prompt?: string }) { export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number; questionId?: string; prompt?: string }) {
const { profile } = useAppData(); const { profile } = useAppData();
const companion = useCompanion();
const chat = useAskState(); const chat = useAskState();
const [input, setInput] = useState(''); const [input, setInput] = useState('');
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@@ -132,11 +134,11 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
return ( return (
<Screen> <Screen>
<ScreenHeader <ScreenHeader
title="از پی بپرس" title={`از ${companion.name} بپرس`}
subtitle={chat.lessonId ? 'از روی کتابِ همین درس جواب می‌دم' : 'دستیارِ ریاضی، از روی کتابِ خودت'} subtitle={chat.lessonId ? 'از روی کتابِ همین درس جواب می‌دم' : 'دستیارِ ریاضی، از روی کتابِ خودت'}
color="#9B7BFF" color={companion.color}
back={lessonId !== undefined || prompt !== undefined} back={lessonId !== undefined || prompt !== undefined}
mascot={{ char: 'pi', mood: busy ? 'excited' : 'idle', size: 60 }} mascot={{ mood: busy ? 'excited' : 'idle', size: 60 }}
> >
<div className="mt-3 flex flex-wrap items-center gap-2"> <div className="mt-3 flex flex-wrap items-center gap-2">
{quota !== null && <QuotaPills free={quota.free} credits={quota.credits} />} {quota !== null && <QuotaPills free={quota.free} credits={quota.credits} />}
@@ -155,7 +157,7 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
<div className="flex flex-1 flex-col gap-3 overflow-y-auto px-4 py-4"> <div className="flex flex-1 flex-col gap-3 overflow-y-auto px-4 py-4">
<Bubble role="assistant"> <Bubble role="assistant">
سلام {profile.name || 'دوستِ من'}! من پی‌ام 🙂 هر سوالی از ریاضیِ کتابت داری بپرس؛ قدم‌به‌قدم با هم حلش می‌کنیم. سلام {profile.name || 'دوستِ من'}! {`من ${iAm(companion.name)}`} 🙂 هر سوالی از ریاضیِ کتابت داری بپرس؛ قدم‌به‌قدم با هم حلش می‌کنیم.
</Bubble> </Bubble>
{empty && chat.historyLoaded && ( {empty && chat.historyLoaded && (
@@ -216,11 +218,12 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
function Bubble({ role, children, extra }: { role: 'user' | 'assistant'; children: React.ReactNode; extra?: React.ReactNode }) { function Bubble({ role, children, extra }: { role: 'user' | 'assistant'; children: React.ReactNode; extra?: React.ReactNode }) {
const mine = role === 'user'; const mine = role === 'user';
const companion = useCompanion().char;
return ( return (
<div className={`flex gap-2 ${mine ? 'flex-row-reverse' : ''}`}> <div className={`flex gap-2 ${mine ? 'flex-row-reverse' : ''}`}>
{!mine && ( {!mine && (
<span className="flex h-8 w-8 flex-shrink-0 items-center justify-center overflow-hidden rounded-full bg-[#f5f1ff]"> <span className="flex h-8 w-8 flex-shrink-0 items-center justify-center overflow-hidden rounded-full bg-[#f5f1ff]">
<SeyarMascot char="pi" mood="happy" size={32} /> <SeyarMascot char={companion} mood="happy" size={32} />
</span> </span>
)} )}
<div <div
@@ -242,8 +245,8 @@ function Bubble({ role, children, extra }: { role: 'user' | 'assistant'; childre
* سرور وصل نیستند. * سرور وصل نیستند.
*/ */
const THINKING_STEPS: [number, string][] = [ const THINKING_STEPS: [number, string][] = [
[0, 'پی داره تو کتابت می‌گرده'], [0, '{name} داره تو کتابت می‌گرده'],
[4, 'پی داره حلش می‌کنه'], [4, '{name} داره حلش می‌کنه'],
[15, 'داره قدم‌ها رو مرتب می‌نویسه'], [15, 'داره قدم‌ها رو مرتب می‌نویسه'],
[28, 'جوابِ کامل یه کم طول می‌کشه، الان می‌رسه'], [28, 'جوابِ کامل یه کم طول می‌کشه، الان می‌رسه'],
]; ];
@@ -255,11 +258,12 @@ function Thinking() {
const timer = setInterval(() => setSeconds((Date.now() - started) / 1000), 1000); const timer = setInterval(() => setSeconds((Date.now() - started) / 1000), 1000);
return () => clearInterval(timer); return () => clearInterval(timer);
}, []); }, []);
const text = [...THINKING_STEPS].reverse().find(([at]) => seconds >= at)![1]; const companion = useCompanion();
const text = [...THINKING_STEPS].reverse().find(([at]) => seconds >= at)![1].replace('{name}', companion.name);
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="flex h-8 w-8 flex-shrink-0 items-center justify-center overflow-hidden rounded-full bg-[#f5f1ff]"> <span className="flex h-8 w-8 flex-shrink-0 items-center justify-center overflow-hidden rounded-full bg-[#f5f1ff]">
<SeyarMascot char="pi" mood="idle" size={32} /> <SeyarMascot char={companion.char} mood="idle" size={32} />
</span> </span>
<div className="flex items-center gap-2 rounded-2xl border border-line bg-white px-4 py-3 text-xs text-muted" aria-live="polite"> <div className="flex items-center gap-2 rounded-2xl border border-line bg-white px-4 py-3 text-xs text-muted" aria-live="polite">
{text} {text}

View File

@@ -4,7 +4,8 @@ import { api, type Character } from '@/api';
import { useAppData } from '@/state/AppData'; import { useAppData } from '@/state/AppData';
import { useAsync } from '@/lib/useAsync'; import { useAsync } from '@/lib/useAsync';
import { SeyarMascot } from '@/mascots/SeyarMascot'; import { SeyarMascot } from '@/mascots/SeyarMascot';
import { SEYAR_INFO, seyarFromSlug } from '@/mascots/seyar'; import { seyarFromSlug } from '@/mascots/seyar';
import { COMPANIONS } from '@/state/companion';
import { useToast } from '@/components/Toast'; import { useToast } from '@/components/Toast';
import { EmptyState, ErrorState, LoadingState, Screen, ScreenHeader, Sep } from '@/components/ui'; import { EmptyState, ErrorState, LoadingState, Screen, ScreenHeader, Sep } from '@/components/ui';
import { friendlyError } from '@/lib/errors'; import { friendlyError } from '@/lib/errors';
@@ -54,6 +55,7 @@ export function CharactersScreen() {
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
{list.data.map((c) => { {list.data.map((c) => {
const seyar = seyarFromSlug(c.slug); const seyar = seyarFromSlug(c.slug);
const look = seyar ? COMPANIONS[seyar] : null;
return ( return (
<button <button
key={c.characterId} key={c.characterId}
@@ -62,11 +64,12 @@ export function CharactersScreen() {
onClick={() => void select(c)} onClick={() => void select(c)}
aria-pressed={c.selected} aria-pressed={c.selected}
className={`relative flex flex-col items-center gap-2 rounded-3xl border-2 p-4 transition-transform ${ className={`relative flex flex-col items-center gap-2 rounded-3xl border-2 p-4 transition-transform ${
c.selected ? 'border-violet bg-[#F5F1FF]' : c.unlocked ? 'border-line bg-white active:scale-95' : 'border-line bg-[#f4f3fa]' c.selected ? '' : c.unlocked ? 'border-line bg-white active:scale-95' : 'border-line bg-[#f4f3fa]'
}`} }`}
style={c.selected && look ? { borderColor: look.color, backgroundColor: `${look.color}12` } : undefined}
> >
{c.selected && ( {c.selected && (
<span className="absolute left-2 top-2 flex h-5 w-5 items-center justify-center rounded-full bg-violet"> <span className="absolute left-2 top-2 flex h-5 w-5 items-center justify-center rounded-full bg-violet" style={look ? { backgroundColor: look.color } : undefined}>
<Check size={11} className="text-white" strokeWidth={3} /> <Check size={11} className="text-white" strokeWidth={3} />
</span> </span>
)} )}
@@ -84,15 +87,14 @@ export function CharactersScreen() {
<Lock size={16} className="text-[#5a5580]" /> <Lock size={16} className="text-[#5a5580]" />
</span> </span>
)} )}
<span className="text-sm font-bold text-ink">{c.name}</span> <span className="text-sm font-bold text-ink">{look ? `${look.symbol} ${c.name}` : c.name}</span>
{look && (
<span className="text-center text-[11px] leading-5 text-body">
<b style={{ color: look.color }}>{look.trait.title}:</b> {look.trait.text}
</span>
)}
<span className="text-center text-[10px] leading-5 text-muted"> <span className="text-center text-[10px] leading-5 text-muted">
{!c.unlocked {!c.unlocked ? `با ${toFa(c.unlockXp)} امتیاز باز می‌شه` : c.selected ? 'همراهِ فعلی' : 'بزن تا همراهت بشه'}
? `با ${toFa(c.unlockXp)} امتیاز باز می‌شه`
: c.selected
? 'همراهِ فعلی'
: seyar
? SEYAR_INFO[seyar].role
: 'بازشده'}
</span> </span>
</button> </button>
); );

View File

@@ -35,7 +35,7 @@ export function InviteScreen() {
return ( return (
<Screen> <Screen>
<ScreenHeader title="معرفی به دوستان" subtitle="دوستات رو بیار، با هم ریاضی بخونید" color="#5FBF8F" back mascot={{ char: 'delta', mood: 'happy', size: 56 }} /> <ScreenHeader title="معرفی به دوستان" subtitle="دوستات رو بیار، با هم ریاضی بخونید" color="#5FBF8F" back mascot={{ mood: 'happy', size: 56 }} />
<ScreenBody className="flex flex-col gap-4"> <ScreenBody className="flex flex-col gap-4">
{me.loading ? ( {me.loading ? (
<LoadingState /> <LoadingState />

View File

@@ -22,7 +22,7 @@ export function LeaderboardScreen() {
return ( return (
<Screen> <Screen>
<ScreenHeader title="جدولِ امتیازها" color="#FF7A3D" back mascot={{ char: 'delta', mood: 'excited', size: 56 }}> <ScreenHeader title="جدولِ امتیازها" color="#FF7A3D" back mascot={{ mood: 'excited', size: 56 }}>
<div className="mt-4 flex gap-1 rounded-2xl bg-white/15 p-1" role="tablist"> <div className="mt-4 flex gap-1 rounded-2xl bg-white/15 p-1" role="tablist">
{([['weekly', 'این هفته'], ['all_time', 'همه‌ی زمان‌ها']] as const).map(([p, label]) => ( {([['weekly', 'این هفته'], ['all_time', 'همه‌ی زمان‌ها']] as const).map(([p, label]) => (
<button <button
@@ -41,7 +41,7 @@ export function LeaderboardScreen() {
<div className="flex-1 overflow-y-auto px-4 py-4"> <div className="flex-1 overflow-y-auto px-4 py-4">
{board.loading ? ( {board.loading ? (
<LoadingState char="delta" text="دارم جدول رو میارم…" /> <LoadingState text="دارم جدول رو میارم…" />
) : board.error ? ( ) : board.error ? (
<ErrorState error={board.error} onRetry={board.reload} /> <ErrorState error={board.error} onRetry={board.reload} />
) : entries.length === 0 ? ( ) : entries.length === 0 ? (

View File

@@ -1,3 +1,4 @@
import { useCompanion } from '@/state/companion';
import { Brain, Play, Zap } from 'lucide-react'; import { Brain, Play, Zap } from 'lucide-react';
import { api } from '@/api'; import { api } from '@/api';
import { useAppData } from '@/state/AppData'; import { useAppData } from '@/state/AppData';
@@ -15,6 +16,7 @@ import { StepText } from '@/lib/StepText';
/** معرفیِ درس، پیش از تمرین. */ /** معرفیِ درس، پیش از تمرین. */
export function LessonScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: string }) { export function LessonScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: string }) {
const { home, companion } = useAppData(); const { home, companion } = useAppData();
const companionName = useCompanion().name;
const nav = useNav(); const nav = useNav();
const intro = useAsync(() => api.lesson.intro(lessonId), [lessonId], { key: `lesson-intro:${lessonId}` }); const intro = useAsync(() => api.lesson.intro(lessonId), [lessonId], { key: `lesson-intro:${lessonId}` });
const ref = findNode(home, nodeId) ?? findNodeOfLesson(home, lessonId); const ref = findNode(home, nodeId) ?? findNodeOfLesson(home, lessonId);
@@ -50,7 +52,7 @@ export function LessonScreen({ lessonId, nodeId }: { lessonId: number; nodeId?:
) : ( ) : (
<> <>
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<SeyarMascot char="sigma" mood="idle" size={56} /> <SeyarMascot char={companion} mood="idle" size={56} />
<div className="flex-1 rounded-3xl rounded-tr-sm border border-line bg-white p-4 shadow-sm"> <div className="flex-1 rounded-3xl rounded-tr-sm border border-line bg-white p-4 shadow-sm">
<StepText text={data.introText} color={color} className="text-[15px] leading-[1.95] text-body" /> <StepText text={data.introText} color={color} className="text-[15px] leading-[1.95] text-body" />
{figs('intro')} {figs('intro')}
@@ -100,7 +102,7 @@ export function LessonScreen({ lessonId, nodeId }: { lessonId: number; nodeId?:
onClick={() => nav.push({ name: 'ask', lessonId })} onClick={() => nav.push({ name: 'ask', lessonId })}
className="flex items-center justify-center gap-2 rounded-2xl bg-[#F5F1FF] py-3 text-sm font-bold text-[#7857E0]" className="flex items-center justify-center gap-2 rounded-2xl bg-[#F5F1FF] py-3 text-sm font-bold text-[#7857E0]"
> >
<Brain size={16} /> درباره‌ی این درس از پی بپرس <Brain size={16} /> درباره‌ی این درس از {companionName} بپرس
</button> </button>
</div> </div>
</> </>

View File

@@ -40,7 +40,7 @@ export function LessonsScreen() {
subtitle={<>{gradeTitle && <>{gradeTitle}<Sep /></>}اول بخون، بعد آزمونِ جزیره رو بده</>} subtitle={<>{gradeTitle && <>{gradeTitle}<Sep /></>}اول بخون، بعد آزمونِ جزیره رو بده</>}
color="#2FB36B" color="#2FB36B"
exact exact
mascot={{ char: 'sigma', mood: 'happy' }} mascot={{ mood: 'happy' }}
> >
{home && totalNodes > 0 && ( {home && totalNodes > 0 && (
<div className="mt-4"> <div className="mt-4">

View File

@@ -51,7 +51,7 @@ export function NotificationsScreen() {
return ( return (
<Screen> <Screen>
<ScreenHeader title="اعلان‌ها" color="#4C5FD5" back mascot={{ char: 'sigma', mood: 'idle', size: 54 }}> <ScreenHeader title="اعلان‌ها" color="#4C5FD5" back mascot={{ mood: 'idle', size: 54 }}>
{unread > 0 && ( {unread > 0 && (
<button type="button" onClick={() => void markAll()} className="mt-3 rounded-full bg-white/15 px-3 py-1.5 text-xs font-bold"> <button type="button" onClick={() => void markAll()} className="mt-3 rounded-full bg-white/15 px-3 py-1.5 text-xs font-bold">
همه خوانده شد همه خوانده شد

View File

@@ -17,7 +17,8 @@ import { toFa } from '@/lib/format';
import { MathText } from '@/lib/MathText'; import { MathText } from '@/lib/MathText';
import { sounds } from '@/lib/sound'; import { sounds } from '@/lib/sound';
import { useGateHold } from '@/lib/useAsync'; import { useGateHold } from '@/lib/useAsync';
import { StreakCelebration } from '@/components/StreakFlame'; import { StreakCelebration, StreakFlame } from '@/components/StreakFlame';
import { useCompanion } from '@/state/companion';
type Phase = type Phase =
| { kind: 'loading' } | { kind: 'loading' }
@@ -49,6 +50,7 @@ type PlayQuestion = Question & { lessonId?: string };
*/ */
export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: string }) { export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: string }) {
const { home, companion, summary, refreshHome, refreshSummary } = useAppData(); const { home, companion, summary, refreshHome, refreshSummary } = useAppData();
const look = useCompanion();
// زنجیره پیش از این تمرین؛ اگر بعد از ثبت امروز روشن شد، صفحه‌ی «روزِ پیاپی» نشان داده می‌شود. // زنجیره پیش از این تمرین؛ اگر بعد از ثبت امروز روشن شد، صفحه‌ی «روزِ پیاپی» نشان داده می‌شود.
const streakBefore = useRef(summary?.streak ?? null); const streakBefore = useRef(summary?.streak ?? null);
const [streakUp, setStreakUp] = useState<{ from: number; to: number } | null>(null); const [streakUp, setStreakUp] = useState<{ from: number; to: number } | null>(null);
@@ -137,8 +139,9 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
setHearts((h) => (h ? { ...h, currentHearts: result.heartsRemaining } : h)); setHearts((h) => (h ? { ...h, currentHearts: result.heartsRemaining } : h));
if (result.correct) { if (result.correct) {
setCorrectCount((c) => c + 1); setCorrectCount((c) => c + 1);
setCombo((c) => c + 1); setCombo(combo + 1);
sounds.correct(); if (look.char === 'delta' && combo + 1 >= 2) sounds.combo();
sounds.correct(look.pitch);
} else { } else {
setCombo(0); setCombo(0);
sounds.wrong(); sounds.wrong();
@@ -187,7 +190,13 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
: await api.lesson.complete(lessonId, payload, 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 import('canvas-confetti').then(({ default: confetti }) => confetti({ particleCount: 90, spread: 70, origin: { y: 0.35 } })); // کاغذرنگی به رنگِ همراه؛ امگا («جشن‌ساز») با هر نمره‌ای جشن می‌گیرد، بقیه از دو ستاره.
if (result.stars >= 2 || look.char === 'tedi') {
const colors = [look.color, look.light, '#FFD23F', '#ffffff'];
void import('canvas-confetti').then(({ default: confetti }) =>
confetti({ particleCount: look.char === 'tedi' ? 140 : 90, spread: look.char === 'tedi' ? 100 : 70, origin: { y: 0.35 }, colors }),
);
}
void refreshHome(); void refreshHome();
void refreshSummary(); void refreshSummary();
const before = streakBefore.current; const before = streakBefore.current;
@@ -295,6 +304,7 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
<div className="h-2.5 flex-1 overflow-hidden rounded-full bg-soft"> <div className="h-2.5 flex-1 overflow-hidden rounded-full bg-soft">
<div className="h-full rounded-full transition-all duration-500" style={{ width: `${((index + (answer ? 1 : 0)) / questions.length) * 100}%`, backgroundColor: color }} /> <div className="h-full rounded-full transition-all duration-500" style={{ width: `${((index + (answer ? 1 : 0)) / questions.length) * 100}%`, backgroundColor: color }} />
</div> </div>
<TraitChip char={look.char} color={look.color} correct={correctCount} combo={combo} />
<span className="flex-shrink-0 text-left text-xs font-bold text-muted"> <span className="flex-shrink-0 text-left text-xs font-bold text-muted">
{/* «۱/۴» در اپِ ریاضی کسر خوانده می‌شد. */} {/* «۱/۴» در اپِ ریاضی کسر خوانده می‌شد. */}
{toFa(index + 1)} از {toFa(questions.length)} {toFa(index + 1)} از {toFa(questions.length)}
@@ -359,12 +369,22 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
</div> </div>
{answer && !answer.correct && look.char === 'pi' && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
className="mx-4 mb-2 flex items-start gap-2 rounded-2xl bg-[#F5F1FF] px-3 py-2.5 text-xs font-bold leading-6 text-[#5B3FA0]"
>
<span className="text-base leading-5">π</span>
<span>{PI_TIPS[index % PI_TIPS.length]}</span>
</motion.div>
)}
<div className="flex flex-shrink-0 flex-col gap-2.5 border-t border-line bg-white px-4 pb-[max(env(safe-area-inset-bottom),1rem)] pt-3"> <div className="flex flex-shrink-0 flex-col gap-2.5 border-t border-line bg-white px-4 pb-[max(env(safe-area-inset-bottom),1rem)] pt-3">
{answer ? ( {answer ? (
<> <>
{!answer.correct && ( {!answer.correct && (
<button type="button" onClick={askWhy} className="flex items-center justify-center gap-2 rounded-2xl bg-[#F5F1FF] py-3 text-sm font-bold text-[#7857E0]"> <button type="button" onClick={askWhy} className="flex items-center justify-center gap-2 rounded-2xl bg-[#F5F1FF] py-3 text-sm font-bold text-[#7857E0]">
<Brain size={16} /> از پی بپرس چرا غلط شد <Brain size={16} /> از {look.name} بپرس چرا غلط شد
</button> </button>
)} )}
<PrimaryButton color={answer.correct ? '#5FBF8F' : color} onClick={() => void next()}> <PrimaryButton color={answer.correct ? '#5FBF8F' : color} onClick={() => void next()}>
@@ -516,6 +536,36 @@ function Result({
const PRAISE = ['آفرین! درست بود', 'دقیقاً همینه!', 'عالی! خوب فکر کردی', 'درسته! داری راه میفتی']; const PRAISE = ['آفرین! درست بود', 'دقیقاً همینه!', 'عالی! خوب فکر کردی', 'درسته! داری راه میفتی'];
const NUDGE = ['نزدیک بودی! جوابِ درست رو ببین.', 'اشکالی نداره؛ از همین اشتباه یاد می‌گیریم.', 'این یکی سخت بود؛ جوابِ درست سبز شده.']; const NUDGE = ['نزدیک بودی! جوابِ درست رو ببین.', 'اشکالی نداره؛ از همین اشتباه یاد می‌گیریم.', 'این یکی سخت بود؛ جوابِ درست سبز شده.'];
/** «راهنمای مهربون»ِ پی: نکته‌ی عمومیِ کوتاه بعد از جوابِ غلط؛ نه جوابِ سوال، فقط عادتِ خوبِ حل کردن. */
const PI_TIPS = [
'صورتِ سوال رو یه بار دیگه آروم بخون؛ کلمه‌ی «نیست» یا «بیشترین» زیاد جا می‌مونه.',
'علامتِ منفی‌ها رو یه بار دیگه چک کن.',
'اول داخلِ پرانتز، بعد ضرب و تقسیم، آخرش جمع و تفریق.',
'جوابت رو بذار توی سوال؛ اگه درست درنیومد یه جا اشتباه شده.',
];
/**
* ویژگیِ همراه کنارِ نوارِ پیشرفتِ آزمون: سیگما جمعِ جوابای درست (Σ)، دلتا شعله‌ی جوابای پشتِ هم. پی و امگا
* ویژگی‌شان جای دیگری است (نکته بعد از غلط، جشنِ پایان)، پس این‌جا چیزی نشان نمی‌دهند.
*/
function TraitChip({ char, color, correct, combo }: { char: string; color: string; correct: number; combo: number }) {
if (char === 'sigma') {
return (
<motion.span key={correct} initial={{ scale: 1.4 }} animate={{ scale: 1 }} className="flex-shrink-0 rounded-full px-2 py-0.5 text-xs font-black text-white" style={{ backgroundColor: color }}>
Σ {toFa(correct)}
</motion.span>
);
}
if (char === 'delta' && combo >= 2) {
return (
<motion.span key={combo} initial={{ scale: 0.4, rotate: -12 }} animate={{ scale: 1, rotate: 0 }} transition={{ type: 'spring', stiffness: 500, damping: 12 }} className="flex flex-shrink-0 items-center gap-0.5 rounded-full px-2 py-0.5 text-xs font-black text-white" style={{ backgroundColor: color }}>
<StreakFlame state="lit" size={11} className="[&_path]:fill-white" /> ×{toFa(combo)}
</motion.span>
);
}
return null;
}
const REASONS: { id: ReportReason; label: string }[] = [ const REASONS: { id: ReportReason; label: string }[] = [
{ id: 'wrong_answer', label: 'جوابِ درست اشتباهه' }, { id: 'wrong_answer', label: 'جوابِ درست اشتباهه' },
{ id: 'unclear', label: 'صورتِ سوال گنگه' }, { id: 'unclear', label: 'صورتِ سوال گنگه' },

View File

@@ -7,6 +7,7 @@ import { SeyarMascot } from '@/mascots/SeyarMascot';
import type { SeyarMood } from '@/mascots/seyar'; import type { SeyarMood } from '@/mascots/seyar';
import { ErrorState, LoadingState, Screen, ScreenBody, ScreenHeader } from '@/components/ui'; import { ErrorState, LoadingState, Screen, ScreenBody, ScreenHeader } from '@/components/ui';
import { toFa } from '@/lib/format'; import { toFa } from '@/lib/format';
import { useCompanion } from '@/state/companion';
/* /*
* «گزارشِ والدین». تا ۱۴۰۵-۰۷ والد حسابِ جدا و کدِ دعوت داشت؛ به خواستِ سینیور * «گزارشِ والدین». تا ۱۴۰۵-۰۷ والد حسابِ جدا و کدِ دعوت داشت؛ به خواستِ سینیور
@@ -46,7 +47,7 @@ export function ReportScreen() {
subtitle={`پیشرفتِ ${profile.name || 'دانش‌آموز'} در ۷ روزِ اخیر`} subtitle={`پیشرفتِ ${profile.name || 'دانش‌آموز'} در ۷ روزِ اخیر`}
color={THIS_WEEK} color={THIS_WEEK}
back back
mascot={{ char: 'sigma', mood: 'happy', size: 56 }} mascot={{ mood: 'happy', size: 56 }}
/> />
<ScreenBody className="flex flex-col gap-4"> <ScreenBody className="flex flex-col gap-4">
{report.loading ? ( {report.loading ? (
@@ -143,9 +144,10 @@ function verdict(now: ReportTotals, before: ReportTotals, name: string): { mood:
function Verdict({ now, before, name }: { now: ReportTotals; before: ReportTotals; name: string }) { function Verdict({ now, before, name }: { now: ReportTotals; before: ReportTotals; name: string }) {
const { mood, text } = verdict(now, before, name); const { mood, text } = verdict(now, before, name);
const companion = useCompanion().char;
return ( return (
<div className="flex items-center gap-3 rounded-3xl border border-line bg-white p-4"> <div className="flex items-center gap-3 rounded-3xl border border-line bg-white p-4">
<SeyarMascot char="sigma" mood={mood} size={52} /> <SeyarMascot char={companion} mood={mood} size={52} />
<p className="flex-1 text-sm font-bold leading-7 text-ink">{text}</p> <p className="flex-1 text-sm font-bold leading-7 text-ink">{text}</p>
</div> </div>
); );

View File

@@ -40,10 +40,10 @@ export function StreakScreen() {
return ( return (
<Screen> <Screen>
<ScreenHeader title="زنجیره‌ی روزها" subtitle="هر روز حداقل یه درس، زنجیره‌ت رو زنده نگه می‌داره" color="#FF7A3D" back mascot={{ char: 'delta', mood: 'excited', size: 56 }} /> <ScreenHeader title="زنجیره‌ی روزها" subtitle="هر روز حداقل یه درس، زنجیره‌ت رو زنده نگه می‌داره" color="#FF7A3D" back mascot={{ mood: 'excited', size: 56 }} />
<ScreenBody className="flex flex-col gap-4"> <ScreenBody className="flex flex-col gap-4">
{calendar.loading ? ( {calendar.loading ? (
<LoadingState char="delta" /> <LoadingState />
) : calendar.error || !calendar.data ? ( ) : calendar.error || !calendar.data ? (
<ErrorState error={calendar.error} onRetry={calendar.reload} /> <ErrorState error={calendar.error} onRetry={calendar.reload} />
) : ( ) : (

View File

@@ -10,6 +10,7 @@ import { useBackStep } from '@/nav/Navigator';
import { EmptyState, ErrorState, InlineError, LoadingState, PrimaryButton, Screen, ScreenHeader } from '@/components/ui'; import { EmptyState, ErrorState, InlineError, LoadingState, PrimaryButton, Screen, ScreenHeader } from '@/components/ui';
import { friendlyError } from '@/lib/errors'; import { friendlyError } from '@/lib/errors';
import { formatJalaliDate, formatNumber, formatToman, toFa } from '@/lib/format'; import { formatJalaliDate, formatNumber, formatToman, toFa } from '@/lib/format';
import { useCompanion } from '@/state/companion';
function durationLabel(days: number): string { function durationLabel(days: number): string {
if (days === 30 || days === 31) return 'یک‌ماهه'; if (days === 30 || days === 31) return 'یک‌ماهه';
@@ -42,6 +43,7 @@ export function SubscriptionScreen({ initialView }: { initialView?: View }) {
const [picked, setPicked] = useState<string | null>(null); const [picked, setPicked] = useState<string | null>(null);
const [buying, setBuying] = useState<Plan | null>(null); const [buying, setBuying] = useState<Plan | null>(null);
const bazaar = inBazaarApp(); const bazaar = inBazaarApp();
const look = useCompanion();
// از فهرست، «برگشت» اول به دو گزینه برمی‌گردد. // از فهرست، «برگشت» اول به دو گزینه برمی‌گردد.
useBackStep(view !== null && !initialView, () => setView(null)); useBackStep(view !== null && !initialView, () => setView(null));
@@ -64,11 +66,11 @@ export function SubscriptionScreen({ initialView }: { initialView?: View }) {
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
<ScreenHeader <ScreenHeader
title={view === 'pack' ? 'بسته‌ی سوال' : view === 'subscription' ? (active ? 'تمدیدِ اشتراک' : 'اشتراکِ تدریسینو') : 'فروشگاه'} title={view === 'pack' ? 'بسته‌ی سوال' : view === 'subscription' ? (active ? 'تمدیدِ اشتراک' : 'اشتراکِ تدریسینو') : 'فروشگاه'}
subtitle={view === 'pack' ? 'سوالِ بیشتر از پی، بدونِ تاریخِ انقضا' : 'همه‌ی فصل‌ها، قلبِ نامحدود'} subtitle={view === 'pack' ? `سوالِ بیشتر از ${look.name}، بدونِ تاریخِ انقضا` : 'همه‌ی فصل‌ها، قلبِ نامحدود'}
color="#ED6956" color="#ED6956"
exact exact
back={view !== null && !initialView ? () => setView(null) : true} back={view !== null && !initialView ? () => setView(null) : true}
mascot={{ char: 'pi', mood: 'excited', size: 60 }} mascot={{ mood: 'excited', size: 60 }}
/> />
<div className="flex flex-col gap-4 px-4 py-5"> <div className="flex flex-col gap-4 px-4 py-5">
@@ -88,7 +90,7 @@ export function SubscriptionScreen({ initialView }: { initialView?: View }) {
icon={<Sparkles size={26} />} icon={<Sparkles size={26} />}
tone="#9B7BFF" tone="#9B7BFF"
title="بسته‌ی سوال" title="بسته‌ی سوال"
text="سوالِ بیشتر از پی، هیچ‌وقت تموم نمی‌شه" text={`سوالِ بیشتر از ${look.name}، هیچ‌وقت تموم نمی‌شه`}
onClick={() => setView('pack')} onClick={() => setView('pack')}
/> />
</div> </div>

View File

@@ -58,7 +58,7 @@ export function SupportScreen() {
return ( return (
<Screen> <Screen>
<ScreenHeader title="پشتیبانی" subtitle="سوال، مشکل یا پیشنهاد داری؟ بنویس." color="#4C5FD5" back mascot={{ char: 'sigma', mood: 'happy', size: 56 }} /> <ScreenHeader title="پشتیبانی" subtitle="سوال، مشکل یا پیشنهاد داری؟ بنویس." color="#4C5FD5" back mascot={{ mood: 'happy', size: 56 }} />
<ScreenBody className="flex flex-col gap-4"> <ScreenBody className="flex flex-col gap-4">
<div className="flex gap-1 rounded-2xl bg-soft p-1"> <div className="flex gap-1 rounded-2xl bg-soft p-1">
{([['ticket', 'پیام به پشتیبانی', MessageCircle], ['bug', 'گزارشِ خطای اپ', Bug]] as const).map(([id, label, Icon]) => ( {([['ticket', 'پیام به پشتیبانی', MessageCircle], ['bug', 'گزارشِ خطای اپ', Bug]] as const).map(([id, label, Icon]) => (

View File

@@ -1,6 +1,7 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react'; import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
import { api, type Character, type Grade, type HomeResponse, type UserProfile, type UserSummary } from '@/api'; import { api, type Character, type Grade, type HomeResponse, type UserProfile, type UserSummary } from '@/api';
import { seyarFromSlug, type SeyarChar } from '@/mascots/seyar'; import { seyarFromSlug, type SeyarChar } from '@/mascots/seyar';
import { companionStore, useCompanion } from '@/state/companion';
/** /**
* داده‌هایی که چند صفحه با هم لازم دارند: پروفایل، آمار، نقشه، همراهِ انتخابی * داده‌هایی که چند صفحه با هم لازم دارند: پروفایل، آمار، نقشه، همراهِ انتخابی
@@ -48,7 +49,9 @@ export function AppDataProvider({
const [summary, setSummary] = useState<UserSummary | null>(prefetch?.summary ?? null); const [summary, setSummary] = useState<UserSummary | null>(prefetch?.summary ?? null);
const [home, setHome] = useState<HomeResponse | null>(prefetch?.home ?? null); const [home, setHome] = useState<HomeResponse | null>(prefetch?.home ?? null);
const [homeError, setHomeError] = useState<unknown>(null); const [homeError, setHomeError] = useState<unknown>(null);
const [companion, setCompanionChar] = useState<SeyarChar>('sigma'); // همراه در companionStore است تا بیرون از AppData (اسپلش، نشان، لودرها) هم خوانده شود.
const companion = useCompanion().char;
const setCompanionChar = companionStore.set;
const [unreadCount, setUnreadCount] = useState(0); const [unreadCount, setUnreadCount] = useState(0);
const refreshHome = useCallback(async () => { const refreshHome = useCallback(async () => {
@@ -84,10 +87,10 @@ export function AppDataProvider({
api.characters api.characters
.list() .list()
.then((list) => { .then((list) => {
const selected = seyarFromSlug(list.find((c) => c.selected)?.slug); // بی‌انتخاب یعنی پیش‌فرضِ سرور (سیگما)؛ همراهِ کاربرِ قبلی روی این دستگاه نباید بماند.
if (selected) setCompanionChar(selected); setCompanionChar(seyarFromSlug(list.find((c) => c.selected)?.slug) ?? 'sigma');
}) })
.catch(() => {}); .catch((err: unknown) => console.warn('companion lookup failed; keeping the stored one', err));
// فقط بارِ اول؛ prefetch بعد از mount عوض نمی‌شود. // فقط بارِ اول؛ prefetch بعد از mount عوض نمی‌شود.
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [refreshHome, refreshSummary, refreshUnread]); }, [refreshHome, refreshSummary, refreshUnread]);

70
src/state/companion.ts Normal file
View File

@@ -0,0 +1,70 @@
import { useSyncExternalStore } from 'react';
import { KEYS, store } from '@/lib/storage';
import { SEYAR_INFO, seyarFromSlug, type SeyarChar } from '@/mascots/seyar';
/**
* همراهِ انتخابیِ کاربر، یک جا برای کلِ اپ: نشانِ سرِ خانه، ماسکوتِ سربرگ‌ها و لودرها، نام و چهره‌ی دستیار، و ویژگیِ
* کوچکِ هر همراه. پیش‌تر هر صفحه همراهِ خودش را ثابت نوشته بود (فروشگاه و دستیار «پی»، درس‌ها «سیگما»، …) و انتخابِ
* کاربر فقط در خانه و درس دیده می‌شد.
*
* در localStorage هم می‌ماند تا اسپلش و صفحه‌ی ورود پیش از رسیدنِ پاسخِ سرور همان همراه را نشان دهند؛ منبعِ حقیقت
* همچنان سرور است (AppData بعد از ورود از /characters می‌خواند و این را به‌روز می‌کند).
*/
export interface CompanionLook {
char: SeyarChar;
name: string;
symbol: string;
/** رنگِ اصلی (بدنه‌ی ماسکوت) و رنگِ روشن‌ترِ کنارش، برای نشان و ته‌رنگ. */
color: string;
light: string;
/** ویژگیِ این همراه؛ در صفحه‌ی همراه‌ها نوشته شده و در آزمون دیده می‌شود (QuizScreen). */
trait: { title: string; text: string };
/** گامِ صدای «درست» نسبت به سیگما (نیم‌پرده)؛ هر همراه صدای خودش را دارد. */
pitch: number;
}
export const COMPANIONS: Record<SeyarChar, CompanionLook> = {
sigma: {
char: 'sigma', name: SEYAR_INFO.sigma.name, symbol: 'Σ', color: '#4C5FD5', light: '#7A8CFF', pitch: 0,
trait: { title: 'جمع‌زن', text: 'تو آزمون جوابای درستت رو یکی‌یکی برات جمع می‌زنه.' },
},
delta: {
char: 'delta', name: SEYAR_INFO.delta.name, symbol: 'Δ', color: '#FF7A3D', light: '#FFB25E', pitch: 2,
trait: { title: 'شعله‌ی سرعت', text: 'جوابای درستِ پشتِ هم رو آتیشی می‌کنه و تشویقت می‌کنه.' },
},
pi: {
char: 'pi', name: SEYAR_INFO.pi.name, symbol: 'π', color: '#9B7BFF', light: '#C6B3FF', pitch: 4,
trait: { title: 'راهنمای مهربون', text: 'وقتی غلط می‌زنی یه نکته‌ی کوچیک می‌گه که دفعه‌ی بعد گیر نکنی.' },
},
tedi: {
char: 'tedi', name: SEYAR_INFO.tedi.name, symbol: 'ω', color: '#E8A400', light: '#FFC63D', pitch: 7,
trait: { title: 'جشن‌ساز', text: 'آخرِ هر جزیره، هر نمره‌ای بگیری، برات جشنِ طلایی می‌گیره.' },
},
};
let current: SeyarChar = seyarFromSlug(store.get(KEYS.companion)) ?? 'sigma';
const listeners = new Set<() => void>();
export const companionStore = {
get: () => current,
set(char: SeyarChar) {
if (char === current) return;
current = char;
store.set(KEYS.companion, char);
listeners.forEach((l) => l());
},
subscribe(listener: () => void) {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
export function useCompanion(): CompanionLook {
const char = useSyncExternalStore(companionStore.subscribe, companionStore.get, companionStore.get);
return COMPANIONS[char];
}
/** «من سیگمام»، «من پی‌ام». */
export function iAm(name: string): string {
return name.endsWith('ی') ? `${name}‌ام` : `${name}م`;
}