Subscription store, paywall on locked units, profile subscription bar, Tadrisino branding

- Store opens with two choices (subscription / question packs); 3- and 6-month cards
  show per-month price and saving.
- Units that need a subscription show a crown badge and open the store; a 402 from
  the quiz goes there too. «ادامه بده» skips locked units.
- Profile: subscription card with an animated days-left bar (planDurationDays).
- BrandMark (SVG) on splash, home header and footer; copyright footer; floating math
  glyphs on splash. CSS transform/opacity only; honours reduced motion.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-10 01:19:09 +03:30
parent 8879b42b1c
commit a8a5351862
15 changed files with 320 additions and 71 deletions

View File

@@ -1,6 +1,6 @@
import { useState, type ReactNode } from 'react';
import {
BarChart3, Bell, CalendarDays, Check, ChevronLeft, Coins, Flame, Gift, GraduationCap, LifeBuoy, LogOut,
BarChart3, Bell, CalendarDays, Check, ChevronLeft, Coins, Crown, Heart, Flame, Gift, GraduationCap, LifeBuoy, LogOut,
FileText, Pencil, Repeat, Sparkles, Star, Trash2, Trophy, Volume2, VolumeX,
} from 'lucide-react';
import { api } from '@/api';
@@ -15,7 +15,10 @@ import { BirthDatePicker, ageOf, formatBirthDate } from '@/components/BirthDateP
import { InlineError, PrimaryButton, Screen, SecondaryButton } from '@/components/ui';
import { friendlyError } from '@/lib/errors';
import { setSoundEnabled, soundEnabled } from '@/lib/sound';
import { digitsOnly, toFa } from '@/lib/format';
import { digitsOnly, formatJalaliDate, toFa } from '@/lib/format';
import { useAsync } from '@/lib/useAsync';
import { daysLeft } from '@/lib/subscription';
import { BrandFooter } from '@/components/BrandFooter';
type Editing = 'name' | 'grade' | 'birth' | 'logout' | 'switch' | 'delete' | null;
@@ -68,6 +71,8 @@ export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
</header>
<div className="flex flex-col gap-4 px-4 py-5">
<SubscriptionCard onOpen={() => nav.switchTab('subscription')} />
<Menu>
<Row icon={GraduationCap} label="پایه‌ی تحصیلی" value={gradeTitle} onClick={() => setEditing('grade')} />
<Row
@@ -108,6 +113,7 @@ export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
<button type="button" onClick={() => setEditing('delete')} className="flex items-center justify-center gap-2 py-2 text-xs font-bold text-red-500">
<Trash2 size={14} /> حذفِ حساب
</button>
<BrandFooter />
</div>
</div>
@@ -120,6 +126,74 @@ export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
);
}
/**
* اشتراک به‌صورتِ نوار: چند روز از کلِ دوره مانده. مخرج طولِ پلن است (planDurationDays)؛ تمدیدِ زودتر از
* موعد روزها را روی هم می‌گذارد، پس نوار در ۱۰۰٪ بسته می‌شود. زدنِ کارت فروشگاه را باز می‌کند.
*/
function SubscriptionCard({ onOpen }: { onOpen: () => void }) {
const status = useAsync(() => api.subscription.status(), []);
const s = status.data;
if (!s) return <div className="h-[132px] animate-pulse rounded-3xl bg-soft" />;
const days = s.active ? daysLeft(s.expiresAt) : 0;
const total = s.planDurationDays ?? 30;
// اشتراکِ بی‌تاریخ (هدیه) پر نشان داده می‌شود.
const percent = s.active ? (days === null ? 100 : Math.min(100, Math.max(4, Math.round((days / total) * 100)))) : 0;
const low = s.active && days !== null && days <= 5;
return (
<button
type="button"
onClick={onOpen}
className={`relative overflow-hidden rounded-3xl p-4 text-right active:scale-[0.99] ${s.active ? 'bg-gradient-to-l from-[#FF7A3D] to-[#ED6956] text-white shadow-lg shadow-orange/25' : 'border-2 border-dashed border-orange/50 bg-white text-ink'}`}
>
<div className="flex items-center gap-3">
<span className={`glyph flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-2xl ${s.active ? 'bg-white/20' : 'bg-[#FFF4D6] text-[#E0A200]'}`}><Crown size={20} /></span>
<span className="min-w-0 flex-1">
<span className={`block text-xs font-bold ${s.active ? 'text-white/80' : 'text-muted'}`}>اشتراکِ تدریسینو</span>
<span className="block text-base font-black">{s.active ? (s.planTitle ?? 'فعال') : 'اشتراک نداری'}</span>
</span>
<span className="text-left">
{s.active && days !== null ? (
<><b className="block text-2xl font-black leading-none">{toFa(days)}</b><span className="text-[10px] text-white/80">روز مونده</span></>
) : (
<ChevronLeft size={18} className={s.active ? 'text-white/80' : 'text-faint'} />
)}
</span>
</div>
<div
className={`relative mt-3 h-3 overflow-hidden rounded-full ${s.active ? 'bg-black/15' : 'bg-soft'}`}
role="progressbar"
aria-valuemin={0}
aria-valuemax={total}
aria-valuenow={days ?? total}
aria-label="روزهای ماندهٔ اشتراک"
>
{percent > 0 && (
<div className="bar-fill absolute inset-y-0 right-0 overflow-hidden rounded-full" style={{ width: `${percent}%`, backgroundColor: low ? '#FFD23F' : '#fff' }}>
<span className="shine absolute inset-y-0 w-1/3 bg-gradient-to-l from-transparent via-white/70 to-transparent" style={{ mixBlendMode: 'overlay' }} />
</div>
)}
</div>
<div className={`mt-2 flex flex-wrap justify-between gap-x-3 gap-y-1 text-[11px] ${s.active ? 'text-white/85' : 'text-muted'}`}>
{s.active ? (
<>
{s.expiresAt && <span>تا {formatJalaliDate(s.expiresAt)}{low && ' · وقتِ تمدیده'}</span>}
<span className="flex items-center gap-1"><Heart size={11} fill="#fff" /> قلبِ نامحدود · {toFa(s.aiDailyQuota)} سوال در روز</span>
</>
) : (
<span>فصلِ اولِ هر پایه رایگانه؛ با اشتراک همه‌ی فصل‌ها و قلبِ نامحدود باز می‌شه.</span>
)}
</div>
{s.questionCredits > 0 && (
<div className={`mt-1.5 flex items-center gap-1 text-[11px] font-bold ${s.active ? 'text-white/90' : 'text-violet'}`}>
<Sparkles size={11} /> {toFa(s.questionCredits)} سوالِ خریده‌شده در اعتبارت
</div>
)}
</button>
);
}
function Menu({ children }: { children: ReactNode }) {
return <div className="divide-y divide-line overflow-hidden rounded-3xl border border-line bg-white">{children}</div>;
}
@@ -273,6 +347,7 @@ function LogoutSheet({ open, switching = false, onClose, onSignedOut }: { open:
{switching && 'اول از این حساب خارج می‌شی، بعد با شماره‌ی دیگه وارد شو. '}
هر وقت خواستی با همین شماره دوباره وارد شو؛ پیشرفتت سرِ جاشه.
</p>
<p className="mb-5 -mt-3 text-xs text-muted">ممنون که با تدریسینو یاد می‌گیری 💛</p>
<div className="flex flex-col gap-2.5">
<SecondaryButton onClick={onClose}>نه، می‌مونم</SecondaryButton>
<PrimaryButton color={switching ? undefined : '#ef4444'} loading={busy} onClick={() => void logout()}>