Calmer shop with payment choice, step-by-step phone back, sound/haptics that reach the phone, live streak flame

- Shop: subscription perks shown once (each card repeated them, «۳ سوال در روز» twice), plans as selectable
  rows with one big buy button, prices as «۷۹۸ هزار تومان», a single clear status card for subscribers.
  Checkout inside the Bazaar app offers «کارتِ بانکی» (default) or «کافه‌بازار» for packs with a SKU;
  «به‌زودی در بازار» is gone. The gateway opens in the phone browser; status refreshes on return.
- Back button: every back-able level (open sheet, pushed screen, non-home tab, shop list) owns exactly one
  history entry created right after the tap; back closes the sheet, then the screen, then returns to Home,
  then leaves the app. (A re-pushed trap is skipped by Chrome's history intervention.)
- Sound: AudioContext unlocked on the first touch (it was created after a network wait, which WebView/Safari
  keep muted); softer notes, option tick, finish fanfare, streak whoosh. Haptics through the new
  TadrisinoAndroid.vibrate bridge when the APK has it, navigator.vibrate otherwise.
- Streak: animated flame with lit / at-risk (grey) / frozen (ice) states on Home and the streak screen,
  a companion reminder when the streak is at risk or frozen, and a full-screen «روزِ پیاپی» celebration
  after the first lesson of the day (needs backend 9024c6b: streak.activeToday / lost).
- Lessons: intro/example text with inline «۱. … ۲. …» renders as a numbered list (2,250 of 4,469 texts).
- Quiz: duplicate React key on the answer bubble fixed.
- android/MainActivity.kt: vibrate bridge and mediaPlaybackRequiresUserGesture=false. Not built; needs the
  next APK from the signing owner.

Checked in headless Chrome against the local backend (shop as a subscriber with the Bazaar bridge stubbed,
back sequences, quiz → streak celebration, frozen state). Not checked on a real phone or in the APK.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-10 18:27:24 +03:30
parent eafee76a93
commit 468f5b2d29
15 changed files with 715 additions and 217 deletions

View File

@@ -3,7 +3,11 @@ package com.tadrisino.app
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
import android.util.Log import android.util.Log
import android.webkit.JavascriptInterface import android.webkit.JavascriptInterface
import android.webkit.WebResourceRequest import android.webkit.WebResourceRequest
@@ -16,6 +20,7 @@ import ir.cafebazaar.poolakey.Payment
import ir.cafebazaar.poolakey.config.PaymentConfiguration import ir.cafebazaar.poolakey.config.PaymentConfiguration
import ir.cafebazaar.poolakey.config.SecurityCheck import ir.cafebazaar.poolakey.config.SecurityCheck
import ir.cafebazaar.poolakey.request.PurchaseRequest import ir.cafebazaar.poolakey.request.PurchaseRequest
import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
/** /**
@@ -54,6 +59,8 @@ class MainActivity : AppCompatActivity() {
// WebView فقط آدرسِ وب را می‌خواند؛ دسترسی به فایل و content:// لازم نیست. // WebView فقط آدرسِ وب را می‌خواند؛ دسترسی به فایل و content:// لازم نیست.
webView.settings.allowFileAccess = false webView.settings.allowFileAccess = false
webView.settings.allowContentAccess = false webView.settings.allowContentAccess = false
// صداهای کوتاهِ رابط (Web Audio) بعد از پاسخِ سرور پخش می‌شوند، نه درست در لحظه‌ی لمس.
webView.settings.mediaPlaybackRequiresUserGesture = false
webView.webViewClient = object : WebViewClient() { webView.webViewClient = object : WebViewClient() {
// هر آدرسی بیرونِ دامنه‌ی خودمان در مرورگرِ گوشی باز می‌شود، نه داخلِ اپ؛ پس پل فقط // هر آدرسی بیرونِ دامنه‌ی خودمان در مرورگرِ گوشی باز می‌شود، نه داخلِ اپ؛ پس پل فقط
// در صفحه‌های خودمان در دسترس است. // در صفحه‌های خودمان در دسترس است.
@@ -160,6 +167,32 @@ class MainActivity : AppCompatActivity() {
} }
} }
/**
* لرزشِ کوتاهِ رابط (src/lib/sound.tsِ فرانت). `navigator.vibrate` در همه‌ی WebViewها اجرا نمی‌شود؛
* این مستقیم از Vibratorِ گوشی است. ورودی آرایه‌ی JSON از میلی‌ثانیه‌ها: [لرزش، مکث، لرزش، …].
*/
@JavascriptInterface
fun vibrate(patternJson: String) {
val pattern = try {
val arr = JSONArray(patternJson)
LongArray(arr.length()) { i -> arr.getLong(i).coerceIn(0, 1000) }
} catch (e: Exception) {
Log.w(TAG, "bad vibrate pattern", e)
return
}
if (pattern.isEmpty()) return
val vibrator = if (Build.VERSION.SDK_INT >= 31) {
(getSystemService(VIBRATOR_MANAGER_SERVICE) as VibratorManager).defaultVibrator
} else {
@Suppress("DEPRECATION")
getSystemService(VIBRATOR_SERVICE) as Vibrator
}
// [لرزش، مکث، …] → قالبِ اندروید که با مکث شروع می‌شود.
val timings = longArrayOf(0) + pattern
if (Build.VERSION.SDK_INT >= 26) vibrator.vibrate(VibrationEffect.createWaveform(timings, -1))
else @Suppress("DEPRECATION") vibrator.vibrate(timings, -1)
}
@JavascriptInterface @JavascriptInterface
fun bazaarConsume(purchaseToken: String) { fun bazaarConsume(purchaseToken: String) {
runOnUiThread { runOnUiThread {

View File

@@ -46,7 +46,10 @@ export interface UserSummary {
gradeId: number | null; gradeId: number | null;
xp: number; xp: number;
coins: number; coins: number;
streak: { current: number; longest: number }; /**
* activeToday: امروز تمرین شده (آتش روشن). lost: طولِ زنجیره‌ای که دیروز شکست (> ۰ یعنی «یخ‌زده»).
*/
streak: { current: number; longest: number; activeToday: boolean; lost: number };
subscription: string | null; subscription: string | null;
} }

View File

@@ -1,3 +1,4 @@
import { sounds } from '@/lib/sound';
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';
@@ -18,7 +19,10 @@ export function BottomNav({ active, onChange }: { active: TabName | null; onChan
<button <button
key={id} key={id}
type="button" type="button"
onClick={() => onChange(id)} onClick={() => {
sounds.tap();
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-coral' : 'text-muted'}`}
> >

View File

@@ -1,6 +1,7 @@
import { useEffect, type ReactNode } from 'react'; import { useEffect, type ReactNode } from 'react';
import { AnimatePresence, motion, useDragControls } from 'motion/react'; import { AnimatePresence, motion, useDragControls } from 'motion/react';
import { X } from 'lucide-react'; import { X } from 'lucide-react';
import { useBackStep } from '@/nav/Navigator';
/** /**
* برگه‌ای که از پایین بالا می‌آید؛ با زدنِ پس‌زمینه، Escape، یا کشیدنِ دستگیره/سرِ برگه به پایین بسته می‌شود. * برگه‌ای که از پایین بالا می‌آید؛ با زدنِ پس‌زمینه، Escape، یا کشیدنِ دستگیره/سرِ برگه به پایین بسته می‌شود.
@@ -18,6 +19,8 @@ export function Sheet({
children: ReactNode; children: ReactNode;
}) { }) {
const drag = useDragControls(); const drag = useDragControls();
// دکمه‌ی برگشتِ گوشی اول برگه را می‌بندد، نه صفحه‌ی زیرش را.
useBackStep(open, onClose);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose(); const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose();

View File

@@ -0,0 +1,127 @@
import { useEffect, useState } from 'react';
import { motion } from 'motion/react';
import { PrimaryButton } from '@/components/ui';
import { toFa } from '@/lib/format';
import { sounds } from '@/lib/sound';
/**
* سه حالتِ زنجیره، مثلِ دولینگو:
* lit امروز تمرین شده؛ آتشِ نارنجیِ زنده (لرزشِ آرامِ شعله‌ها).
* risk زنجیره هست ولی امروز هنوز نه؛ همان شعله خاکستری، تا کاربر ببیند چیزی منتظرش است.
* frozen زنجیره دیروز شکست؛ شعله‌ی یخ‌زده‌ی آبی، با برق.
* none هیچ‌وقت زنجیره نداشته؛ مثلِ risk.
* فقط SVG و CSS (transform/opacity)، بی‌تصویر.
*/
export type FlameState = 'lit' | 'risk' | 'frozen' | 'none';
export function flameState(streak: { current: number; activeToday?: boolean; lost?: number } | undefined | null): FlameState {
if (!streak) return 'none';
if (streak.activeToday) return 'lit';
if (streak.current > 0) return 'risk';
if ((streak.lost ?? 0) > 0) return 'frozen';
return 'none';
}
const PALETTE: Record<FlameState, [string, string, string]> = {
lit: ['#FF7A3D', '#FFB23F', '#FFE58A'],
risk: ['#C9C6DA', '#DCDAE8', '#EEEDF5'],
none: ['#C9C6DA', '#DCDAE8', '#EEEDF5'],
frozen: ['#5BB8F0', '#9ED8FA', '#E6F6FF'],
};
export function StreakFlame({ state, size = 20, className = '' }: { state: FlameState; size?: number; className?: string }) {
const [outer, middle, core] = PALETTE[state];
const alive = state === 'lit';
return (
<svg viewBox="0 0 64 80" width={size} height={size * 1.25} className={className} aria-hidden>
<g className={alive ? 'flame-outer' : undefined}>
<path d="M32 2C36 18 54 26 54 50c0 15-10 28-22 28S10 65 10 50c0-11 6-18 10-22 1 7 4 11 8 12-2-14 0-28 4-38z" fill={outer} />
</g>
<g className={alive ? 'flame-inner' : undefined}>
<path d="M33 30c3 9 13 13 13 26 0 9-6 16-14 16s-14-7-14-16c0-6 3-10 6-12 0 4 2 7 5 7-1-8 1-15 4-21z" fill={middle} />
</g>
<path d="M32 52c2 5 7 7 7 13 0 4-3 7-7 7s-7-3-7-7c0-3 2-5 3-6 0 2 1 3 2 3 0-4 1-7 2-10z" fill={core} />
{state === 'frozen' && (
<>
{/* برقِ یخ و ترک‌های ریز */}
<path d="M22 40l6 4M40 34l-5 6M30 60l4-5" stroke="#fff" strokeWidth="2.2" strokeLinecap="round" opacity=".85" />
<circle cx="44" cy="22" r="2.4" fill="#fff" className="ice-glint" />
</>
)}
</svg>
);
}
const WEEKDAYS = ['ش', 'ی', 'د', 'س', 'چ', 'پ', 'ج'];
/**
* صفحه‌ی «زنجیره‌ت روشن ماند!» بعد از اولین درس/جزیره‌ی امروز. عدد از مقدارِ قبلی به تازه می‌رود و روزهای این
* هفته که در زنجیره‌اند روشن می‌شوند (از طولِ زنجیره حساب می‌شود، بی‌درخواستِ دیگر).
*/
export function StreakCelebration({ from, to, onDone }: { from: number; to: number; onDone: () => void }) {
const [shown, setShown] = useState(from);
useEffect(() => {
sounds.streak();
const t = setTimeout(() => setShown(to), 650);
return () => clearTimeout(t);
}, [to]);
const today = (new Date().getDay() + 1) % 7; // شنبه = ۰
const message = to === 1
? 'اولین روزِ زنجیره‌ت! فردا هم بیا تا آتیشش بزرگ‌تر بشه.'
: `${toFa(to)} روزه که هر روز ریاضی تمرین می‌کنی. فردا هم بیا تا خاموش نشه!`;
return (
<div className="relative flex h-full flex-col items-center justify-center gap-6 overflow-hidden bg-gradient-to-b from-[#FFF4EC] to-white px-8 text-center">
<div aria-hidden className="pointer-events-none absolute inset-x-0 bottom-1/3 h-48">
{[14, 30, 48, 66, 82].map((x, i) => (
<span key={x} className="spark-rise absolute bottom-0 h-2 w-2 rounded-full bg-[#FFB23F]" style={{ left: `${x}%`, animationDelay: `${i * 0.35}s` }} />
))}
</div>
<motion.div
initial={{ scale: 0.3, y: 40, opacity: 0 }}
animate={{ scale: 1, y: 0, opacity: 1 }}
transition={{ type: 'spring', stiffness: 180, damping: 12 }}
className="relative"
>
<div className="absolute inset-0 -z-10 scale-150 rounded-full bg-[#FFB23F]/30 blur-2xl" />
<StreakFlame state="lit" size={130} />
</motion.div>
<div>
<motion.div
key={shown}
initial={{ scale: 1.6, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: 'spring', stiffness: 300, damping: 14 }}
className="text-6xl font-black text-orange"
>
{toFa(shown)}
</motion.div>
<h2 className="mt-1 text-2xl font-black text-ink">روزِ پیاپی!</h2>
<p className="mt-2 text-sm leading-7 text-body">{message}</p>
</div>
<div className="flex gap-2" aria-label="روزهای این هفته">
{WEEKDAYS.map((d, i) => {
const inStreak = i <= today && today - i < to;
return (
<motion.div
key={d}
initial={{ scale: 0.6, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.5 + i * 0.06 }}
className="flex flex-col items-center gap-1"
>
<span className={`flex h-9 w-9 items-center justify-center rounded-full ${inStreak ? 'bg-orange' : 'bg-soft'} ${i === today ? 'ring-2 ring-orange ring-offset-2' : ''}`}>
{inStreak ? <StreakFlame state="lit" size={16} className="[&_path]:fill-white" /> : null}
</span>
<span className={`text-xs font-bold ${i === today ? 'text-orange' : 'text-muted'}`}>{d}</span>
</motion.div>
);
})}
</div>
<div className="w-full">
<PrimaryButton color="#FF7A3D" onClick={onDone}>ادامه</PrimaryButton>
</div>
</div>
);
}

47
src/lib/StepText.tsx Normal file
View File

@@ -0,0 +1,47 @@
import { MathText } from './MathText';
import { toFa } from './format';
/**
* متنِ درس که قدم‌هایش در یک پاراگراف پشتِ هم آمده‌اند («۱. مجموعه را بنویسید. ۲. بررسی کنید… ۳. …»):
* ۱۰۴۱ مقدمه‌ی درس در دیتابیس همین شکل‌اند (۱۴۰۵-۰۷) و روی صفحه یک تکه‌ی درهم دیده می‌شدند. این‌جا به
* فهرستِ شماره‌دار تبدیل می‌شوند؛ متنِ بی‌فهرست همان پاراگرافِ قبلی می‌ماند.
*
* فقط دنباله‌ی ۱، ۲، ۳، … با «.» یا «)» و فاصله‌ی بعدش شمرده می‌شود، پس «۳.۵» (اعشار) و «شکلِ ۲. سپس» که
* پیش از آن ۱ ندارد فهرست نمی‌شوند.
*/
const MARKER = /(^|\s)([0-9۰-۹]{1,2})[.)](?=\s)/g;
const num = (d: string) => Number(d.replace(/[۰-۹]/g, (c) => String(c.charCodeAt(0) - 0x06f0)));
export function parseNumbered(text: string): { lead: string; items: string[] } | null {
const marks: { start: number; end: number }[] = [];
for (const m of text.matchAll(MARKER)) {
if (num(m[2]) !== marks.length + 1) continue;
const start = (m.index ?? 0) + m[1].length;
marks.push({ start, end: start + m[2].length + 1 });
}
if (marks.length < 2) return null;
return {
lead: text.slice(0, marks[0].start).trim(),
items: marks.map((mk, i) => text.slice(mk.end, marks[i + 1]?.start ?? text.length).trim()),
};
}
export function StepText({ text, color, className = '' }: { text: string; color: string; className?: string }) {
const list = parseNumbered(text);
if (!list) return <p className={`whitespace-pre-line ${className}`}><MathText text={text} /></p>;
return (
<div className={className}>
{list.lead && <p className="mb-2 whitespace-pre-line"><MathText text={list.lead} /></p>}
<ol className="flex flex-col gap-2.5">
{list.items.map((item, i) => (
<li key={i} className="flex gap-2.5">
<span className="mt-0.5 flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full text-xs font-black text-white" style={{ backgroundColor: color }}>
{toFa(i + 1)}
</span>
<span className="flex-1 whitespace-pre-line"><MathText text={item} /></span>
</li>
))}
</ol>
</div>
);
}

View File

@@ -1,8 +1,14 @@
import { KEYS, store } from './storage'; import { KEYS, store } from './storage';
/** /**
* صداهای کوتاهِ رابط. یک AudioContext برای کلِ اپ؛ ساختنِ یکی برای هر صدا * صدا و لرزشِ کوتاهِ رابط، به‌جا و کم: انتخابِ گزینه، جوابِ درست/غلط، پایانِ درس/جزیره، روشن‌ماندنِ زنجیره.
* در Safari بعد از چند بار شکست می‌خورد. * همه با کلیدِ «صداها» در پروفایل خاموش می‌شوند.
*
* چرا روی اپِ نصب‌شده صدا نبود (از روی کد؛ روی گوشی دیده نشد): AudioContext اولین بار بعد از پاسخِ سرور
* ساخته می‌شد، یعنی بیرون از لمسِ کاربر، و WebViewِ اندروید و Safari چنین صدایی را «پخشِ خودکار» می‌دانند و
* بی‌صدا نگهش می‌دارند. حالا با اولین لمسِ صفحه ساخته و باز می‌شود (unlockAudioOnFirstTouch).
*
* صداها با Web Audio ساخته می‌شوند، بی‌فایل: بارِ اپ اضافه نمی‌شود.
*/ */
let ctx: AudioContext | null = null; let ctx: AudioContext | null = null;
@@ -12,12 +18,33 @@ function context(): AudioContext | null {
if (!Ctor) return null; if (!Ctor) return null;
try { try {
ctx = new Ctor(); ctx = new Ctor();
} catch { } catch (err) {
console.warn('AudioContext unavailable', err);
return null; return null;
} }
return ctx; return ctx;
} }
/** با اولین لمسِ صفحه صدا را باز می‌کند؛ یک بار در main صدا زده می‌شود. */
export function unlockAudioOnFirstTouch() {
const unlock = () => {
const audio = context();
if (!audio) return;
if (audio.state === 'suspended') void audio.resume();
// یک بافرِ ساکت؛ iOS بدونِ پخشِ واقعی در همان لمس، context را باز نمی‌کند.
const src = audio.createBufferSource();
src.buffer = audio.createBuffer(1, 1, 22050);
src.connect(audio.destination);
src.start(0);
if (audio.state === 'running') {
window.removeEventListener('pointerdown', unlock, true);
window.removeEventListener('touchend', unlock, true);
}
};
window.addEventListener('pointerdown', unlock, true);
window.addEventListener('touchend', unlock, true);
}
export function soundEnabled(): boolean { export function soundEnabled(): boolean {
return store.get(KEYS.sound) !== 'off'; return store.get(KEYS.sound) !== 'off';
} }
@@ -26,46 +53,106 @@ export function setSoundEnabled(on: boolean) {
store.set(KEYS.sound, on ? 'on' : 'off'); store.set(KEYS.sound, on ? 'on' : 'off');
} }
function tone(frequency: number, start: number, duration: number, volume = 0.05) { /** یک نتِ نرم: حمله‌ی کوتاه و افتِ نمایی، با کمی هارمونیک تا شبیهِ بوقِ سینوسیِ خالی نباشد. */
function note(frequency: number, start: number, duration: number, volume = 0.05, type: OscillatorType = 'sine') {
const audio = context(); const audio = context();
if (!audio || !soundEnabled()) return; if (!audio || !soundEnabled()) return;
if (audio.state === 'suspended') void audio.resume(); if (audio.state === 'suspended') void audio.resume();
const t = audio.currentTime + start; const t = audio.currentTime + start;
const osc = audio.createOscillator();
const gain = audio.createGain(); const gain = audio.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(frequency, t);
gain.gain.setValueAtTime(0.0001, t); gain.gain.setValueAtTime(0.0001, t);
gain.gain.exponentialRampToValueAtTime(volume, t + 0.01); gain.gain.exponentialRampToValueAtTime(volume, t + 0.012);
gain.gain.exponentialRampToValueAtTime(0.0001, t + duration); gain.gain.exponentialRampToValueAtTime(0.0001, t + duration);
osc.connect(gain).connect(audio.destination); gain.connect(audio.destination);
for (const [mult, level] of [[1, 1], [2, 0.18], [3, 0.06]] as const) {
const osc = audio.createOscillator();
const g = audio.createGain();
osc.type = type;
osc.frequency.setValueAtTime(frequency * mult, t);
g.gain.value = level;
osc.connect(g).connect(gain);
osc.start(t); osc.start(t);
osc.stop(t + duration + 0.02); osc.stop(t + duration + 0.03);
}
}
/** «فوش»ِ آتش برای زنجیره: نویز از یک فیلترِ بالارونده. */
function whoosh(start: number, duration: number, volume = 0.05) {
const audio = context();
if (!audio || !soundEnabled()) return;
if (audio.state === 'suspended') void audio.resume();
const t = audio.currentTime + start;
const length = Math.floor(audio.sampleRate * duration);
const buffer = audio.createBuffer(1, length, audio.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < length; i++) data[i] = Math.random() * 2 - 1;
const src = audio.createBufferSource();
src.buffer = buffer;
const filter = audio.createBiquadFilter();
filter.type = 'bandpass';
filter.Q.value = 0.8;
filter.frequency.setValueAtTime(300, t);
filter.frequency.exponentialRampToValueAtTime(2400, t + duration);
const gain = audio.createGain();
gain.gain.setValueAtTime(0.0001, t);
gain.gain.exponentialRampToValueAtTime(volume, t + duration * 0.35);
gain.gain.exponentialRampToValueAtTime(0.0001, t + duration);
src.connect(filter).connect(gain).connect(audio.destination);
src.start(t);
}
interface AndroidHaptics {
vibrate?: (patternJson: string) => void;
} }
/** /**
* لرزشِ کوتاه کنارِ صدا، با همان کلیدِ «صدا» خاموش می‌شود. فقط اندروید/کروم دارد؛ * لرزش. اپِ اندروید (android/MainActivity.kt) از نسخه‌ی بعدی `TadrisinoAndroid.vibrate` دارد که مستقیم از
* Safariِ آیفون `vibrate` ندارد و این‌جا هیچ کاری نمی‌کند. * Vibratorِ گوشی می‌لرزاند؛ تا آن نسخه ساخته نشده `navigator.vibrate` امتحان می‌شود، که همه‌ی WebViewها
* اجرایش نمی‌کنند. Safariِ آیفون لرزش ندارد.
*/ */
function buzz(pattern: number | number[]) { function buzz(pattern: number | number[]) {
if (!soundEnabled() || typeof navigator.vibrate !== 'function') return; if (!soundEnabled()) return;
const bridge = (window as unknown as { TadrisinoAndroid?: AndroidHaptics }).TadrisinoAndroid;
try { try {
navigator.vibrate(pattern); if (bridge?.vibrate) bridge.vibrate(JSON.stringify(Array.isArray(pattern) ? pattern : [pattern]));
} catch { else if (typeof navigator.vibrate === 'function') navigator.vibrate(pattern);
/* بعضی WebViewها بی‌اجازه throw می‌کنند؛ لرزش فقط تزئینی است */ } catch (err) {
console.warn('vibrate failed', err);
} }
} }
// نت‌ها (هرتز)
const C5 = 523.25, E5 = 659.25, G5 = 783.99, C6 = 1046.5, E6 = 1318.5, G6 = 1568;
export const sounds = { export const sounds = {
tick: () => tone(720, 0, 0.045, 0.04), /** لمسِ گزینه‌ی آزمون: یک «تیک»ِ خیلی ریز. */
select: () => {
note(880, 0, 0.05, 0.025, 'triangle');
buzz(8);
},
correct: () => { correct: () => {
tone(660, 0, 0.12); note(E5, 0, 0.12, 0.05);
tone(990, 0.1, 0.18); note(C6, 0.09, 0.22, 0.05);
buzz(30); buzz(25);
}, },
wrong: () => { wrong: () => {
tone(220, 0, 0.25, 0.06); note(311, 0, 0.16, 0.045, 'triangle');
buzz([40, 60, 40]); note(233, 0.12, 0.24, 0.045, 'triangle');
buzz([35, 50, 35]);
}, },
finish: () => [523, 659, 784, 1047].forEach((f, i) => tone(f, i * 0.11, 0.2)), /** پایانِ درس/جزیره: آرپژِ بالارونده و یک آکوردِ کوتاه. */
finish: () => {
[C5, E5, G5, C6].forEach((f, i) => note(f, i * 0.09, 0.22, 0.045));
[C6, E6, G6].forEach((f) => note(f, 0.4, 0.5, 0.025));
buzz([30, 60, 30, 60, 90]);
},
/** زنجیره روشن ماند: «فوش»ِ آتش و دو نتِ بالارونده. */
streak: () => {
whoosh(0, 0.55, 0.06);
note(G5, 0.45, 0.18, 0.045);
note(C6, 0.58, 0.4, 0.05);
buzz([20, 40, 20, 40, 160]);
},
/** لرزشِ بسیار کوتاه برای تعویضِ تب؛ بی‌صدا. */
tap: () => buzz(6),
}; };

View File

@@ -2,6 +2,9 @@ import { StrictMode, lazy, Suspense } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { App } from './app/App'; import { App } from './app/App';
import './styles/index.css'; import './styles/index.css';
import { unlockAudioOnFirstTouch } from './lib/sound';
unlockAudioOnFirstTouch();
// گالریِ شکل‌ها فقط در حالتِ توسعه؛ در build اصلاً وارد نمی‌شود (import.meta.env.DEV ثابت است). // گالریِ شکل‌ها فقط در حالتِ توسعه؛ در build اصلاً وارد نمی‌شود (import.meta.env.DEV ثابت است).
const DiagramGallery = import.meta.env.DEV const DiagramGallery = import.meta.env.DEV

View File

@@ -2,11 +2,18 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, use
import type { Route, TabName } from './routes'; import type { Route, TabName } from './routes';
/** /**
* ناوبریِ پشته‌ای، هماهنگ با تاریخچه‌ی مرورگر. * ناوبریِ پشته‌ای، هماهنگ با تاریخچه‌ی مرورگر، تا دکمه‌ی «برگشت»ِ گوشی مثلِ اپ‌های بومی قدم‌به‌قدم عقب برود:
* برگه‌ی باز ← صفحه‌ی قبلی ← تبِ خانه ← بیرون از اپ.
* *
* هر push یک ورودی به history اضافه می‌کند، پس دکمه‌ی back گوشی یا مرورگر * هر «قدمِ برگشت‌پذیر» دقیقاً یک ورودیِ history دارد: هر صفحه‌ی push‌شده، تبی غیر از خانه، و هر برگه/حالتی که
* یک صفحه عقب می‌رود به‌جای بیرون انداختنِ کاربر از اپ. تعویضِ تب پشته را * با useBackStep ثبت شده. تعدادِ لازم (desired) از وضعیت حساب می‌شود و یک افکت ورودی‌ها را با آن یکی می‌کند.
* از نو می‌سازد (مثلِ اپ‌های بومی). * دکمه‌ی برگشت یک ورودی برمی‌دارد (popstate) و این‌جا یک قدم در اپ عقب می‌رویم؛ بستنِ برگه یا عوض کردنِ تب از
* داخلِ اپ ورودیِ اضافه را با history.go پاک می‌کند (و آن popstate نادیده گرفته می‌شود).
*
* چرا «تله»ی همیشگی (pushState دوباره داخلِ popstate) نه: کروم ورودی‌ای را که بی‌لمسِ کاربر ساخته شده در دکمه‌ی
* برگشت رد می‌کند، پس آن تله گاهی کاربر را یک‌راست بیرون می‌انداخت. این‌جا ورودی‌ها در افکتِ بلافاصله بعد از لمس
* ساخته می‌شوند (فعال‌سازیِ گذرای کاربر هنوز برقرار است). در کرومِ دسکتاپ و موبایلِ شبیه‌سازی‌شده آزموده شد؛
* روی WebViewِ اپِ اندروید و Safariِ آیفون با دستگاهِ واقعی نه.
*/ */
/** نوعِ آخرین جابه‌جایی؛ انیمیشنِ ورودِ صفحه از آن می‌آید (جلو از چپ، عقب از راست، تب فقط محو). */ /** نوعِ آخرین جابه‌جایی؛ انیمیشنِ ورودِ صفحه از آن می‌آید (جلو از چپ، عقب از راست، تب فقط محو). */
export type NavDirection = 'push' | 'back' | 'tab' | 'replace'; export type NavDirection = 'push' | 'back' | 'tab' | 'replace';
@@ -28,36 +35,92 @@ interface Nav {
const NavContext = createContext<Nav | null>(null); const NavContext = createContext<Nav | null>(null);
interface BackSteps {
register: (onBack: () => void) => () => void;
}
const BackContext = createContext<BackSteps | null>(null);
/**
* یک قدمِ برگشت‌پذیرِ داخلِ صفحه (برگه‌ی باز، فهرستی که از «انتخاب» باز شده): تا `active` است دکمه‌ی برگشتِ
* گوشی اول `onBack` را صدا می‌زند. بیرون از اپِ اصلی (صفحه‌ی ورود) کاری نمی‌کند.
*/
export function useBackStep(active: boolean, onBack: () => void) {
const steps = useContext(BackContext);
const ref = useRef(onBack);
ref.current = onBack;
useEffect(() => (active && steps ? steps.register(() => ref.current()) : undefined), [active, steps]);
}
export function NavigatorProvider({ initial, children }: { initial: Route; children: ReactNode }) { export function NavigatorProvider({ initial, children }: { initial: Route; children: ReactNode }) {
const [{ stack, direction }, setState] = useState<{ stack: Route[]; direction: NavDirection }>({ stack: [initial], direction: 'tab' }); const [{ stack, direction }, setState] = useState<{ stack: Route[]; direction: NavDirection }>({ stack: [initial], direction: 'tab' });
const [reselect, setReselect] = useState(0); const [reselect, setReselect] = useState(0);
const [steps, setSteps] = useState<(() => void)[]>([]);
const stackRef = useRef(stack); const stackRef = useRef(stack);
stackRef.current = stack; stackRef.current = stack;
const setStack = (update: (s: Route[]) => Route[], dir: NavDirection) => const stepsRef = useRef(steps);
setState((prev) => { stepsRef.current = steps;
const next = update(prev.stack);
return next === prev.stack ? prev : { stack: next, direction: dir };
});
useEffect(() => { /** ورودی‌هایی که خودمان بالای ورودیِ اولِ اپ گذاشته‌ایم. */
const onPop = () => setStack((s) => (s.length > 1 ? s.slice(0, -1) : s), 'back'); const entries = useRef(0);
window.addEventListener('popstate', onPop); /** popstateهایی که از history.goِ خودمان است، نه از دکمه‌ی کاربر. */
return () => window.removeEventListener('popstate', onPop); const ignorePops = useRef(0);
/** یک قدم عقب در خودِ اپ (بی history). */
const stepBack = useCallback(() => {
const open = stepsRef.current;
if (open.length) {
open[open.length - 1]();
return;
}
const s = stackRef.current;
if (s.length > 1) setState({ stack: s.slice(0, -1), direction: 'back' });
else if (s[0].name !== 'home') setState({ stack: [{ name: 'home' }], direction: 'back' });
}, []); }, []);
useEffect(() => {
const onPop = () => {
if (ignorePops.current > 0) {
ignorePops.current--;
return;
}
entries.current = Math.max(0, entries.current - 1);
stepBack();
};
window.addEventListener('popstate', onPop);
return () => window.removeEventListener('popstate', onPop);
}, [stepBack]);
const desired = steps.length + (stack.length - 1) + (stack[0].name !== 'home' ? 1 : 0);
useEffect(() => {
const diff = desired - entries.current;
if (diff > 0) {
for (let i = 0; i < diff; i++) window.history.pushState({ tadrisino: true }, '');
} else if (diff < 0) {
ignorePops.current++;
window.history.go(diff);
}
entries.current = desired;
}, [desired]);
const register = useCallback((onBack: () => void) => {
setSteps((list) => [...list, onBack]);
return () => setSteps((list) => list.filter((f) => f !== onBack));
}, []);
const backSteps = useMemo<BackSteps>(() => ({ register }), [register]);
const push = useCallback((route: Route) => { const push = useCallback((route: Route) => {
setStack((s) => [...s, route], 'push'); setState((prev) => ({ stack: [...prev.stack, route], direction: 'push' }));
window.history.pushState({ tadrisino: true }, '');
}, []); }, []);
const replace = useCallback((route: Route) => { const replace = useCallback((route: Route) => {
setStack((s) => [...s.slice(0, -1), route], 'replace'); setState((prev) => ({ stack: [...prev.stack.slice(0, -1), route], direction: 'replace' }));
}, []); }, []);
const back = useCallback(() => { const back = useCallback(() => {
// popstate خودش صفحه را برمی‌دارد؛ پس دو منبعِ حقیقت نداریم. // دکمه‌ی برگشتِ داخلِ اپ همان راهِ دکمه‌ی گوشی را می‌رود تا history و پشته از هم جدا نشوند.
window.history.back(); if (entries.current > 0) window.history.back();
}, []); else stepBack();
}, [stepBack]);
const switchTab = useCallback((tab: TabName) => { const switchTab = useCallback((tab: TabName) => {
// زدنِ همان تبی که باز و ریشه است صفحه را از نو نمی‌سازد (قبلاً دوباره بار می‌شد)؛ مثلِ اپ‌های بومی به // زدنِ همان تبی که باز و ریشه است صفحه را از نو نمی‌سازد (قبلاً دوباره بار می‌شد)؛ مثلِ اپ‌های بومی به
@@ -82,7 +145,11 @@ export function NavigatorProvider({ initial, children }: { initial: Route; child
}; };
}, [stack, direction, reselect, push, replace, back, switchTab]); }, [stack, direction, reselect, push, replace, back, switchTab]);
return <NavContext.Provider value={value}>{children}</NavContext.Provider>; return (
<NavContext.Provider value={value}>
<BackContext.Provider value={backSteps}>{children}</BackContext.Provider>
</NavContext.Provider>
);
} }
export function useNav(): Nav { export function useNav(): Nav {

View File

@@ -13,6 +13,7 @@ import { toFa } from '@/lib/format';
import { MathText } from '@/lib/MathText'; import { MathText } from '@/lib/MathText';
import { BrandFooter } from '@/components/BrandFooter'; import { BrandFooter } from '@/components/BrandFooter';
import { BrandMark } from '@/components/BrandMark'; import { BrandMark } from '@/components/BrandMark';
import { StreakFlame, flameState } from '@/components/StreakFlame';
/** /**
* خانه = خودِ مسیر، مثلِ دولینگو: همه‌ی فصل‌های پایه پشتِ هم، و جزیره‌ی فعلی با * خانه = خودِ مسیر، مثلِ دولینگو: همه‌ی فصل‌های پایه پشتِ هم، و جزیره‌ی فعلی با
@@ -38,10 +39,23 @@ export function HomeScreen() {
}, [home]); }, [home]);
const name = profile.name || 'دوستِ من'; const name = profile.name || 'دوستِ من';
const flame = flameState(summary?.streak);
const streak = summary?.streak;
// زنجیره‌ی در خطر یا یخ‌زده جلوتر از پیامِ معمول می‌آید: از دست دادن انگیزه‌ی قوی‌تری از به‌دست آوردن است،
// ولی لحن مهربان می‌ماند و قدمِ بعدی را می‌گوید.
const streakNote = cont && streak
? flame === 'frozen'
? <>{name}، زنجیره‌ی {toFa(streak.lost)} روزه‌ت یخ زد <StreakFlame state="frozen" size={14} className="mb-0.5 inline" /> یه جزیره بزن تا دوباره آتیشش روشن بشه!</>
: flame === 'risk'
? <>{name}، امروز هنوز تمرین نکردی! زنجیره‌ی {toFa(streak.current)} روزه‌ت منتظرته <StreakFlame state="lit" size={14} className="mb-0.5 inline" /></>
: null
: null;
const bubble = !home const bubble = !home
? 'دارم مسیرت رو میارم…' ? 'دارم مسیرت رو میارم…'
: !home.units.length : !home.units.length
? 'هنوز درسی برای این پایه ثبت نشده.' ? 'هنوز درسی برای این پایه ثبت نشده.'
: streakNote
? streakNote
: cont : cont
? <>سلام {name}! <Hand size={15} className="mb-0.5 inline text-[#E8A400]" /> وقتشه جزیره‌ی <b>«<MathText text={cont.node.title} />»</b> رو شروع کنی؛ دکمه‌ی <b>شروع</b> رو بزن.</> ? <>سلام {name}! <Hand size={15} className="mb-0.5 inline text-[#E8A400]" /> وقتشه جزیره‌ی <b>«<MathText text={cont.node.title} />»</b> رو شروع کنی؛ دکمه‌ی <b>شروع</b> رو بزن.</>
: <>آفرین {name}! همه‌ی جزیره‌های این پایه رو تموم کردی <PartyPopper size={15} className="mb-0.5 inline text-[#E8A400]" /></>; : <>آفرین {name}! همه‌ی جزیره‌های این پایه رو تموم کردی <PartyPopper size={15} className="mb-0.5 inline text-[#E8A400]" /></>;
@@ -69,10 +83,10 @@ export function HomeScreen() {
type="button" type="button"
aria-label={`${label}: ${toFa(value)}`} aria-label={`${label}: ${toFa(value)}`}
onClick={() => nav.push({ name: to })} onClick={() => nav.push({ name: to })}
className="hit flex items-center gap-1 rounded-full bg-white px-2.5 py-1.5 text-xs font-black text-ink shadow-sm active:scale-95" className={`hit flex items-center gap-1 rounded-full px-2.5 py-1.5 text-xs font-black shadow-sm active:scale-95 ${to === 'streak' && flame === 'frozen' ? 'bg-[#E6F6FF] text-[#2B7FB8]' : 'bg-white text-ink'}`}
> >
<Icon size={15} style={{ color }} fill={fill ? color : 'none'} strokeWidth={2.4} /> {to === 'streak' ? <StreakFlame state={flame} size={13} /> : <Icon size={15} style={{ color }} fill={fill ? color : 'none'} strokeWidth={2.4} />}
{toFa(value)} {toFa(to === 'streak' && flame === 'frozen' ? streak?.lost ?? 0 : value)}
</button> </button>
))} ))}
<button type="button" aria-label="اعلان‌ها" onClick={() => nav.push({ name: 'notifications' })} className="hit relative flex h-8 w-8 items-center justify-center rounded-full bg-white/20 active:scale-95"> <button type="button" aria-label="اعلان‌ها" onClick={() => nav.push({ name: 'notifications' })} className="hit relative flex h-8 w-8 items-center justify-center rounded-full bg-white/20 active:scale-95">

View File

@@ -10,6 +10,7 @@ import { ErrorState, LoadingState, PrimaryButton, Screen, ScreenBody, ScreenHead
import { toFa } from '@/lib/format'; import { toFa } from '@/lib/format';
import { MathText } from '@/lib/MathText'; import { MathText } from '@/lib/MathText';
import { Diagram } from '@/diagram'; import { Diagram } from '@/diagram';
import { StepText } from '@/lib/StepText';
/** معرفیِ درس، پیش از تمرین. */ /** معرفیِ درس، پیش از تمرین. */
export function LessonScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: string }) { export function LessonScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: string }) {
@@ -51,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="sigma" 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">
<p className="whitespace-pre-line text-sm leading-[1.95] text-body"><MathText text={data.introText} /></p> <StepText text={data.introText} color={color} className="text-[15px] leading-[1.95] text-body" />
{figs('intro')} {figs('intro')}
</div> </div>
</div> </div>
@@ -63,7 +64,7 @@ export function LessonScreen({ lessonId, nodeId }: { lessonId: number; nodeId?:
{data.example && ( {data.example && (
<section className="rounded-3xl border border-line bg-white p-4"> <section className="rounded-3xl border border-line bg-white p-4">
<h2 className="mb-2 text-xs font-black text-muted">مثال</h2> <h2 className="mb-2 text-xs font-black text-muted">مثال</h2>
<p className="whitespace-pre-line text-sm leading-[1.95] text-ink"><MathText text={data.example} /></p> <StepText text={data.example} color={color} className="text-[15px] leading-[1.95] text-ink" />
{figs('example')} {figs('example')}
</section> </section>
)} )}

View File

@@ -17,6 +17,7 @@ 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';
type Phase = type Phase =
| { kind: 'loading' } | { kind: 'loading' }
@@ -47,7 +48,10 @@ 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, refreshHome, refreshSummary } = useAppData(); const { home, companion, summary, refreshHome, refreshSummary } = useAppData();
// زنجیره پیش از این تمرین؛ اگر بعد از ثبت امروز روشن شد، صفحه‌ی «روزِ پیاپی» نشان داده می‌شود.
const streakBefore = useRef(summary?.streak ?? null);
const [streakUp, setStreakUp] = useState<{ from: number; to: number } | null>(null);
const nav = useNav(); const nav = useNav();
const toast = useToast(); const toast = useToast();
@@ -114,6 +118,7 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
/** انتخاب فقط گزینه را آبی می‌کند؛ تا «بررسی» نزده، چیزی ثبت یا رو نمی‌شود و می‌شود عوضش کرد. */ /** انتخاب فقط گزینه را آبی می‌کند؛ تا «بررسی» نزده، چیزی ثبت یا رو نمی‌شود و می‌شود عوضش کرد. */
function choose(optionId: string) { function choose(optionId: string) {
if (!question || answer || submitting) return; if (!question || answer || submitting) return;
if (optionId !== selected) sounds.select();
setSelected(optionId); setSelected(optionId);
} }
@@ -185,6 +190,14 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
if (result.stars >= 2) void import('canvas-confetti').then(({ default: confetti }) => confetti({ particleCount: 90, spread: 70, origin: { y: 0.35 } })); if (result.stars >= 2) void import('canvas-confetti').then(({ default: confetti }) => confetti({ particleCount: 90, spread: 70, origin: { y: 0.35 } }));
void refreshHome(); void refreshHome();
void refreshSummary(); void refreshSummary();
const before = streakBefore.current;
if (before && !before.activeToday) {
api.summary()
.then((fresh) => {
if (fresh.streak.activeToday) setStreakUp({ from: before.current, to: fresh.streak.current });
})
.catch((err: unknown) => console.warn('streak check after finish failed', err));
}
} catch (error) { } catch (error) {
// سرور پایانِ درس/جزیره را بی‌قلب و بی‌جواب نمی‌پذیرد (۱۴۰۵-۰۷)؛ همان صفحه‌ی انتظار. // سرور پایانِ درس/جزیره را بی‌قلب و بی‌جواب نمی‌پذیرد (۱۴۰۵-۰۷)؛ همان صفحه‌ی انتظار.
if (error instanceof ApiError && error.code === 'no_hearts') { if (error instanceof ApiError && error.code === 'no_hearts') {
@@ -223,7 +236,7 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
if (phase.kind === 'noHearts') { if (phase.kind === 'noHearts') {
return <NoHeartsScreen nextRefillAt={phase.nextRefillAt} fullRefillAt={phase.fullRefillAt} onRefilled={() => setReload((n) => n + 1)} />; return <NoHeartsScreen nextRefillAt={phase.nextRefillAt} fullRefillAt={phase.fullRefillAt} onRefilled={() => setReload((n) => n + 1)} />;
} }
if (phase.kind === 'result') return <Result result={phase.result} total={questions.length} correct={correctCount} lessonId={lessonId} nodeId={ref?.node.id ?? nodeId} color={color} />; if (phase.kind === 'result') return <Result result={phase.result} total={questions.length} correct={correctCount} lessonId={lessonId} nodeId={ref?.node.id ?? nodeId} color={color} streakUp={streakUp} />;
if (phase.kind === 'finishing' || phase.kind === 'finishError') { if (phase.kind === 'finishing' || phase.kind === 'finishError') {
return ( return (
<Screen> <Screen>
@@ -301,10 +314,10 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
!answer ? 'border-line bg-white text-indigo' : answer.correct ? 'border-green-200 bg-green-50 text-green-700' : 'border-purple-200 bg-purple-50 text-[#5B3FA0]' !answer ? 'border-line bg-white text-indigo' : answer.correct ? 'border-green-200 bg-green-50 text-green-700' : 'border-purple-200 bg-purple-50 text-[#5B3FA0]'
}`}> }`}>
{bubble} {bubble}
{answer && <Reaction key={question.id} correct={answer.correct} seed={index} />} {answer && <Reaction key={`r${question.id}`} correct={answer.correct} seed={index} />}
{answer?.correct && answer.xpEarned > 0 && ( {answer?.correct && answer.xpEarned > 0 && (
<motion.span <motion.span
key={question.id} key={`xp${question.id}`}
initial={{ opacity: 0, y: 8, scale: 0.8 }} initial={{ opacity: 0, y: 8, scale: 0.8 }}
animate={{ opacity: 1, y: 0, scale: 1 }} animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ type: 'spring', stiffness: 400, damping: 14 }} transition={{ type: 'spring', stiffness: 400, damping: 14 }}
@@ -413,11 +426,15 @@ function HeartsRow({ hearts }: { hearts: Hearts | null }) {
} }
function Result({ function Result({
result, total, correct, lessonId, nodeId, color, result, total, correct, lessonId, nodeId, color, streakUp,
}: { result: QuizResult; total: number; correct: number; lessonId?: number; nodeId?: string; color: string }) { }: { result: QuizResult; total: number; correct: number; lessonId?: number; nodeId?: string; color: string; streakUp: { from: number; to: number } | null }) {
const nav = useNav(); const nav = useNav();
const { companion } = useAppData(); const { companion } = useAppData();
const [loadingNext, setLoadingNext] = useState(false); const [loadingNext, setLoadingNext] = useState(false);
// بعد از نتیجه، اگر زنجیره امروز روشن شد، یک صفحه‌ی جشنِ جدا (مثلِ دولینگو) و بعد مقصدِ انتخاب‌شده.
const [streakThen, setStreakThen] = useState<(() => void) | null>(null);
const then = (go: () => void) => (streakUp ? setStreakThen(() => go) : go());
if (streakThen && streakUp) return <StreakCelebration from={streakUp.from} to={streakUp.to} onDone={streakThen} />;
const nodeMode = lessonId === undefined; const nodeMode = lessonId === undefined;
const nextNode = result.nextNodeId ?? null; const nextNode = result.nextNodeId ?? null;
@@ -479,11 +496,11 @@ function Result({
<span className="text-[10px] text-muted">سکه</span> <span className="text-[10px] text-muted">سکه</span>
</div> </div>
</div>} </div>}
<PrimaryButton color={color} loading={loadingNext} onClick={() => void goNext()}> <PrimaryButton color={color} loading={loadingNext} onClick={() => then(() => void goNext())}>
{nodeMode && nextNode ? 'بریم جزیره‌ی بعدی' : 'ادامه'} <ChevronLeft size={18} /> {nodeMode && nextNode ? 'بریم جزیره‌ی بعدی' : 'ادامه'} <ChevronLeft size={18} />
</PrimaryButton> </PrimaryButton>
{nodeMode && nextNode && ( {nodeMode && nextNode && (
<button type="button" onClick={nav.back} className="-mt-2 py-2 text-sm font-bold text-muted"> <button type="button" onClick={() => then(nav.back)} className="-mt-2 py-2 text-sm font-bold text-muted">
فعلاً بسه، برگردیم فعلاً بسه، برگردیم
</button> </button>
)} )}

View File

@@ -3,6 +3,8 @@ import { api } from '@/api';
import { useAsync } from '@/lib/useAsync'; import { useAsync } from '@/lib/useAsync';
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 { useAppData } from '@/state/AppData';
import { StreakFlame, flameState } from '@/components/StreakFlame';
const WEEKS = 6; const WEEKS = 6;
const WEEKDAYS = ['ش', 'ی', 'د', 'س', 'چ', 'پ', 'ج']; const WEEKDAYS = ['ش', 'ی', 'د', 'س', 'چ', 'پ', 'ج'];
@@ -30,6 +32,8 @@ const dayOfMonth = (d: Date) => new Intl.DateTimeFormat('fa-IR-u-ca-persian', {
export function StreakScreen() { export function StreakScreen() {
const calendar = useAsync(() => api.streakCalendar(), [], { key: 'streak-calendar' }); const calendar = useAsync(() => api.streakCalendar(), [], { key: 'streak-calendar' });
const { summary } = useAppData();
const flame = flameState(summary?.streak);
const active = new Set(calendar.data?.activeDays ?? []); const active = new Set(calendar.data?.activeDays ?? []);
const today = isoDay(new Date()); const today = isoDay(new Date());
const weeks = recentWeeks(); const weeks = recentWeeks();
@@ -48,16 +52,16 @@ export function StreakScreen() {
{[ {[
{ label: 'زنجیره‌ی فعلی', value: calendar.data.currentStreak }, { label: 'زنجیره‌ی فعلی', value: calendar.data.currentStreak },
{ label: 'بیشترین زنجیره', value: calendar.data.longestStreak }, { label: 'بیشترین زنجیره', value: calendar.data.longestStreak },
].map((s) => ( ].map((s, i) => (
<div key={s.label} className="flex flex-col items-center rounded-3xl border border-line bg-white py-4"> <div key={s.label} className="flex flex-col items-center rounded-3xl border border-line bg-white py-4">
<Flame size={24} className="text-orange" fill="#FF7A3D" /> {i === 0 ? <StreakFlame state={flame} size={26} /> : <Flame size={24} className="text-orange" fill="#FF7A3D" />}
<span className="mt-1 text-2xl font-black text-ink">{toFa(s.value)}</span> <span className="mt-1 text-2xl font-black text-ink">{toFa(s.value)}</span>
<span className="text-xs text-muted">{s.label} (روز)</span> <span className="text-xs text-muted">{s.label} (روز)</span>
</div> </div>
))} ))}
</div> </div>
<StreakGoal current={calendar.data.currentStreak} doneToday={active.has(today)} /> <StreakGoal current={calendar.data.currentStreak} doneToday={active.has(today)} lost={summary?.streak.lost ?? 0} />
<div className="rounded-3xl border border-line bg-white p-4"> <div className="rounded-3xl border border-line bg-white p-4">
<div className="mb-2 grid grid-cols-7 gap-1.5 text-center text-[10px] font-bold text-muted"> <div className="mb-2 grid grid-cols-7 gap-1.5 text-center text-[10px] font-bold text-muted">
@@ -96,12 +100,14 @@ export function StreakScreen() {
/** نقطه‌عطف‌های زنجیره؛ فقط پیام و نوار، هیچ پاداشِ واقعی‌ای پشتش نیست (پاداش تصمیمِ محصولی است). */ /** نقطه‌عطف‌های زنجیره؛ فقط پیام و نوار، هیچ پاداشِ واقعی‌ای پشتش نیست (پاداش تصمیمِ محصولی است). */
const MILESTONES = [3, 7, 14, 30, 60, 100]; const MILESTONES = [3, 7, 14, 30, 60, 100];
function StreakGoal({ current, doneToday }: { current: number; doneToday: boolean }) { function StreakGoal({ current, doneToday, lost }: { current: number; doneToday: boolean; lost: number }) {
const next = MILESTONES.find((m) => m > current); const next = MILESTONES.find((m) => m > current);
const reached = MILESTONES.filter((m) => m <= current).pop(); const reached = MILESTONES.filter((m) => m <= current).pop();
// زنجیره‌ی صفر: «۳ روز دیگه می‌شه زنجیره‌ی ۳ روزه» هدفی دور بود؛ قدمِ امروز کوچک و روشن گفته می‌شود. // زنجیره‌ی صفر: «۳ روز دیگه می‌شه زنجیره‌ی ۳ روزه» هدفی دور بود؛ قدمِ امروز کوچک و روشن گفته می‌شود.
// زنجیره‌ی روشن و امروزِ خالی: یادآوریِ این‌که امروز چیزی مانده تا زنجیره نسوزد. // زنجیره‌ی روشن و امروزِ خالی: یادآوریِ این‌که امروز چیزی مانده تا زنجیره نسوزد.
const today = current === 0 const today = current === 0 && lost > 0
? `زنجیره‌ی ${toFa(lost)} روزه‌ت یخ زد! امروز یه درس بزن تا دوباره آتیشش روشن بشه.`
: current === 0
? 'امروز فقط یه درس یا یه جزیره تموم کن تا زنجیره‌ت روشن بشه.' ? 'امروز فقط یه درس یا یه جزیره تموم کن تا زنجیره‌ت روشن بشه.'
: !doneToday : !doneToday
? `امروز هنوز تمرین نکردی؛ یه درس بزن تا زنجیره‌ی ${toFa(current)} روزه‌ت نسوزه.` ? `امروز هنوز تمرین نکردی؛ یه درس بزن تا زنجیره‌ی ${toFa(current)} روزه‌ت نسوزه.`

View File

@@ -1,14 +1,15 @@
import { useState, type ReactNode } from 'react'; import { useEffect, useState, type ReactNode } from 'react';
import { Check, ChevronLeft, ChevronRight, Crown, Heart, Sparkles, Ticket } from 'lucide-react'; import { Check, ChevronLeft, Crown, CreditCard, Heart, ShieldCheck, ShoppingBag, Sparkles, Ticket } from 'lucide-react';
import { api, type Plan, type SubscriptionStatus, type TutorQuota } from '@/api'; import { api, type Plan, type SubscriptionStatus } from '@/api';
import { daysLeft } from '@/lib/subscription'; import { daysLeft } from '@/lib/subscription';
import { newIdempotencyKey } from '@/api/http'; import { newIdempotencyKey } from '@/api/http';
import { BazaarPurchaseError, bazaarConsume, bazaarPending, bazaarPurchase, inBazaarApp } from '@/lib/bazaar'; import { BazaarPurchaseError, bazaarConsume, bazaarPending, bazaarPurchase, inBazaarApp } from '@/lib/bazaar';
import { useAsync } from '@/lib/useAsync'; import { useAsync } from '@/lib/useAsync';
import { Sheet } from '@/components/Sheet'; import { Sheet } from '@/components/Sheet';
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, formatToman, toFa } from '@/lib/format'; import { formatJalaliDate, formatNumber, formatToman, toFa } from '@/lib/format';
function durationLabel(days: number): string { function durationLabel(days: number): string {
if (days === 30 || days === 31) return 'یک‌ماهه'; if (days === 30 || days === 31) return 'یک‌ماهه';
@@ -18,208 +19,241 @@ function durationLabel(days: number): string {
return days ? `${toFa(days)} روزه` : ''; return days ? `${toFa(days)} روزه` : '';
} }
/** «۷۹۸ هزار تومان» به‌جای «۷۹۸٬۰۰۰ تومان»؛ صفرهای پشتِ هم برای بچه خواندنِ قیمت را سخت می‌کرد. */
function price(value: number): string {
return value >= 1000 && value % 1000 === 0 ? `${formatNumber(value / 1000)} هزار تومان` : formatToman(value);
}
type View = 'subscription' | 'pack'; type View = 'subscription' | 'pack';
/** /**
* فروشگاه دو محصول دارد: «اشتراک» (یک/سه/شش‌ماهه؛ همه‌ی فصل‌ها، قلبِ نامحدود، سقفِ روزانه‌ی دستیار) * فروشگاه دو محصول دارد: «اشتراک» (یک/سه/شش‌ماهه؛ همه‌ی فصل‌ها، قلبِ نامحدود، سقفِ روزانه‌ی دستیار)
* و «بسته‌ی سوال» (سوالِ بی‌انقضا از دستیار). خواستِ سینیور: پیش از فهرست دو گزینه بیاید تا شش کارت * و «بسته‌ی سوال» (سوالِ بی‌انقضا از دستیار). خواستِ سینیور: پیش از فهرست دو گزینه بیاید تا شش کارت
* یک‌جا کاربر را گیج نکند. از فصلِ قفل یک‌راست به فهرستِ اشتراک می‌آید (`initialView`). * یک‌جا کاربر را گیج نکند. از فصلِ قفل یک‌راست به فهرستِ اشتراک می‌آید (`initialView`).
*
* چیدمان (۱۴۰۵-۰۷، خواستِ مالک: «شلوغه و برای بچه گیج‌کننده»): مزیت‌های اشتراک یک بار بالای فهرست
* می‌آیند نه روی هر کارت (هر کارت همان سه خط و «۳ سوال در روز» را دو بار داشت)، هر پلن یک ردیفِ قابلِ
* انتخاب است با نام و قیمت، و فقط یک دکمه‌ی بزرگ پایینِ صفحه هست. روشِ پرداخت در برگه‌ی پرداخت انتخاب می‌شود.
*/ */
export function SubscriptionScreen({ initialView }: { initialView?: View }) { export function SubscriptionScreen({ initialView }: { initialView?: View }) {
const plans = useAsync(() => api.subscription.plans(), [], { key: 'plans' }); const plans = useAsync(() => api.subscription.plans(), [], { key: 'plans' });
const status = useAsync(() => api.subscription.status(), [], { key: 'subscription-status' }); const status = useAsync(() => api.subscription.status(), [], { key: 'subscription-status' });
const quota = useAsync(() => api.ai.quota(), [], { key: 'ai-quota' });
const [view, setView] = useState<View | null>(initialView ?? null); const [view, setView] = useState<View | null>(initialView ?? null);
const [picked, setPicked] = useState<string | null>(null);
const [buying, setBuying] = useState<Plan | null>(null); const [buying, setBuying] = useState<Plan | null>(null);
// داخلِ اپِ بازار، طبقِ قانونِ بازار، خرید فقط با پرداختِ درون‌برنامه‌ایِ خودش است؛ محصولی که
// SKU ندارد آن‌جا خریدنی نیست (به زرین‌پال فرستاده نمی‌شود).
const bazaar = inBazaarApp(); const bazaar = inBazaarApp();
// از فهرست، «برگشت» اول به دو گزینه برمی‌گردد.
useBackStep(view !== null && !initialView, () => setView(null));
const credits = status.data?.questionCredits ?? quota.data?.credits ?? 0; // پرداخت با درگاه داخلِ اپ در مرورگرِ گوشی باز می‌شود؛ برگشت به اپ یعنی وضعیت را دوباره بپرس.
const heartsUntil = status.data?.unlimitedHeartsUntil ?? null; useEffect(() => {
const reload = () => { status.reload(); quota.reload(); }; const onVisible = () => document.visibilityState === 'visible' && status.reload();
const list = plans.data?.plans.filter((p) => p.kind === view) ?? []; document.addEventListener('visibilitychange', onVisible);
const monthly = plans.data?.plans.find((p) => p.kind === 'subscription' && p.durationDays <= 31); return () => document.removeEventListener('visibilitychange', onVisible);
}, [status.reload]);
const all = plans.data?.plans ?? [];
const list = all.filter((p) => p.kind === view);
const monthly = all.find((p) => p.kind === 'subscription' && p.durationDays <= 31);
const cheapestSub = all.filter((p) => p.kind === 'subscription').sort((x, y) => x.price - y.price)[0];
const selected = list.find((p) => p.id === picked) ?? list.find((p) => p.popular) ?? list[0];
const active = status.data?.active ?? false;
return ( return (
<Screen> <Screen>
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
<ScreenHeader <ScreenHeader
title={view === 'pack' ? 'بسته‌ی سوال' : view === 'subscription' ? 'اشتراکِ تدریسینو' : 'فروشگاه'} title={view === 'pack' ? 'بسته‌ی سوال' : view === 'subscription' ? (active ? 'تمدیدِ اشتراک' : 'اشتراکِ تدریسینو') : 'فروشگاه'}
subtitle={view === 'pack' ? 'سوالِ بیشتر از دستیار، بدونِ انقضا' : 'همه‌ی فصل‌ها، قلبِ نامحدود'} subtitle={view === 'pack' ? 'سوالِ بیشتر از پی، بدونِ تاریخِ انقضا' : 'همه‌ی فصل‌ها، قلبِ نامحدود'}
color="#ED6956" color="#ED6956"
exact exact
back={view !== null && !initialView ? () => setView(null) : true}
mascot={{ char: 'pi', mood: 'excited', size: 60 }} mascot={{ char: 'pi', 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">
<StatusCard status={status.data ?? null} statusLoading={status.loading && !status.data} credits={credits} heartsUntil={heartsUntil} quota={quota.data ?? null} quotaLoading={quota.loading && !quota.data} /> {view === null && <StatusCard status={status.data ?? null} loading={status.loading && !status.data} />}
{view === null ? ( {view === null ? (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<ChoiceCard <ChoiceCard
icon={<Crown size={24} />} icon={<Crown size={26} />}
tone="#FF7A3D" tone="#FF7A3D"
title="اشتراکِ ماهانه" title={active ? 'تمدیدِ اشتراک' : 'اشتراک'}
text="همه‌ی فصل‌ها و جزیره‌ها باز، با قلبِ نامحدود" text="همه‌ی فصل‌ها باز، با قلبِ نامحدود"
hint={monthly ? `از ${formatToman(monthly.price)}` : undefined} hint={cheapestSub ? `از ${price(cheapestSub.price)}` : undefined}
onClick={() => setView('subscription')} onClick={() => setView('subscription')}
/> />
<ChoiceCard <ChoiceCard
icon={<Sparkles size={24} />} icon={<Sparkles size={26} />}
tone="#9B7BFF" tone="#9B7BFF"
title="بسته‌ی سوال" title="بسته‌ی سوال"
text="سوالِ بیشتر از دستیارِ هوشمند وقتی سقفِ روزت تموم شد؛ هیچ‌وقت منقضی نمی‌شه" text="سوالِ بیشتر از پی، هیچ‌وقت تموم نمی‌شه"
onClick={() => setView('pack')} onClick={() => setView('pack')}
/> />
</div> </div>
) : ( ) : plans.loading && !plans.data ? (
<>
{!initialView && (
<button type="button" onClick={() => setView(null)} className="flex items-center gap-1 self-start text-xs font-bold text-coral">
<ChevronRight size={14} /> برگشت به انتخاب
</button>
)}
{plans.loading ? (
<LoadingState text="دارم فهرست رو میارم…" /> <LoadingState text="دارم فهرست رو میارم…" />
) : plans.error ? ( ) : plans.error && !plans.data ? (
<ErrorState error={plans.error} onRetry={plans.reload} /> <ErrorState error={plans.error} onRetry={plans.reload} />
) : !list.length ? ( ) : !list.length ? (
<EmptyState text="فعلاً چیزی برای خرید تعریف نشده" /> <EmptyState text="فعلاً چیزی برای خرید تعریف نشده" />
) : ( ) : (
list.map((plan) => ( <>
<PackCard {view === 'subscription' ? (
key={plan.id} <Perks items={selected?.features.length ? selected.features : ['همه‌ی فصل‌ها و جزیره‌ها باز', 'قلبِ نامحدود']} />
plan={plan} ) : (
monthlyPrice={monthly?.price} <p className="px-1 text-center text-sm leading-7 text-body">
// اشتراک در بازار هنوز SKU و تمدیدِ آزموده‌شده ندارد؛ آن‌جا فقط بسته خریدنی است. هر روز اول از سوال‌های رایگانِ همون روز خرج می‌شه، بعد از بسته.
available={!bazaar || (plan.kind === 'pack' && !!plan.bazaarSku)}
onBuy={() => setBuying(plan)}
/>
))
)}
{view === 'pack' && (
<p className="text-center text-xs leading-6 text-muted">
هر روز {toFa(status.data?.aiDailyQuota ?? plans.data?.freeAiDailyQuota ?? 3)} سوالِ اول از سقفِ روزانه‌ته؛ بعدش از اعتبارِ بسته کم می‌شه.
</p> </p>
)} )}
<div role="radiogroup" aria-label={view === 'pack' ? 'بسته‌ها' : 'مدتِ اشتراک'} className="flex flex-col gap-3">
{list.map((plan) => (
<PlanRow key={plan.id} plan={plan} monthlyPrice={monthly?.price} selected={plan.id === selected?.id} onSelect={() => setPicked(plan.id)} />
))}
</div>
</> </>
)} )}
<p className="pb-2 text-center text-[11px] leading-6 text-muted">
{bazaar ? 'پرداخت از طریقِ کافه‌بازار انجام می‌شه.' : 'پرداخت از طریقِ درگاهِ امنِ زرین‌پال انجام می‌شه.'}
</p>
</div> </div>
</div> </div>
<CheckoutSheet plan={buying} bazaar={bazaar} onClose={() => setBuying(null)} onPaid={() => { setBuying(null); reload(); }} /> {view !== null && selected && (
<div className="flex-shrink-0 border-t border-line bg-white px-4 pb-[max(env(safe-area-inset-bottom),1rem)] pt-3">
<PrimaryButton color={view === 'pack' ? '#9B7BFF' : '#FF7A3D'} onClick={() => setBuying(selected)}>
{view === 'pack' ? `خریدِ ${toFa(selected.questionCredits)} سوال` : `خریدِ اشتراکِ ${durationLabel(selected.durationDays) || selected.title}`}
</PrimaryButton>
</div>
)}
<CheckoutSheet plan={buying} bazaar={bazaar} onClose={() => setBuying(null)} onPaid={() => { setBuying(null); status.reload(); }} />
</Screen> </Screen>
); );
} }
function ChoiceCard({ icon, tone, title, text, hint, onClick }: { icon: ReactNode; tone: string; title: string; text: string; hint?: string; onClick: () => void }) { function ChoiceCard({ icon, tone, title, text, hint, onClick }: { icon: ReactNode; tone: string; title: string; text: string; hint?: string; onClick: () => void }) {
return ( return (
<button type="button" onClick={onClick} className="flex items-center gap-3 rounded-3xl border-2 border-line bg-white p-4 text-right active:scale-[0.99]"> <button
type="button"
onClick={onClick}
className="flex items-center gap-4 rounded-3xl border-2 border-b-4 bg-white p-4 text-right transition-transform active:translate-y-0.5"
style={{ borderColor: `${tone}40` }}
>
<span className="flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-2xl text-white" style={{ backgroundColor: tone }}>{icon}</span> <span className="flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-2xl text-white" style={{ backgroundColor: tone }}>{icon}</span>
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="block text-base font-black text-ink">{title}</span> <span className="block text-lg font-black text-ink">{title}</span>
<span className="mt-0.5 block text-xs leading-6 text-muted">{text}</span> <span className="mt-0.5 block text-sm leading-6 text-body">{text}</span>
{hint && <span className="mt-1 block text-xs font-black" style={{ color: tone }}>{hint}</span>} {hint && <span className="mt-1 block text-sm font-black" style={{ color: tone }}>{hint}</span>}
</span> </span>
<ChevronLeft size={18} className="flex-shrink-0 text-faint" /> <ChevronLeft size={20} className="flex-shrink-0" style={{ color: tone }} />
</button> </button>
); );
} }
/** وضعیتِ فعلیِ کاربر بالای فروشگاه: اشتراک و روزهای مانده، اعتبارِ سوال، قلبِ نامحدود. */ /**
/** تا وضعیت نرسیده جای خالیِ هم‌اندازه نشان داده می‌شود؛ قبلاً «نداری» می‌آمد و برای مشترک غلط بود. */ * وضعیتِ کاربر بالای فروشگاه. مشترک: یک کارتِ واضح با روزهای مانده. بی‌اشتراک: هیچ کارتی (دو گزینه‌ی زیر
const Placeholder = ({ className }: { className: string }) => <span className={`inline-block animate-pulse rounded-lg bg-soft align-middle ${className}`} />; * خودشان حرف را می‌زنند)، مگر سوالِ خریده‌شده یا قلبِ هدیه داشته باشد. تا وضعیت نرسیده جای خالیِ هم‌اندازه
* می‌آید، نه «نداری» که برای مشترک غلط بود.
function StatusCard({ status, statusLoading, credits, heartsUntil, quota, quotaLoading }: { */
status: SubscriptionStatus | null; statusLoading: boolean; credits: number; heartsUntil: string | null; quota: TutorQuota | null; quotaLoading: boolean; function StatusCard({ status, loading }: { status: SubscriptionStatus | null; loading: boolean }) {
}) { if (loading) return <div className="h-[104px] animate-pulse rounded-3xl bg-soft" />;
const days = status?.active ? daysLeft(status.expiresAt) : null; if (!status) return null;
const days = status.active ? daysLeft(status.expiresAt) : null;
const hearts = !status.active && status.unlimitedHeartsUntil;
const extras = (
<>
{status.questionCredits > 0 && <Pill icon={<Sparkles size={14} />} text={`${toFa(status.questionCredits)} سوالِ خریده‌شده`} />}
{hearts && <Pill icon={<Heart size={14} fill="currentColor" />} text={`قلبِ نامحدود تا ${formatJalaliDate(status.unlimitedHeartsUntil!)}`} />}
</>
);
if (!status.active) {
return status.questionCredits > 0 || hearts ? <div className="flex flex-wrap gap-2">{extras}</div> : null;
}
const total = status.planDurationDays ?? 30;
const percent = days === null ? 100 : Math.min(100, Math.max(4, Math.round((days / total) * 100)));
return ( return (
<div className="rounded-3xl border border-line bg-white p-4"> <div className="flex flex-col gap-2">
<div className="rounded-3xl bg-gradient-to-l from-[#FF7A3D] to-[#ED6956] p-4 text-white shadow-lg shadow-orange/25">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className={`flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-2xl ${status?.active ? 'bg-[#FFF4D6] text-[#E0A200]' : 'bg-soft text-muted'}`}><Crown size={22} /></span> <span className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-2xl bg-white/20"><Crown size={24} /></span>
<div className="flex-1"> <div className="min-w-0 flex-1">
<div className="text-xs font-bold text-muted">اشتراکِ تو</div> <div className="text-base font-black">اشتراکت فعاله</div>
<div className="text-base font-black text-ink"> <div className="text-sm text-white/85">{status.planTitle ?? 'اشتراک'}</div>
{statusLoading ? <Placeholder className="h-5 w-20" /> : status?.active ? <>{status.planTitle ?? 'فعال'}{days !== null && <span className="text-sm font-bold text-body"> · {toFa(days)} روز مونده</span>}</> : 'نداری'}
</div> </div>
</div> {days !== null && (
{quotaLoading && <Placeholder className="h-10 w-14" />} <div className="text-center">
{quota && ( <div className="text-3xl font-black leading-none">{toFa(days)}</div>
<div className="text-left text-xs leading-6 text-muted"> <div className="mt-1 text-xs text-white/85">روز مونده</div>
سوالِ امروز<br />
<b className="text-base text-ink">{toFa(quota.remainingQuota)}</b> از {toFa(status?.aiDailyQuota ?? quota.freeDaily)}
</div> </div>
)} )}
</div> </div>
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 border-t border-line pt-3 text-xs font-bold"> <div className="mt-3 h-2.5 overflow-hidden rounded-full bg-black/15">
<span className="flex items-center gap-1.5 text-violet"><Sparkles size={13} /> اعتبار: {statusLoading && quotaLoading ? <Placeholder className="h-3.5 w-10" /> : <>{toFa(credits)} سوال</>}</span> <div className="h-full rounded-full bg-white" style={{ width: `${percent}%` }} />
{(status?.active || heartsUntil) && (
<span className="flex items-center gap-1.5 text-coral">
<Heart size={13} fill="#ED6956" /> قلبِ نامحدود{!status?.active && heartsUntil && <> تا {formatJalaliDate(heartsUntil)}</>}
</span>
)}
{status?.active && status.expiresAt && <span className="text-muted">تا {formatJalaliDate(status.expiresAt)}</span>}
</div> </div>
</div> </div>
{(status.questionCredits > 0) && <div className="flex flex-wrap gap-2">{extras}</div>}
</div>
); );
} }
function PackCard({ plan, monthlyPrice, available, onBuy }: { plan: Plan; monthlyPrice?: number; available: boolean; onBuy: () => void }) { function Pill({ icon, text }: { icon: ReactNode; text: string }) {
const months = Math.round(plan.durationDays / 30);
const saving = plan.kind === 'subscription' && monthlyPrice && months > 1 ? Math.round((1 - plan.price / (monthlyPrice * months)) * 100) : 0;
const featured = plan.popular;
const isPack = plan.kind === 'pack';
const tone = featured ? 'text-white/80' : 'text-muted';
return ( return (
<div className="relative pt-3"> <span className="inline-flex items-center gap-1.5 rounded-full bg-white px-3 py-1.5 text-sm font-bold text-violet ring-1 ring-line">
{plan.badge && ( {icon} {text}
<span className="absolute right-5 top-0 z-10 rounded-full bg-[#FFD23F] px-3 py-1 text-[11px] font-black text-ink shadow">{plan.badge}</span> </span>
)} );
<div className={`rounded-3xl p-5 ${featured ? 'bg-orange text-white shadow-lg shadow-orange/30' : 'border border-line bg-white text-ink'}`}> }
<div className="mb-3 flex items-start justify-between gap-3">
<div> /** مزیت‌های اشتراک، یک بار. */
<h3 className="text-2xl font-black">{isPack ? <>{toFa(plan.questionCredits)} <span className="text-base">سوال</span></> : plan.title}</h3> function Perks({ items }: { items: string[] }) {
<p className={`mt-0.5 text-xs ${tone}`}>{isPack ? 'از دستیارِ هوشمند، بدونِ تاریخِ انقضا' : durationLabel(plan.durationDays)}</p> return (
</div> <ul className="flex flex-col gap-2.5 rounded-3xl bg-[#FFF4EC] p-4">
<div className="text-left"> {items.map((f) => (
<p className="text-base font-black">{formatToman(plan.price)}</p> <li key={f} className="flex items-center gap-2.5 text-[15px] font-bold text-ink">
{months > 1 && !isPack && <p className={`text-[11px] ${tone}`}>ماهی {formatToman(Math.round(plan.price / months / 1000) * 1000)}</p>} <span className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-orange text-white"><Check size={14} strokeWidth={3.5} /></span>
{saving > 0 && <p className={`mt-0.5 text-[11px] font-black ${featured ? 'text-white' : 'text-mint'}`}>{toFa(saving)}٪ ارزان‌تر</p>} {f}
</div>
</div>
<ul className="mb-4 flex flex-col gap-2 text-sm">
{isPack && plan.heartsDays > 0 && (
<li className="flex items-center gap-2 font-bold">
<Heart size={14} fill={featured ? '#fff' : '#ED6956'} className={featured ? 'text-white' : 'text-coral'} />
هدیه: {toFa(plan.heartsDays)} روز قلبِ نامحدود
</li>
)}
{!isPack && plan.aiDailyQuota > 0 && (
<li className="flex items-center gap-2 font-bold"><Sparkles size={14} /> {toFa(plan.aiDailyQuota)} سوال در روز از دستیار</li>
)}
{plan.features.map((f) => (
<li key={f} className="flex items-center gap-2">
<Check size={14} strokeWidth={3} className={featured ? 'text-white' : 'text-coral'} />
<span className={featured ? 'text-white/90' : 'text-body'}>{f}</span>
</li> </li>
))} ))}
</ul> </ul>
);
}
/** یک ردیفِ انتخابی: نام و قیمت درشت؛ فقط یک نکته‌ی کوچک (تخفیف یا هدیه)، نه پنج خطِ ریز. */
function PlanRow({ plan, monthlyPrice, selected, onSelect }: { plan: Plan; monthlyPrice?: number; selected: boolean; onSelect: () => void }) {
const isPack = plan.kind === 'pack';
const months = Math.round(plan.durationDays / 30);
const saving = !isPack && monthlyPrice && months > 1 ? Math.round((1 - plan.price / (monthlyPrice * months)) * 100) : 0;
const tone = isPack ? '#9B7BFF' : '#FF7A3D';
const note = isPack
? plan.heartsDays > 0 ? `+ ${toFa(plan.heartsDays)} روز قلبِ نامحدود هدیه` : ''
: saving > 0 ? `${toFa(saving)}٪ ارزان‌تر از ماهانه` : '';
return (
<button <button
type="button" type="button"
onClick={onBuy} role="radio"
disabled={!available} aria-checked={selected}
className={`w-full rounded-2xl py-3 text-sm font-black active:scale-[0.98] disabled:opacity-50 ${featured ? 'bg-white text-orange' : 'border-2 border-coral text-coral'}`} onClick={onSelect}
className="relative flex items-center gap-3 rounded-3xl border-2 border-b-4 bg-white px-4 py-4 text-right transition-colors active:translate-y-0.5"
style={{ borderColor: selected ? tone : '#e9e6f7', backgroundColor: selected ? `${tone}0F` : '#fff' }}
> >
{available ? 'خرید' : 'به‌زودی در بازار'} {plan.badge && (
<span className="absolute -top-3 left-4 rounded-full px-3 py-0.5 text-xs font-black text-white shadow-sm" style={{ backgroundColor: tone }}>
{plan.badge}
</span>
)}
<span
className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full border-2"
style={{ borderColor: selected ? tone : '#d6d3ea', backgroundColor: selected ? tone : '#fff' }}
>
{selected && <Check size={14} strokeWidth={3.5} className="text-white" />}
</span>
<span className="min-w-0 flex-1">
<span className="block text-lg font-black text-ink">
{isPack ? <>{toFa(plan.questionCredits)} سوال</> : durationLabel(plan.durationDays) || plan.title}
</span>
{note && <span className="mt-0.5 block text-sm font-bold" style={{ color: isPack ? '#7857E0' : '#2E9E6A' }}>{note}</span>}
</span>
<span className="text-left text-base font-black text-ink">{price(plan.price)}</span>
</button> </button>
</div>
</div>
); );
} }
@@ -229,11 +263,16 @@ function PackCard({ plan, monthlyPrice, available, onBuy }: { plan: Plan; monthl
* Safariِ آیفون مسدود می‌کند؛ برای همین جابه‌جاییِ همین صفحه است. * Safariِ آیفون مسدود می‌کند؛ برای همین جابه‌جاییِ همین صفحه است.
*/ */
function CheckoutSheet({ plan, bazaar, onClose, onPaid }: { plan: Plan | null; bazaar: boolean; onClose: () => void; onPaid: () => void }) { function CheckoutSheet({ plan, bazaar, onClose, onPaid }: { plan: Plan | null; bazaar: boolean; onClose: () => void; onPaid: () => void }) {
// داخلِ اپِ بازار دو راه هست (خواستِ مالک، ۱۴۰۵-۰۷): درگاهِ بانکی پیش‌فرض است، بازار فقط برای بسته‌ای که SKU دارد.
// ⚠️ قانونِ کافه‌بازار پرداختِ محتوای دیجیتال بیرون از پرداختِ درون‌برنامه‌ای را محدود می‌کند؛ این انتخاب تصمیمِ مالک است
// و با بازار هماهنگ نشده.
const canBazaar = bazaar && !!plan?.bazaarSku;
const [method, setMethod] = useState<'gateway' | 'bazaar'>('gateway');
const [code, setCode] = useState(''); const [code, setCode] = useState('');
const [showCode, setShowCode] = useState(false); const [showCode, setShowCode] = useState(false);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [done, setDone] = useState<'code' | 'bazaar' | null>(null); const [done, setDone] = useState<'code' | 'bazaar' | 'gateway' | null>(null);
/** /**
* داخلِ اپِ بازار: سفارش (با کلیدِ ثابتِ همین SKU) → خرید در بازار → سنجشِ سرور → مصرفِ خرید. * داخلِ اپِ بازار: سفارش (با کلیدِ ثابتِ همین SKU) → خرید در بازار → سنجشِ سرور → مصرفِ خرید.
@@ -272,7 +311,7 @@ function CheckoutSheet({ plan, bazaar, onClose, onPaid }: { plan: Plan | null; b
if (!plan || busy) return; if (!plan || busy) return;
setBusy(true); setBusy(true);
setError(''); setError('');
if (bazaar) { if (canBazaar && method === 'bazaar') {
try { try {
if (await payWithBazaar(plan)) setDone('bazaar'); if (await payWithBazaar(plan)) setDone('bazaar');
} catch (err) { } catch (err) {
@@ -295,6 +334,8 @@ function CheckoutSheet({ plan, bazaar, onClose, onPaid }: { plan: Plan | null; b
// فقط http(s): یک «javascript:» یا «data:» از پاسخِ API نباید با assign اجرا شود. // فقط http(s): یک «javascript:» یا «data:» از پاسخِ API نباید با assign اجرا شود.
if (!/^https?:\/\//i.test(order.paymentUrl)) throw new Error('paymentUrl is not http(s)'); if (!/^https?:\/\//i.test(order.paymentUrl)) throw new Error('paymentUrl is not http(s)');
window.location.assign(order.paymentUrl); window.location.assign(order.paymentUrl);
// داخلِ اپ آدرسِ درگاه در مرورگرِ گوشی باز می‌شود و این صفحه همین‌جا می‌ماند؛ دکمه نباید تا ابد بچرخد.
if (bazaar) setTimeout(() => { setBusy(false); setDone('gateway'); }, 1500);
} catch (err) { } catch (err) {
setError(friendlyError(err, 'اتصال به درگاه انجام نشد، دوباره امتحان کن')); setError(friendlyError(err, 'اتصال به درگاه انجام نشد، دوباره امتحان کن'));
setBusy(false); setBusy(false);
@@ -308,26 +349,36 @@ function CheckoutSheet({ plan, bazaar, onClose, onPaid }: { plan: Plan | null; b
setShowCode(false); setShowCode(false);
setError(''); setError('');
setDone(null); setDone(null);
setMethod('gateway');
}; };
return ( return (
<Sheet open={plan !== null} onClose={close} title={done ? 'خریدت انجام شد! 🎉' : plan?.title}> <Sheet open={plan !== null} onClose={close} title={done === 'gateway' ? 'پرداخت در مرورگر' : done ? 'خریدت انجام شد! 🎉' : plan ? (plan.kind === 'pack' ? `${toFa(plan.questionCredits)} سوال` : `اشتراکِ ${durationLabel(plan.durationDays) || plan.title}`) : ''}>
{done ? ( {done ? (
<> <>
<p className="mb-5 text-sm leading-7 text-muted"> <p className="mb-5 text-sm leading-7 text-muted">
{done === 'code' ? 'کدِ تخفیفت کلِ مبلغ رو پوشوند و همین الان به حسابت اضافه شد.' : 'پرداختت در بازار تأیید شد و به حسابت اضافه شد.'} {done === 'gateway'
? 'صفحه‌ی پرداخت در مرورگرِ گوشی باز شد. بعد از پرداخت برگرد همین‌جا؛ خریدت خودش فعال می‌شه.'
: done === 'code' ? 'کدِ تخفیفت کلِ مبلغ رو پوشوند و همین الان به حسابت اضافه شد.' : 'پرداختت در بازار تأیید شد و به حسابت اضافه شد.'}
</p> </p>
<PrimaryButton onClick={close}>عالیه!</PrimaryButton> <PrimaryButton onClick={close}>{done === 'gateway' ? 'باشه' : 'عالیه!'}</PrimaryButton>
</> </>
) : plan && ( ) : plan && (
<> <>
<div className="mb-4 flex items-center justify-between rounded-2xl bg-soft px-4 py-3"> <div className="mb-4 flex items-center justify-between rounded-2xl bg-soft px-4 py-3.5">
<span className="text-sm text-body">مبلغ</span> <span className="text-sm font-bold text-body">مبلغ</span>
<span className="text-base font-black text-ink">{formatToman(plan.price)}</span> <span className="text-lg font-black text-ink">{price(plan.price)}</span>
</div> </div>
{bazaar ? null : showCode ? ( {canBazaar && (
<div role="radiogroup" aria-label="روشِ پرداخت" className="mb-4 flex flex-col gap-2">
<span className="text-sm font-black text-ink">با چی پرداخت می‌کنی؟</span>
<MethodRow selected={method === 'gateway'} onSelect={() => setMethod('gateway')} icon={<CreditCard size={20} />} title="کارتِ بانکی" note="درگاهِ امنِ زرین‌پال" />
<MethodRow selected={method === 'bazaar'} onSelect={() => setMethod('bazaar')} icon={<ShoppingBag size={20} />} title="کافه‌بازار" note="با حسابِ بازارت" />
</div>
)}
{method === 'gateway' && (showCode ? (
<label className="mb-4 block"> <label className="mb-4 block">
<span className="mb-2 block text-xs font-bold text-muted">کدِ تخفیف</span> <span className="mb-2 block text-sm font-bold text-body">کدِ تخفیف</span>
<input <input
value={code} value={code}
onChange={(e) => setCode(e.target.value.trim().slice(0, 40))} onChange={(e) => setCode(e.target.value.trim().slice(0, 40))}
@@ -336,17 +387,42 @@ function CheckoutSheet({ plan, bazaar, onClose, onPaid }: { plan: Plan | null; b
placeholder="CODE" placeholder="CODE"
className="w-full rounded-2xl border-2 border-line px-4 py-3 text-center font-bold tracking-widest outline-none focus:border-coral" className="w-full rounded-2xl border-2 border-line px-4 py-3 text-center font-bold tracking-widest outline-none focus:border-coral"
/> />
<span className="mt-1.5 block text-[11px] text-muted">مبلغِ نهایی بعد از اعمالِ کد در درگاه نشون داده می‌شه.</span>
</label> </label>
) : ( ) : (
<button type="button" onClick={() => setShowCode(true)} className="mb-4 flex items-center gap-1.5 text-sm font-bold text-coral"> <button type="button" onClick={() => setShowCode(true)} className="mb-4 flex items-center gap-1.5 text-sm font-bold text-coral">
<Ticket size={15} /> کدِ تخفیف دارم <Ticket size={15} /> کدِ تخفیف دارم
</button> </button>
)} ))}
{error && <div className="mb-4"><InlineError>{error}</InlineError></div>} {error && <div className="mb-4"><InlineError>{error}</InlineError></div>}
<PrimaryButton loading={busy} onClick={() => void pay()}>پرداخت</PrimaryButton> <PrimaryButton loading={busy} onClick={() => void pay()}>پرداخت</PrimaryButton>
{method === 'gateway' && (
<p className="mt-3 flex items-center justify-center gap-1.5 text-xs text-muted">
<ShieldCheck size={14} /> {bazaar ? 'صفحه‌ی پرداخت در مرورگرِ گوشی باز می‌شه؛ بعدش برگرد به اپ.' : 'پرداخت در درگاهِ امنِ زرین‌پال انجام می‌شه.'}
</p>
)}
</> </>
)} )}
</Sheet> </Sheet>
); );
} }
function MethodRow({ selected, onSelect, icon, title, note }: { selected: boolean; onSelect: () => void; icon: ReactNode; title: string; note: string }) {
return (
<button
type="button"
role="radio"
aria-checked={selected}
onClick={onSelect}
className={`flex items-center gap-3 rounded-2xl border-2 px-4 py-3 text-right ${selected ? 'border-coral bg-[#fff4f2]' : 'border-line bg-white'}`}
>
<span className={`flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full border-2 ${selected ? 'border-coral bg-coral' : 'border-[#d6d3ea]'}`}>
{selected && <Check size={12} strokeWidth={4} className="text-white" />}
</span>
<span className={selected ? 'text-coral' : 'text-muted'}>{icon}</span>
<span className="flex-1">
<span className="block text-[15px] font-black text-ink">{title}</span>
<span className="block text-xs text-muted">{note}</span>
</span>
</button>
);
}

View File

@@ -123,3 +123,13 @@
/* نوارِ انتظارِ میانِ دو صفحه (MainApp → HoldBar). */ /* نوارِ انتظارِ میانِ دو صفحه (MainApp → HoldBar). */
@keyframes hold-bar { 0% { transform: translateX(120%); } 100% { transform: translateX(-320%); } } @keyframes hold-bar { 0% { transform: translateX(120%); } 100% { transform: translateX(-320%); } }
.hold-bar { animation: hold-bar 0.9s cubic-bezier(.4,0,.2,1) infinite; will-change: transform; } .hold-bar { animation: hold-bar 0.9s cubic-bezier(.4,0,.2,1) infinite; will-change: transform; }
/* شعله‌ی زنجیره (StreakFlame): دو لایه با ریتمِ متفاوت، از پایین تکان می‌خورند. */
@keyframes flame-a { 0%, 100% { transform: scale(1, 1) skewX(0deg); } 33% { transform: scale(.97, 1.04) skewX(-2deg); } 66% { transform: scale(1.02, .97) skewX(2deg); } }
@keyframes flame-b { 0%, 100% { transform: scale(1, 1); } 50% { transform: scale(.94, 1.08); } }
.flame-outer { transform-box: fill-box; transform-origin: 50% 100%; animation: flame-a 1.4s ease-in-out infinite; }
.flame-inner { transform-box: fill-box; transform-origin: 50% 100%; animation: flame-b .9s ease-in-out infinite; }
@keyframes ice-glint { 0%, 70%, 100% { opacity: 0; } 80% { opacity: 1; } }
.ice-glint { animation: ice-glint 2.8s ease-in-out infinite; }
@keyframes spark-rise { 0% { transform: translateY(0) scale(1); opacity: 0; } 15% { opacity: 1; } 100% { transform: translateY(-180px) scale(.3); opacity: 0; } }
.spark-rise { animation: spark-rise 1.8s ease-out infinite; }