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:
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user