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

@@ -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>
)}