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

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

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