forked from Tadrisino/MathApp_frontend
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:
@@ -3,7 +3,11 @@ package com.tadrisino.app
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.VibrationEffect
|
||||
import android.os.Vibrator
|
||||
import android.os.VibratorManager
|
||||
import android.util.Log
|
||||
import android.webkit.JavascriptInterface
|
||||
import android.webkit.WebResourceRequest
|
||||
@@ -16,6 +20,7 @@ import ir.cafebazaar.poolakey.Payment
|
||||
import ir.cafebazaar.poolakey.config.PaymentConfiguration
|
||||
import ir.cafebazaar.poolakey.config.SecurityCheck
|
||||
import ir.cafebazaar.poolakey.request.PurchaseRequest
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
@@ -54,6 +59,8 @@ class MainActivity : AppCompatActivity() {
|
||||
// WebView فقط آدرسِ وب را میخواند؛ دسترسی به فایل و content:// لازم نیست.
|
||||
webView.settings.allowFileAccess = false
|
||||
webView.settings.allowContentAccess = false
|
||||
// صداهای کوتاهِ رابط (Web Audio) بعد از پاسخِ سرور پخش میشوند، نه درست در لحظهی لمس.
|
||||
webView.settings.mediaPlaybackRequiresUserGesture = false
|
||||
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
|
||||
fun bazaarConsume(purchaseToken: String) {
|
||||
runOnUiThread {
|
||||
|
||||
@@ -46,7 +46,10 @@ export interface UserSummary {
|
||||
gradeId: number | null;
|
||||
xp: number;
|
||||
coins: number;
|
||||
streak: { current: number; longest: number };
|
||||
/**
|
||||
* activeToday: امروز تمرین شده (آتش روشن). lost: طولِ زنجیرهای که دیروز شکست (> ۰ یعنی «یخزده»).
|
||||
*/
|
||||
streak: { current: number; longest: number; activeToday: boolean; lost: number };
|
||||
subscription: string | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { sounds } from '@/lib/sound';
|
||||
import { Brain, ShoppingBag, Home, BookOpen, UserRound, type LucideIcon } from 'lucide-react';
|
||||
import type { TabName } from '@/nav/routes';
|
||||
|
||||
@@ -18,7 +19,10 @@ export function BottomNav({ active, onChange }: { active: TabName | null; onChan
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => onChange(id)}
|
||||
onClick={() => {
|
||||
sounds.tap();
|
||||
onChange(id);
|
||||
}}
|
||||
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'}`}
|
||||
>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, type ReactNode } from 'react';
|
||||
import { AnimatePresence, motion, useDragControls } from 'motion/react';
|
||||
import { X } from 'lucide-react';
|
||||
import { useBackStep } from '@/nav/Navigator';
|
||||
|
||||
/**
|
||||
* برگهای که از پایین بالا میآید؛ با زدنِ پسزمینه، Escape، یا کشیدنِ دستگیره/سرِ برگه به پایین بسته میشود.
|
||||
@@ -18,6 +19,8 @@ export function Sheet({
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const drag = useDragControls();
|
||||
// دکمهی برگشتِ گوشی اول برگه را میبندد، نه صفحهی زیرش را.
|
||||
useBackStep(open, onClose);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose();
|
||||
|
||||
127
src/components/StreakFlame.tsx
Normal file
127
src/components/StreakFlame.tsx
Normal 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
47
src/lib/StepText.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
145
src/lib/sound.ts
145
src/lib/sound.ts
@@ -1,8 +1,14 @@
|
||||
import { KEYS, store } from './storage';
|
||||
|
||||
/**
|
||||
* صداهای کوتاهِ رابط. یک AudioContext برای کلِ اپ؛ ساختنِ یکی برای هر صدا
|
||||
* در Safari بعد از چند بار شکست میخورد.
|
||||
* صدا و لرزشِ کوتاهِ رابط، بهجا و کم: انتخابِ گزینه، جوابِ درست/غلط، پایانِ درس/جزیره، روشنماندنِ زنجیره.
|
||||
* همه با کلیدِ «صداها» در پروفایل خاموش میشوند.
|
||||
*
|
||||
* چرا روی اپِ نصبشده صدا نبود (از روی کد؛ روی گوشی دیده نشد): AudioContext اولین بار بعد از پاسخِ سرور
|
||||
* ساخته میشد، یعنی بیرون از لمسِ کاربر، و WebViewِ اندروید و Safari چنین صدایی را «پخشِ خودکار» میدانند و
|
||||
* بیصدا نگهش میدارند. حالا با اولین لمسِ صفحه ساخته و باز میشود (unlockAudioOnFirstTouch).
|
||||
*
|
||||
* صداها با Web Audio ساخته میشوند، بیفایل: بارِ اپ اضافه نمیشود.
|
||||
*/
|
||||
let ctx: AudioContext | null = null;
|
||||
|
||||
@@ -12,12 +18,33 @@ function context(): AudioContext | null {
|
||||
if (!Ctor) return null;
|
||||
try {
|
||||
ctx = new Ctor();
|
||||
} catch {
|
||||
} catch (err) {
|
||||
console.warn('AudioContext unavailable', err);
|
||||
return null;
|
||||
}
|
||||
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 {
|
||||
return store.get(KEYS.sound) !== 'off';
|
||||
}
|
||||
@@ -26,46 +53,106 @@ export function setSoundEnabled(on: boolean) {
|
||||
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();
|
||||
if (!audio || !soundEnabled()) return;
|
||||
if (audio.state === 'suspended') void audio.resume();
|
||||
const t = audio.currentTime + start;
|
||||
const osc = audio.createOscillator();
|
||||
const gain = audio.createGain();
|
||||
osc.type = 'sine';
|
||||
osc.frequency.setValueAtTime(frequency, 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);
|
||||
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.stop(t + duration + 0.02);
|
||||
}
|
||||
|
||||
/**
|
||||
* لرزشِ کوتاه کنارِ صدا، با همان کلیدِ «صدا» خاموش میشود. فقط اندروید/کروم دارد؛
|
||||
* Safariِ آیفون `vibrate` ندارد و اینجا هیچ کاری نمیکند.
|
||||
*/
|
||||
function buzz(pattern: number | number[]) {
|
||||
if (!soundEnabled() || typeof navigator.vibrate !== 'function') return;
|
||||
try {
|
||||
navigator.vibrate(pattern);
|
||||
} catch {
|
||||
/* بعضی WebViewها بیاجازه throw میکنند؛ لرزش فقط تزئینی است */
|
||||
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` دارد که مستقیم از
|
||||
* Vibratorِ گوشی میلرزاند؛ تا آن نسخه ساخته نشده `navigator.vibrate` امتحان میشود، که همهی WebViewها
|
||||
* اجرایش نمیکنند. Safariِ آیفون لرزش ندارد.
|
||||
*/
|
||||
function buzz(pattern: number | number[]) {
|
||||
if (!soundEnabled()) return;
|
||||
const bridge = (window as unknown as { TadrisinoAndroid?: AndroidHaptics }).TadrisinoAndroid;
|
||||
try {
|
||||
if (bridge?.vibrate) bridge.vibrate(JSON.stringify(Array.isArray(pattern) ? pattern : [pattern]));
|
||||
else if (typeof navigator.vibrate === 'function') navigator.vibrate(pattern);
|
||||
} 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 = {
|
||||
tick: () => tone(720, 0, 0.045, 0.04),
|
||||
/** لمسِ گزینهی آزمون: یک «تیک»ِ خیلی ریز. */
|
||||
select: () => {
|
||||
note(880, 0, 0.05, 0.025, 'triangle');
|
||||
buzz(8);
|
||||
},
|
||||
correct: () => {
|
||||
tone(660, 0, 0.12);
|
||||
tone(990, 0.1, 0.18);
|
||||
buzz(30);
|
||||
note(E5, 0, 0.12, 0.05);
|
||||
note(C6, 0.09, 0.22, 0.05);
|
||||
buzz(25);
|
||||
},
|
||||
wrong: () => {
|
||||
tone(220, 0, 0.25, 0.06);
|
||||
buzz([40, 60, 40]);
|
||||
note(311, 0, 0.16, 0.045, 'triangle');
|
||||
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),
|
||||
};
|
||||
|
||||
@@ -2,6 +2,9 @@ import { StrictMode, lazy, Suspense } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { App } from './app/App';
|
||||
import './styles/index.css';
|
||||
import { unlockAudioOnFirstTouch } from './lib/sound';
|
||||
|
||||
unlockAudioOnFirstTouch();
|
||||
|
||||
// گالریِ شکلها فقط در حالتِ توسعه؛ در build اصلاً وارد نمیشود (import.meta.env.DEV ثابت است).
|
||||
const DiagramGallery = import.meta.env.DEV
|
||||
|
||||
@@ -2,11 +2,18 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, use
|
||||
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';
|
||||
@@ -28,36 +35,92 @@ interface Nav {
|
||||
|
||||
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 }) {
|
||||
const [{ stack, direction }, setState] = useState<{ stack: Route[]; direction: NavDirection }>({ stack: [initial], direction: 'tab' });
|
||||
const [reselect, setReselect] = useState(0);
|
||||
const [steps, setSteps] = useState<(() => void)[]>([]);
|
||||
const stackRef = useRef(stack);
|
||||
stackRef.current = stack;
|
||||
const setStack = (update: (s: Route[]) => Route[], dir: NavDirection) =>
|
||||
setState((prev) => {
|
||||
const next = update(prev.stack);
|
||||
return next === prev.stack ? prev : { stack: next, direction: dir };
|
||||
});
|
||||
const stepsRef = useRef(steps);
|
||||
stepsRef.current = steps;
|
||||
|
||||
useEffect(() => {
|
||||
const onPop = () => setStack((s) => (s.length > 1 ? s.slice(0, -1) : s), 'back');
|
||||
window.addEventListener('popstate', onPop);
|
||||
return () => window.removeEventListener('popstate', onPop);
|
||||
/** ورودیهایی که خودمان بالای ورودیِ اولِ اپ گذاشتهایم. */
|
||||
const entries = useRef(0);
|
||||
/** popstateهایی که از history.goِ خودمان است، نه از دکمهی کاربر. */
|
||||
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) => {
|
||||
setStack((s) => [...s, route], 'push');
|
||||
window.history.pushState({ tadrisino: true }, '');
|
||||
setState((prev) => ({ stack: [...prev.stack, route], direction: 'push' }));
|
||||
}, []);
|
||||
|
||||
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(() => {
|
||||
// popstate خودش صفحه را برمیدارد؛ پس دو منبعِ حقیقت نداریم.
|
||||
window.history.back();
|
||||
}, []);
|
||||
// دکمهی برگشتِ داخلِ اپ همان راهِ دکمهی گوشی را میرود تا history و پشته از هم جدا نشوند.
|
||||
if (entries.current > 0) window.history.back();
|
||||
else stepBack();
|
||||
}, [stepBack]);
|
||||
|
||||
const switchTab = useCallback((tab: TabName) => {
|
||||
// زدنِ همان تبی که باز و ریشه است صفحه را از نو نمیسازد (قبلاً دوباره بار میشد)؛ مثلِ اپهای بومی به
|
||||
@@ -82,7 +145,11 @@ export function NavigatorProvider({ initial, children }: { initial: Route; child
|
||||
};
|
||||
}, [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 {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { toFa } from '@/lib/format';
|
||||
import { MathText } from '@/lib/MathText';
|
||||
import { BrandFooter } from '@/components/BrandFooter';
|
||||
import { BrandMark } from '@/components/BrandMark';
|
||||
import { StreakFlame, flameState } from '@/components/StreakFlame';
|
||||
|
||||
/**
|
||||
* خانه = خودِ مسیر، مثلِ دولینگو: همهی فصلهای پایه پشتِ هم، و جزیرهی فعلی با
|
||||
@@ -38,10 +39,23 @@ export function HomeScreen() {
|
||||
}, [home]);
|
||||
|
||||
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
|
||||
? 'دارم مسیرت رو میارم…'
|
||||
: !home.units.length
|
||||
? 'هنوز درسی برای این پایه ثبت نشده.'
|
||||
: streakNote
|
||||
? streakNote
|
||||
: cont
|
||||
? <>سلام {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]" /></>;
|
||||
@@ -69,10 +83,10 @@ export function HomeScreen() {
|
||||
type="button"
|
||||
aria-label={`${label}: ${toFa(value)}`}
|
||||
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} />
|
||||
{toFa(value)}
|
||||
{to === 'streak' ? <StreakFlame state={flame} size={13} /> : <Icon size={15} style={{ color }} fill={fill ? color : 'none'} strokeWidth={2.4} />}
|
||||
{toFa(to === 'streak' && flame === 'frozen' ? streak?.lost ?? 0 : value)}
|
||||
</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">
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ErrorState, LoadingState, PrimaryButton, Screen, ScreenBody, ScreenHead
|
||||
import { toFa } from '@/lib/format';
|
||||
import { MathText } from '@/lib/MathText';
|
||||
import { Diagram } from '@/diagram';
|
||||
import { StepText } from '@/lib/StepText';
|
||||
|
||||
/** معرفیِ درس، پیش از تمرین. */
|
||||
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">
|
||||
<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">
|
||||
<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')}
|
||||
</div>
|
||||
</div>
|
||||
@@ -63,7 +64,7 @@ export function LessonScreen({ lessonId, nodeId }: { lessonId: number; nodeId?:
|
||||
{data.example && (
|
||||
<section className="rounded-3xl border border-line bg-white p-4">
|
||||
<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')}
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { toFa } from '@/lib/format';
|
||||
import { MathText } from '@/lib/MathText';
|
||||
import { sounds } from '@/lib/sound';
|
||||
import { useGateHold } from '@/lib/useAsync';
|
||||
import { StreakCelebration } from '@/components/StreakFlame';
|
||||
|
||||
type Phase =
|
||||
| { kind: 'loading' }
|
||||
@@ -47,7 +48,10 @@ type PlayQuestion = Question & { lessonId?: 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 toast = useToast();
|
||||
|
||||
@@ -114,6 +118,7 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
|
||||
/** انتخاب فقط گزینه را آبی میکند؛ تا «بررسی» نزده، چیزی ثبت یا رو نمیشود و میشود عوضش کرد. */
|
||||
function choose(optionId: string) {
|
||||
if (!question || answer || submitting) return;
|
||||
if (optionId !== selected) sounds.select();
|
||||
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 } }));
|
||||
void refreshHome();
|
||||
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) {
|
||||
// سرور پایانِ درس/جزیره را بیقلب و بیجواب نمیپذیرد (۱۴۰۵-۰۷)؛ همان صفحهی انتظار.
|
||||
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') {
|
||||
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') {
|
||||
return (
|
||||
<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]'
|
||||
}`}>
|
||||
{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 && (
|
||||
<motion.span
|
||||
key={question.id}
|
||||
key={`xp${question.id}`}
|
||||
initial={{ opacity: 0, y: 8, scale: 0.8 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
transition={{ type: 'spring', stiffness: 400, damping: 14 }}
|
||||
@@ -413,11 +426,15 @@ function HeartsRow({ hearts }: { hearts: Hearts | null }) {
|
||||
}
|
||||
|
||||
function Result({
|
||||
result, total, correct, lessonId, nodeId, color,
|
||||
}: { result: QuizResult; total: number; correct: number; lessonId?: number; nodeId?: string; color: string }) {
|
||||
result, total, correct, lessonId, nodeId, color, streakUp,
|
||||
}: { result: QuizResult; total: number; correct: number; lessonId?: number; nodeId?: string; color: string; streakUp: { from: number; to: number } | null }) {
|
||||
const nav = useNav();
|
||||
const { companion } = useAppData();
|
||||
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 nextNode = result.nextNodeId ?? null;
|
||||
@@ -479,11 +496,11 @@ function Result({
|
||||
<span className="text-[10px] text-muted">سکه</span>
|
||||
</div>
|
||||
</div>}
|
||||
<PrimaryButton color={color} loading={loadingNext} onClick={() => void goNext()}>
|
||||
<PrimaryButton color={color} loading={loadingNext} onClick={() => then(() => void goNext())}>
|
||||
{nodeMode && nextNode ? 'بریم جزیرهی بعدی' : 'ادامه'} <ChevronLeft size={18} />
|
||||
</PrimaryButton>
|
||||
{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>
|
||||
)}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { api } from '@/api';
|
||||
import { useAsync } from '@/lib/useAsync';
|
||||
import { ErrorState, LoadingState, Screen, ScreenBody, ScreenHeader } from '@/components/ui';
|
||||
import { toFa } from '@/lib/format';
|
||||
import { useAppData } from '@/state/AppData';
|
||||
import { StreakFlame, flameState } from '@/components/StreakFlame';
|
||||
|
||||
const WEEKS = 6;
|
||||
const WEEKDAYS = ['ش', 'ی', 'د', 'س', 'چ', 'پ', 'ج'];
|
||||
@@ -30,6 +32,8 @@ const dayOfMonth = (d: Date) => new Intl.DateTimeFormat('fa-IR-u-ca-persian', {
|
||||
|
||||
export function StreakScreen() {
|
||||
const calendar = useAsync(() => api.streakCalendar(), [], { key: 'streak-calendar' });
|
||||
const { summary } = useAppData();
|
||||
const flame = flameState(summary?.streak);
|
||||
const active = new Set(calendar.data?.activeDays ?? []);
|
||||
const today = isoDay(new Date());
|
||||
const weeks = recentWeeks();
|
||||
@@ -48,16 +52,16 @@ export function StreakScreen() {
|
||||
{[
|
||||
{ label: 'زنجیرهی فعلی', value: calendar.data.currentStreak },
|
||||
{ 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">
|
||||
<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="text-xs text-muted">{s.label} (روز)</span>
|
||||
</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="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];
|
||||
|
||||
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 reached = MILESTONES.filter((m) => m <= current).pop();
|
||||
// زنجیرهی صفر: «۳ روز دیگه میشه زنجیرهی ۳ روزه» هدفی دور بود؛ قدمِ امروز کوچک و روشن گفته میشود.
|
||||
// زنجیرهی روشن و امروزِ خالی: یادآوریِ اینکه امروز چیزی مانده تا زنجیره نسوزد.
|
||||
const today = current === 0
|
||||
const today = current === 0 && lost > 0
|
||||
? `زنجیرهی ${toFa(lost)} روزهت یخ زد! امروز یه درس بزن تا دوباره آتیشش روشن بشه.`
|
||||
: current === 0
|
||||
? 'امروز فقط یه درس یا یه جزیره تموم کن تا زنجیرهت روشن بشه.'
|
||||
: !doneToday
|
||||
? `امروز هنوز تمرین نکردی؛ یه درس بزن تا زنجیرهی ${toFa(current)} روزهت نسوزه.`
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { Check, ChevronLeft, ChevronRight, Crown, Heart, Sparkles, Ticket } from 'lucide-react';
|
||||
import { api, type Plan, type SubscriptionStatus, type TutorQuota } from '@/api';
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import { Check, ChevronLeft, Crown, CreditCard, Heart, ShieldCheck, ShoppingBag, Sparkles, Ticket } from 'lucide-react';
|
||||
import { api, type Plan, type SubscriptionStatus } from '@/api';
|
||||
import { daysLeft } from '@/lib/subscription';
|
||||
import { newIdempotencyKey } from '@/api/http';
|
||||
import { BazaarPurchaseError, bazaarConsume, bazaarPending, bazaarPurchase, inBazaarApp } from '@/lib/bazaar';
|
||||
import { useAsync } from '@/lib/useAsync';
|
||||
import { Sheet } from '@/components/Sheet';
|
||||
import { useBackStep } from '@/nav/Navigator';
|
||||
import { EmptyState, ErrorState, InlineError, LoadingState, PrimaryButton, Screen, ScreenHeader } from '@/components/ui';
|
||||
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 {
|
||||
if (days === 30 || days === 31) return 'یکماهه';
|
||||
@@ -18,208 +19,241 @@ function durationLabel(days: number): string {
|
||||
return days ? `${toFa(days)} روزه` : '';
|
||||
}
|
||||
|
||||
/** «۷۹۸ هزار تومان» بهجای «۷۹۸٬۰۰۰ تومان»؛ صفرهای پشتِ هم برای بچه خواندنِ قیمت را سخت میکرد. */
|
||||
function price(value: number): string {
|
||||
return value >= 1000 && value % 1000 === 0 ? `${formatNumber(value / 1000)} هزار تومان` : formatToman(value);
|
||||
}
|
||||
|
||||
type View = 'subscription' | 'pack';
|
||||
|
||||
/**
|
||||
* فروشگاه دو محصول دارد: «اشتراک» (یک/سه/ششماهه؛ همهی فصلها، قلبِ نامحدود، سقفِ روزانهی دستیار)
|
||||
* و «بستهی سوال» (سوالِ بیانقضا از دستیار). خواستِ سینیور: پیش از فهرست دو گزینه بیاید تا شش کارت
|
||||
* یکجا کاربر را گیج نکند. از فصلِ قفل یکراست به فهرستِ اشتراک میآید (`initialView`).
|
||||
*
|
||||
* چیدمان (۱۴۰۵-۰۷، خواستِ مالک: «شلوغه و برای بچه گیجکننده»): مزیتهای اشتراک یک بار بالای فهرست
|
||||
* میآیند نه روی هر کارت (هر کارت همان سه خط و «۳ سوال در روز» را دو بار داشت)، هر پلن یک ردیفِ قابلِ
|
||||
* انتخاب است با نام و قیمت، و فقط یک دکمهی بزرگ پایینِ صفحه هست. روشِ پرداخت در برگهی پرداخت انتخاب میشود.
|
||||
*/
|
||||
export function SubscriptionScreen({ initialView }: { initialView?: View }) {
|
||||
const plans = useAsync(() => api.subscription.plans(), [], { key: 'plans' });
|
||||
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 [picked, setPicked] = useState<string | null>(null);
|
||||
const [buying, setBuying] = useState<Plan | null>(null);
|
||||
// داخلِ اپِ بازار، طبقِ قانونِ بازار، خرید فقط با پرداختِ درونبرنامهایِ خودش است؛ محصولی که
|
||||
// SKU ندارد آنجا خریدنی نیست (به زرینپال فرستاده نمیشود).
|
||||
const bazaar = inBazaarApp();
|
||||
// از فهرست، «برگشت» اول به دو گزینه برمیگردد.
|
||||
useBackStep(view !== null && !initialView, () => setView(null));
|
||||
|
||||
const credits = status.data?.questionCredits ?? quota.data?.credits ?? 0;
|
||||
const heartsUntil = status.data?.unlimitedHeartsUntil ?? null;
|
||||
const reload = () => { status.reload(); quota.reload(); };
|
||||
const list = plans.data?.plans.filter((p) => p.kind === view) ?? [];
|
||||
const monthly = plans.data?.plans.find((p) => p.kind === 'subscription' && p.durationDays <= 31);
|
||||
// پرداخت با درگاه داخلِ اپ در مرورگرِ گوشی باز میشود؛ برگشت به اپ یعنی وضعیت را دوباره بپرس.
|
||||
useEffect(() => {
|
||||
const onVisible = () => document.visibilityState === 'visible' && status.reload();
|
||||
document.addEventListener('visibilitychange', onVisible);
|
||||
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 (
|
||||
<Screen>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<ScreenHeader
|
||||
title={view === 'pack' ? 'بستهی سوال' : view === 'subscription' ? 'اشتراکِ تدریسینو' : 'فروشگاه'}
|
||||
subtitle={view === 'pack' ? 'سوالِ بیشتر از دستیار، بدونِ انقضا' : 'همهی فصلها، قلبِ نامحدود'}
|
||||
title={view === 'pack' ? 'بستهی سوال' : view === 'subscription' ? (active ? 'تمدیدِ اشتراک' : 'اشتراکِ تدریسینو') : 'فروشگاه'}
|
||||
subtitle={view === 'pack' ? 'سوالِ بیشتر از پی، بدونِ تاریخِ انقضا' : 'همهی فصلها، قلبِ نامحدود'}
|
||||
color="#ED6956"
|
||||
exact
|
||||
back={view !== null && !initialView ? () => setView(null) : true}
|
||||
mascot={{ char: 'pi', mood: 'excited', size: 60 }}
|
||||
/>
|
||||
|
||||
<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 ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<ChoiceCard
|
||||
icon={<Crown size={24} />}
|
||||
icon={<Crown size={26} />}
|
||||
tone="#FF7A3D"
|
||||
title="اشتراکِ ماهانه"
|
||||
text="همهی فصلها و جزیرهها باز، با قلبِ نامحدود"
|
||||
hint={monthly ? `از ${formatToman(monthly.price)}` : undefined}
|
||||
title={active ? 'تمدیدِ اشتراک' : 'اشتراک'}
|
||||
text="همهی فصلها باز، با قلبِ نامحدود"
|
||||
hint={cheapestSub ? `از ${price(cheapestSub.price)}` : undefined}
|
||||
onClick={() => setView('subscription')}
|
||||
/>
|
||||
<ChoiceCard
|
||||
icon={<Sparkles size={24} />}
|
||||
icon={<Sparkles size={26} />}
|
||||
tone="#9B7BFF"
|
||||
title="بستهی سوال"
|
||||
text="سوالِ بیشتر از دستیارِ هوشمند وقتی سقفِ روزت تموم شد؛ هیچوقت منقضی نمیشه"
|
||||
text="سوالِ بیشتر از پی، هیچوقت تموم نمیشه"
|
||||
onClick={() => setView('pack')}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{!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 ? (
|
||||
) : plans.loading && !plans.data ? (
|
||||
<LoadingState text="دارم فهرست رو میارم…" />
|
||||
) : plans.error ? (
|
||||
) : plans.error && !plans.data ? (
|
||||
<ErrorState error={plans.error} onRetry={plans.reload} />
|
||||
) : !list.length ? (
|
||||
<EmptyState text="فعلاً چیزی برای خرید تعریف نشده" />
|
||||
) : (
|
||||
list.map((plan) => (
|
||||
<PackCard
|
||||
key={plan.id}
|
||||
plan={plan}
|
||||
monthlyPrice={monthly?.price}
|
||||
// اشتراک در بازار هنوز 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)} سوالِ اول از سقفِ روزانهته؛ بعدش از اعتبارِ بسته کم میشه.
|
||||
<>
|
||||
{view === 'subscription' ? (
|
||||
<Perks items={selected?.features.length ? selected.features : ['همهی فصلها و جزیرهها باز', 'قلبِ نامحدود']} />
|
||||
) : (
|
||||
<p className="px-1 text-center text-sm leading-7 text-body">
|
||||
هر روز اول از سوالهای رایگانِ همون روز خرج میشه، بعد از بسته.
|
||||
</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>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
function ChoiceCard({ icon, tone, title, text, hint, onClick }: { icon: ReactNode; tone: string; title: string; text: string; hint?: string; onClick: () => void }) {
|
||||
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="min-w-0 flex-1">
|
||||
<span className="block text-base font-black text-ink">{title}</span>
|
||||
<span className="mt-0.5 block text-xs leading-6 text-muted">{text}</span>
|
||||
{hint && <span className="mt-1 block text-xs font-black" style={{ color: tone }}>{hint}</span>}
|
||||
<span className="block text-lg font-black text-ink">{title}</span>
|
||||
<span className="mt-0.5 block text-sm leading-6 text-body">{text}</span>
|
||||
{hint && <span className="mt-1 block text-sm font-black" style={{ color: tone }}>{hint}</span>}
|
||||
</span>
|
||||
<ChevronLeft size={18} className="flex-shrink-0 text-faint" />
|
||||
<ChevronLeft size={20} className="flex-shrink-0" style={{ color: tone }} />
|
||||
</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;
|
||||
}) {
|
||||
const days = status?.active ? daysLeft(status.expiresAt) : null;
|
||||
/**
|
||||
* وضعیتِ کاربر بالای فروشگاه. مشترک: یک کارتِ واضح با روزهای مانده. بیاشتراک: هیچ کارتی (دو گزینهی زیر
|
||||
* خودشان حرف را میزنند)، مگر سوالِ خریدهشده یا قلبِ هدیه داشته باشد. تا وضعیت نرسیده جای خالیِ هماندازه
|
||||
* میآید، نه «نداری» که برای مشترک غلط بود.
|
||||
*/
|
||||
function StatusCard({ status, loading }: { status: SubscriptionStatus | null; loading: boolean }) {
|
||||
if (loading) return <div className="h-[104px] animate-pulse rounded-3xl bg-soft" />;
|
||||
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 (
|
||||
<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">
|
||||
<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>
|
||||
<div className="flex-1">
|
||||
<div className="text-xs font-bold text-muted">اشتراکِ تو</div>
|
||||
<div className="text-base font-black text-ink">
|
||||
{statusLoading ? <Placeholder className="h-5 w-20" /> : status?.active ? <>{status.planTitle ?? 'فعال'}{days !== null && <span className="text-sm font-bold text-body"> · {toFa(days)} روز مونده</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="min-w-0 flex-1">
|
||||
<div className="text-base font-black">اشتراکت فعاله</div>
|
||||
<div className="text-sm text-white/85">{status.planTitle ?? 'اشتراک'}</div>
|
||||
</div>
|
||||
</div>
|
||||
{quotaLoading && <Placeholder className="h-10 w-14" />}
|
||||
{quota && (
|
||||
<div className="text-left text-xs leading-6 text-muted">
|
||||
سوالِ امروز<br />
|
||||
<b className="text-base text-ink">{toFa(quota.remainingQuota)}</b> از {toFa(status?.aiDailyQuota ?? quota.freeDaily)}
|
||||
{days !== null && (
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-black leading-none">{toFa(days)}</div>
|
||||
<div className="mt-1 text-xs text-white/85">روز مونده</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">
|
||||
<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>
|
||||
{(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 className="mt-3 h-2.5 overflow-hidden rounded-full bg-black/15">
|
||||
<div className="h-full rounded-full bg-white" style={{ width: `${percent}%` }} />
|
||||
</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 }) {
|
||||
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';
|
||||
function Pill({ icon, text }: { icon: ReactNode; text: string }) {
|
||||
return (
|
||||
<div className="relative pt-3">
|
||||
{plan.badge && (
|
||||
<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>
|
||||
)}
|
||||
<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>
|
||||
<p className={`mt-0.5 text-xs ${tone}`}>{isPack ? 'از دستیارِ هوشمند، بدونِ تاریخِ انقضا' : durationLabel(plan.durationDays)}</p>
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="text-base font-black">{formatToman(plan.price)}</p>
|
||||
{months > 1 && !isPack && <p className={`text-[11px] ${tone}`}>ماهی {formatToman(Math.round(plan.price / months / 1000) * 1000)}</p>}
|
||||
{saving > 0 && <p className={`mt-0.5 text-[11px] font-black ${featured ? 'text-white' : 'text-mint'}`}>{toFa(saving)}٪ ارزانتر</p>}
|
||||
</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>
|
||||
<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">
|
||||
{icon} {text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** مزیتهای اشتراک، یک بار. */
|
||||
function Perks({ items }: { items: string[] }) {
|
||||
return (
|
||||
<ul className="flex flex-col gap-2.5 rounded-3xl bg-[#FFF4EC] p-4">
|
||||
{items.map((f) => (
|
||||
<li key={f} className="flex items-center gap-2.5 text-[15px] font-bold text-ink">
|
||||
<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>
|
||||
{f}
|
||||
</li>
|
||||
))}
|
||||
</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
|
||||
type="button"
|
||||
onClick={onBuy}
|
||||
disabled={!available}
|
||||
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'}`}
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -229,11 +263,16 @@ function PackCard({ plan, monthlyPrice, available, onBuy }: { plan: Plan; monthl
|
||||
* Safariِ آیفون مسدود میکند؛ برای همین جابهجاییِ همین صفحه است.
|
||||
*/
|
||||
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 [showCode, setShowCode] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [done, setDone] = useState<'code' | 'bazaar' | null>(null);
|
||||
const [done, setDone] = useState<'code' | 'bazaar' | 'gateway' | null>(null);
|
||||
|
||||
/**
|
||||
* داخلِ اپِ بازار: سفارش (با کلیدِ ثابتِ همین SKU) → خرید در بازار → سنجشِ سرور → مصرفِ خرید.
|
||||
@@ -272,7 +311,7 @@ function CheckoutSheet({ plan, bazaar, onClose, onPaid }: { plan: Plan | null; b
|
||||
if (!plan || busy) return;
|
||||
setBusy(true);
|
||||
setError('');
|
||||
if (bazaar) {
|
||||
if (canBazaar && method === 'bazaar') {
|
||||
try {
|
||||
if (await payWithBazaar(plan)) setDone('bazaar');
|
||||
} catch (err) {
|
||||
@@ -295,6 +334,8 @@ function CheckoutSheet({ plan, bazaar, onClose, onPaid }: { plan: Plan | null; b
|
||||
// فقط http(s): یک «javascript:» یا «data:» از پاسخِ API نباید با assign اجرا شود.
|
||||
if (!/^https?:\/\//i.test(order.paymentUrl)) throw new Error('paymentUrl is not http(s)');
|
||||
window.location.assign(order.paymentUrl);
|
||||
// داخلِ اپ آدرسِ درگاه در مرورگرِ گوشی باز میشود و این صفحه همینجا میماند؛ دکمه نباید تا ابد بچرخد.
|
||||
if (bazaar) setTimeout(() => { setBusy(false); setDone('gateway'); }, 1500);
|
||||
} catch (err) {
|
||||
setError(friendlyError(err, 'اتصال به درگاه انجام نشد، دوباره امتحان کن'));
|
||||
setBusy(false);
|
||||
@@ -308,26 +349,36 @@ function CheckoutSheet({ plan, bazaar, onClose, onPaid }: { plan: Plan | null; b
|
||||
setShowCode(false);
|
||||
setError('');
|
||||
setDone(null);
|
||||
setMethod('gateway');
|
||||
};
|
||||
|
||||
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 ? (
|
||||
<>
|
||||
<p className="mb-5 text-sm leading-7 text-muted">
|
||||
{done === 'code' ? 'کدِ تخفیفت کلِ مبلغ رو پوشوند و همین الان به حسابت اضافه شد.' : 'پرداختت در بازار تأیید شد و به حسابت اضافه شد.'}
|
||||
{done === 'gateway'
|
||||
? 'صفحهی پرداخت در مرورگرِ گوشی باز شد. بعد از پرداخت برگرد همینجا؛ خریدت خودش فعال میشه.'
|
||||
: done === 'code' ? 'کدِ تخفیفت کلِ مبلغ رو پوشوند و همین الان به حسابت اضافه شد.' : 'پرداختت در بازار تأیید شد و به حسابت اضافه شد.'}
|
||||
</p>
|
||||
<PrimaryButton onClick={close}>عالیه!</PrimaryButton>
|
||||
<PrimaryButton onClick={close}>{done === 'gateway' ? 'باشه' : 'عالیه!'}</PrimaryButton>
|
||||
</>
|
||||
) : plan && (
|
||||
<>
|
||||
<div className="mb-4 flex items-center justify-between rounded-2xl bg-soft px-4 py-3">
|
||||
<span className="text-sm text-body">مبلغ</span>
|
||||
<span className="text-base font-black text-ink">{formatToman(plan.price)}</span>
|
||||
<div className="mb-4 flex items-center justify-between rounded-2xl bg-soft px-4 py-3.5">
|
||||
<span className="text-sm font-bold text-body">مبلغ</span>
|
||||
<span className="text-lg font-black text-ink">{price(plan.price)}</span>
|
||||
</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">
|
||||
<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
|
||||
value={code}
|
||||
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"
|
||||
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>
|
||||
) : (
|
||||
<button type="button" onClick={() => setShowCode(true)} className="mb-4 flex items-center gap-1.5 text-sm font-bold text-coral">
|
||||
<Ticket size={15} /> کدِ تخفیف دارم
|
||||
</button>
|
||||
)}
|
||||
))}
|
||||
{error && <div className="mb-4"><InlineError>{error}</InlineError></div>}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -123,3 +123,13 @@
|
||||
/* نوارِ انتظارِ میانِ دو صفحه (MainApp → HoldBar). */
|
||||
@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; }
|
||||
|
||||
/* شعلهی زنجیره (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; }
|
||||
|
||||
Reference in New Issue
Block a user