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:
@@ -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» | هیچکدام تعریف نشده |
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
همه خوانده شد
|
همه خوانده شد
|
||||||
|
|||||||
@@ -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: 'صورتِ سوال گنگه' },
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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} />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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]) => (
|
||||||
|
|||||||
@@ -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
70
src/state/companion.ts
Normal 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}م`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user