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