forked from Tadrisino/MathApp_frontend
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:
@@ -68,6 +68,8 @@ export interface HomeUnit {
|
|||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
blurb: string;
|
blurb: string;
|
||||||
|
/** این کاربر برای بازیِ این فصل اشتراک لازم دارد (فقط فصلِ اولِ هر پایه رایگان است). */
|
||||||
|
requiresSubscription: boolean;
|
||||||
nodes: HomeNode[];
|
nodes: HomeNode[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,7 +261,7 @@ export interface Plan {
|
|||||||
badge: string;
|
badge: string;
|
||||||
/** سوال در روز از دستیار برای مشترکِ این پلن (سرور: Plan.ai_daily_quota). */
|
/** سوال در روز از دستیار برای مشترکِ این پلن (سرور: Plan.ai_daily_quota). */
|
||||||
aiDailyQuota: number;
|
aiDailyQuota: number;
|
||||||
/** «pack» بستهی سوال است (از ۱۴۰۵-۰۷)؛ «subscription» اشتراکِ زمانیِ قدیمی. */
|
/** «pack» بستهی سوال؛ «subscription» اشتراکِ زمانی که همهی فصلها را باز میکند. */
|
||||||
kind: 'pack' | 'subscription';
|
kind: 'pack' | 'subscription';
|
||||||
/** بسته: سوالهایی که به اعتبار اضافه میشود. */
|
/** بسته: سوالهایی که به اعتبار اضافه میشود. */
|
||||||
questionCredits: number;
|
questionCredits: number;
|
||||||
@@ -276,8 +278,14 @@ export interface PlansResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SubscriptionStatus {
|
export interface SubscriptionStatus {
|
||||||
/** اشتراکِ زمانیِ قدیمی (ماهانه/سالانه)، نه بسته. */
|
/** اشتراکِ زمانیِ فعال (یک/سه/ششماهه)، نه بسته. */
|
||||||
active: boolean;
|
active: boolean;
|
||||||
|
/** نامِ پلنِ فعال؛ null بیاشتراک. */
|
||||||
|
planTitle: string | null;
|
||||||
|
/** طولِ دورهی پلنِ فعال به روز؛ مخرجِ نوارِ «چقدر مانده». */
|
||||||
|
planDurationDays: number | null;
|
||||||
|
/** سقفِ سوالِ روزانهی همین کاربر از دستیار. */
|
||||||
|
aiDailyQuota: number;
|
||||||
planId: string | null;
|
planId: string | null;
|
||||||
expiresAt: string | null;
|
expiresAt: string | null;
|
||||||
/** سوالهای خریدهشدهی باقیمانده. */
|
/** سوالهای خریدهشدهی باقیمانده. */
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ function RouteView({ route, onSignedOut }: { route: Route } & Pick<Props, 'onSig
|
|||||||
case 'home': return <HomeScreen />;
|
case 'home': return <HomeScreen />;
|
||||||
case 'lessons': return <LessonsScreen />;
|
case 'lessons': return <LessonsScreen />;
|
||||||
case 'ask': return <AskScreen lessonId={route.lessonId} questionId={route.questionId} prompt={route.prompt} />;
|
case 'ask': return <AskScreen lessonId={route.lessonId} questionId={route.questionId} prompt={route.prompt} />;
|
||||||
case 'subscription': return <SubscriptionScreen />;
|
case 'subscription': return <SubscriptionScreen key={route.view ?? ''} initialView={route.view} />;
|
||||||
case 'profile': return <ProfileScreen onSignedOut={onSignedOut} />;
|
case 'profile': return <ProfileScreen onSignedOut={onSignedOut} />;
|
||||||
case 'unit': return <UnitScreen unitId={route.unitId} />;
|
case 'unit': return <UnitScreen unitId={route.unitId} />;
|
||||||
case 'lesson': return <LessonScreen lessonId={route.lessonId} nodeId={route.nodeId} />;
|
case 'lesson': return <LessonScreen lessonId={route.lessonId} nodeId={route.nodeId} />;
|
||||||
|
|||||||
13
src/components/BrandFooter.tsx
Normal file
13
src/components/BrandFooter.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { toFa } from '@/lib/format';
|
||||||
|
import { BrandMark } from '@/components/BrandMark';
|
||||||
|
|
||||||
|
/** امضای تدریسینو پایینِ صفحهها: نشان، نام و حقِ نشر، کوچک و کمرنگ. */
|
||||||
|
export function BrandFooter() {
|
||||||
|
return (
|
||||||
|
<footer className="flex flex-col items-center gap-1 pb-4 pt-3 text-center">
|
||||||
|
<BrandMark size={24} tone="dark" />
|
||||||
|
<span className="text-[10px] text-muted">ریاضی، قدمبهقدم، از روی کتابِ درسی</span>
|
||||||
|
<span className="text-[10px] text-faint">© {toFa(1405)} تدریسینو · همهی حقوق محفوظ است</span>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
src/components/BrandMark.tsx
Normal file
23
src/components/BrandMark.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* نشانِ تدریسینو: یک کاشیِ گرد با «π» و جرقهی کوچکِ چشمکزن، کنارِ نامِ تدریسینو.
|
||||||
|
* خودِ SVG است (بیفایلِ تصویر)، پس در هر اندازه تیز است و چیزی دانلود نمیشود.
|
||||||
|
*/
|
||||||
|
export function BrandMark({ size = 28, tone = 'light', withName = true }: { size?: number; tone?: 'light' | 'dark'; withName?: boolean }) {
|
||||||
|
const ink = tone === 'light' ? '#fff' : '#3C3F8F';
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1.5" aria-label="تدریسینو">
|
||||||
|
<svg width={size} height={size} viewBox="0 0 32 32" aria-hidden="true">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bm-g" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stopColor="#FF9A5C" />
|
||||||
|
<stop offset="1" stopColor="#ED6956" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect x="1" y="1" width="30" height="30" rx="9" fill="url(#bm-g)" stroke={tone === 'light' ? 'rgba(255,255,255,.55)' : 'none'} />
|
||||||
|
<path d="M9 11.5h14M12.5 11.5v10M19 11.5v8.2c0 1.2.7 1.8 1.8 1.8" stroke="#fff" strokeWidth="2.6" strokeLinecap="round" fill="none" />
|
||||||
|
<path className="spark" d="M25 4.5l.9 1.9 1.9.9-1.9.9-.9 1.9-.9-1.9-1.9-.9 1.9-.9z" fill="#FFD23F" />
|
||||||
|
</svg>
|
||||||
|
{withName && <span className="font-black tracking-tight" style={{ color: ink, fontSize: size * 0.62 }}>تدریسینو</span>}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
src/lib/subscription.ts
Normal file
5
src/lib/subscription.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
/** چند روز تا پایانِ اشتراک مانده (رو به بالا)؛ null برای اشتراکِ بیتاریخ (هدیهی نامحدود). */
|
||||||
|
export function daysLeft(expiresAt: string | null): number | null {
|
||||||
|
if (!expiresAt) return null;
|
||||||
|
return Math.max(0, Math.ceil((new Date(expiresAt).getTime() - Date.now()) / 86_400_000));
|
||||||
|
}
|
||||||
@@ -3,7 +3,8 @@ export type Route =
|
|||||||
| { name: 'home' }
|
| { name: 'home' }
|
||||||
| { name: 'lessons' }
|
| { name: 'lessons' }
|
||||||
| { name: 'ask'; lessonId?: number; questionId?: string; prompt?: string }
|
| { name: 'ask'; lessonId?: number; questionId?: string; prompt?: string }
|
||||||
| { name: 'subscription' }
|
/** view: مستقیم به فهرستِ اشتراکها یا بستهها (مثلاً از فصلِ قفل)؛ بیآن انتخابِ دوگزینهای. */
|
||||||
|
| { name: 'subscription'; view?: 'subscription' | 'pack' }
|
||||||
| { name: 'profile' }
|
| { name: 'profile' }
|
||||||
| { name: 'unit'; unitId: string }
|
| { name: 'unit'; unitId: string }
|
||||||
| { name: 'lesson'; lessonId: number; nodeId?: string }
|
| { name: 'lesson'; lessonId: number; nodeId?: string }
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Bell, ChevronLeft, Coins, Flame, Hand, PartyPopper, Star } from 'lucide-react';
|
import { Bell, ChevronLeft, Coins, Crown, Flame, Hand, PartyPopper, Star } from 'lucide-react';
|
||||||
import type { HomeNode } from '@/api';
|
import type { HomeNode } from '@/api';
|
||||||
import { useAppData } from '@/state/AppData';
|
import { useAppData } from '@/state/AppData';
|
||||||
import { findContinue, unitColor, unitProgress } from '@/state/curriculum';
|
import { findContinue, unitColor, unitProgress } from '@/state/curriculum';
|
||||||
@@ -11,6 +11,8 @@ import { EmptyState, ErrorState, LoadingState, Screen, Sep } from '@/components/
|
|||||||
import { onWhite, shade } from '@/lib/color';
|
import { onWhite, shade } from '@/lib/color';
|
||||||
import { toFa } from '@/lib/format';
|
import { toFa } from '@/lib/format';
|
||||||
import { MathText } from '@/lib/MathText';
|
import { MathText } from '@/lib/MathText';
|
||||||
|
import { BrandFooter } from '@/components/BrandFooter';
|
||||||
|
import { BrandMark } from '@/components/BrandMark';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* خانه = خودِ مسیر، مثلِ دولینگو: همهی فصلهای پایه پشتِ هم، و جزیرهی فعلی با
|
* خانه = خودِ مسیر، مثلِ دولینگو: همهی فصلهای پایه پشتِ هم، و جزیرهی فعلی با
|
||||||
@@ -20,6 +22,8 @@ import { MathText } from '@/lib/MathText';
|
|||||||
export function HomeScreen() {
|
export function HomeScreen() {
|
||||||
const { profile, gradeTitle, summary, home, homeError, companion, unreadCount, refreshHome } = useAppData();
|
const { profile, gradeTitle, summary, home, homeError, companion, unreadCount, refreshHome } = useAppData();
|
||||||
const nav = useNav();
|
const nav = useNav();
|
||||||
|
// فصلِ غیرِ اول بیاشتراک: زدنِ جزیره یکراست فهرستِ اشتراکها را باز میکند.
|
||||||
|
const paywall = () => nav.push({ name: 'subscription', view: 'subscription' });
|
||||||
const [openNode, setOpenNode] = useState<{ node: HomeNode; color: string } | null>(null);
|
const [openNode, setOpenNode] = useState<{ node: HomeNode; color: string } | null>(null);
|
||||||
const currentRef = useRef<HTMLDivElement>(null);
|
const currentRef = useRef<HTMLDivElement>(null);
|
||||||
const scrolled = useRef(false);
|
const scrolled = useRef(false);
|
||||||
@@ -52,6 +56,7 @@ export function HomeScreen() {
|
|||||||
<Screen>
|
<Screen>
|
||||||
<header className="relative z-20 flex-shrink-0 rounded-b-[28px] bg-coral px-4 pb-4 pt-safe text-white shadow-md shadow-coral/20">
|
<header className="relative z-20 flex-shrink-0 rounded-b-[28px] bg-coral px-4 pb-4 pt-safe text-white shadow-md shadow-coral/20">
|
||||||
<div className="flex items-center gap-2 pt-4">
|
<div className="flex items-center gap-2 pt-4">
|
||||||
|
<BrandMark size={26} withName={false} />
|
||||||
{gradeTitle && (
|
{gradeTitle && (
|
||||||
<button type="button" onClick={() => nav.switchTab('profile')} className="hit rounded-full bg-white/20 px-3 py-1.5 text-xs font-black active:scale-95">
|
<button type="button" onClick={() => nav.switchTab('profile')} className="hit rounded-full bg-white/20 px-3 py-1.5 text-xs font-black active:scale-95">
|
||||||
{gradeTitle}
|
{gradeTitle}
|
||||||
@@ -111,6 +116,7 @@ export function HomeScreen() {
|
|||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="text-[11px] font-bold text-white/80">
|
<div className="text-[11px] font-bold text-white/80">
|
||||||
فصلِ {toFa(i + 1)}<Sep />{toFa(p.done)} از {toFa(p.total)} جزیره
|
فصلِ {toFa(i + 1)}<Sep />{toFa(p.done)} از {toFa(p.total)} جزیره
|
||||||
|
{unit.requiresSubscription && <span className="mr-1.5 inline-flex h-5 items-center gap-1 rounded-full bg-white/25 px-2 align-middle leading-none"><Crown size={11} /> با اشتراک</span>}
|
||||||
{isCurrentUnit && <span className="mr-1.5 inline-flex h-5 items-center rounded-full bg-white/25 px-2 align-middle leading-none">الان اینجایی</span>}
|
{isCurrentUnit && <span className="mr-1.5 inline-flex h-5 items-center rounded-full bg-white/25 px-2 align-middle leading-none">الان اینجایی</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5 truncate text-[15px] font-black">{unit.title.replace(/^فصل\s*\S+\s*[::]\s*/, '')}</div>
|
<div className="mt-0.5 truncate text-[15px] font-black">{unit.title.replace(/^فصل\s*\S+\s*[::]\s*/, '')}</div>
|
||||||
@@ -123,13 +129,14 @@ export function HomeScreen() {
|
|||||||
nodes={unit.nodes}
|
nodes={unit.nodes}
|
||||||
color={color}
|
color={color}
|
||||||
currentId={isCurrentUnit ? cont!.node.id : null}
|
currentId={isCurrentUnit ? cont!.node.id : null}
|
||||||
onOpen={(node) => setOpenNode({ node, color })}
|
onOpen={(node) => (unit.requiresSubscription ? paywall() : setOpenNode({ node, color }))}
|
||||||
onStart={(node) => nav.push({ name: 'quiz', nodeId: node.id })}
|
onStart={(node) => (unit.requiresSubscription ? paywall() : nav.push({ name: 'quiz', nodeId: node.id }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
<BrandFooter />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { motion } from 'motion/react';
|
import { motion } from 'motion/react';
|
||||||
import { Brain, Check, ChevronDown, Lock, PartyPopper, Play, Sparkles } from 'lucide-react';
|
import { Brain, Check, ChevronDown, Crown, Lock, PartyPopper, Play, Sparkles } from 'lucide-react';
|
||||||
import { api, type HomeNode, type HomeUnit } from '@/api';
|
import { api, type HomeNode, type HomeUnit } from '@/api';
|
||||||
import { useAppData } from '@/state/AppData';
|
import { useAppData } from '@/state/AppData';
|
||||||
import { findContinue, unitColor, unitProgress } from '@/state/curriculum';
|
import { findContinue, unitColor, unitProgress } from '@/state/curriculum';
|
||||||
@@ -115,6 +115,8 @@ function UnitCard({
|
|||||||
const complete = p.total > 0 && p.done === p.total;
|
const complete = p.total > 0 && p.done === p.total;
|
||||||
const untouched = unit.nodes.every((n) => n.status === 'locked');
|
const untouched = unit.nodes.every((n) => n.status === 'locked');
|
||||||
const Icon = iconFor(unit.nodes[0]?.icon, index);
|
const Icon = iconFor(unit.nodes[0]?.icon, index);
|
||||||
|
const nav = useNav();
|
||||||
|
const premium = unit.requiresSubscription;
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 12 }}
|
initial={{ opacity: 0, y: 12 }}
|
||||||
@@ -123,7 +125,7 @@ function UnitCard({
|
|||||||
className="overflow-hidden rounded-2xl bg-white"
|
className="overflow-hidden rounded-2xl bg-white"
|
||||||
style={{ border: `2px solid ${open ? color : '#e9e6f7'}`, boxShadow: open ? `0 4px 0 ${color}40` : '0 2px 0 #e9e6f7' }}
|
style={{ border: `2px solid ${open ? color : '#e9e6f7'}`, boxShadow: open ? `0 4px 0 ${color}40` : '0 2px 0 #e9e6f7' }}
|
||||||
>
|
>
|
||||||
<button type="button" onClick={onToggle} aria-expanded={open} className="flex w-full items-center gap-3 px-3.5 py-3.5 text-right">
|
<button type="button" onClick={premium ? () => nav.push({ name: 'subscription', view: 'subscription' }) : onToggle} aria-expanded={open} className="flex w-full items-center gap-3 px-3.5 py-3.5 text-right">
|
||||||
<span
|
<span
|
||||||
className="flex h-[52px] w-[52px] flex-shrink-0 items-center justify-center rounded-2xl"
|
className="flex h-[52px] w-[52px] flex-shrink-0 items-center justify-center rounded-2xl"
|
||||||
style={{ backgroundColor: complete ? color : `${color}22`, color: complete ? '#fff' : color, opacity: untouched ? 0.55 : 1 }}
|
style={{ backgroundColor: complete ? color : `${color}22`, color: complete ? '#fff' : color, opacity: untouched ? 0.55 : 1 }}
|
||||||
@@ -133,6 +135,7 @@ function UnitCard({
|
|||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
<span className="block text-[11px] font-bold" style={{ color }}>
|
<span className="block text-[11px] font-bold" style={{ color }}>
|
||||||
{complete ? <><PartyPopper size={12} className="mb-0.5 ml-1 inline" />تموم شد</> : `${toFa(p.total)} جزیره`}
|
{complete ? <><PartyPopper size={12} className="mb-0.5 ml-1 inline" />تموم شد</> : `${toFa(p.total)} جزیره`}
|
||||||
|
{premium && <span className="mr-1.5 inline-flex items-center gap-0.5 rounded-full bg-[#FFF4D6] px-1.5 text-[10px] text-[#A86A00]"><Crown size={10} /> با اشتراک</span>}
|
||||||
</span>
|
</span>
|
||||||
<span className="block text-sm font-black leading-6 text-ink"><MathText text={unit.title} /></span>
|
<span className="block text-sm font-black leading-6 text-ink"><MathText text={unit.title} /></span>
|
||||||
<span className="mt-1 flex items-center gap-2">
|
<span className="mt-1 flex items-center gap-2">
|
||||||
@@ -140,9 +143,9 @@ function UnitCard({
|
|||||||
<span className="text-[11px] text-muted">{toFa(p.done)}/{toFa(p.total)}</span>
|
<span className="text-[11px] text-muted">{toFa(p.done)}/{toFa(p.total)}</span>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown size={18} className={`flex-shrink-0 text-muted transition-transform ${open ? 'rotate-180' : ''}`} />
|
{premium ? <Crown size={18} className="flex-shrink-0 text-[#E0A200]" /> : <ChevronDown size={18} className={`flex-shrink-0 text-muted transition-transform ${open ? 'rotate-180' : ''}`} />}
|
||||||
</button>
|
</button>
|
||||||
{open && (
|
{open && !premium && (
|
||||||
<ul className="flex flex-col gap-1.5 border-t border-line bg-[#fbfaff] px-3 py-3">
|
<ul className="flex flex-col gap-1.5 border-t border-line bg-[#fbfaff] px-3 py-3">
|
||||||
{unit.nodes.map((node, ni) => (
|
{unit.nodes.map((node, ni) => (
|
||||||
<NodeRow
|
<NodeRow
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, type ReactNode } from 'react';
|
import { useState, type ReactNode } from 'react';
|
||||||
import {
|
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,
|
FileText, Pencil, Repeat, Sparkles, Star, Trash2, Trophy, Volume2, VolumeX,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { api } from '@/api';
|
import { api } from '@/api';
|
||||||
@@ -15,7 +15,10 @@ import { BirthDatePicker, ageOf, formatBirthDate } from '@/components/BirthDateP
|
|||||||
import { InlineError, PrimaryButton, Screen, SecondaryButton } from '@/components/ui';
|
import { InlineError, PrimaryButton, Screen, SecondaryButton } from '@/components/ui';
|
||||||
import { friendlyError } from '@/lib/errors';
|
import { friendlyError } from '@/lib/errors';
|
||||||
import { setSoundEnabled, soundEnabled } from '@/lib/sound';
|
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;
|
type Editing = 'name' | 'grade' | 'birth' | 'logout' | 'switch' | 'delete' | null;
|
||||||
|
|
||||||
@@ -68,6 +71,8 @@ export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 px-4 py-5">
|
<div className="flex flex-col gap-4 px-4 py-5">
|
||||||
|
<SubscriptionCard onOpen={() => nav.switchTab('subscription')} />
|
||||||
|
|
||||||
<Menu>
|
<Menu>
|
||||||
<Row icon={GraduationCap} label="پایهی تحصیلی" value={gradeTitle} onClick={() => setEditing('grade')} />
|
<Row icon={GraduationCap} label="پایهی تحصیلی" value={gradeTitle} onClick={() => setEditing('grade')} />
|
||||||
<Row
|
<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">
|
<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} /> حذفِ حساب
|
<Trash2 size={14} /> حذفِ حساب
|
||||||
</button>
|
</button>
|
||||||
|
<BrandFooter />
|
||||||
</div>
|
</div>
|
||||||
</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 }) {
|
function Menu({ children }: { children: ReactNode }) {
|
||||||
return <div className="divide-y divide-line overflow-hidden rounded-3xl border border-line bg-white">{children}</div>;
|
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 && 'اول از این حساب خارج میشی، بعد با شمارهی دیگه وارد شو. '}
|
{switching && 'اول از این حساب خارج میشی، بعد با شمارهی دیگه وارد شو. '}
|
||||||
هر وقت خواستی با همین شماره دوباره وارد شو؛ پیشرفتت سرِ جاشه.
|
هر وقت خواستی با همین شماره دوباره وارد شو؛ پیشرفتت سرِ جاشه.
|
||||||
</p>
|
</p>
|
||||||
|
<p className="mb-5 -mt-3 text-xs text-muted">ممنون که با تدریسینو یاد میگیری 💛</p>
|
||||||
<div className="flex flex-col gap-2.5">
|
<div className="flex flex-col gap-2.5">
|
||||||
<SecondaryButton onClick={onClose}>نه، میمونم</SecondaryButton>
|
<SecondaryButton onClick={onClose}>نه، میمونم</SecondaryButton>
|
||||||
<PrimaryButton color={switching ? undefined : '#ef4444'} loading={busy} onClick={() => void logout()}>
|
<PrimaryButton color={switching ? undefined : '#ef4444'} loading={busy} onClick={() => void logout()}>
|
||||||
|
|||||||
@@ -95,7 +95,12 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
|
|||||||
if (h && !h.isUnlimited && h.currentHearts <= 0) setPhase({ kind: 'noHearts', nextRefillAt: h.nextRefillAt, fullRefillAt: h.fullRefillAt });
|
if (h && !h.isUnlimited && h.currentHearts <= 0) setPhase({ kind: 'noHearts', nextRefillAt: h.nextRefillAt, fullRefillAt: h.fullRefillAt });
|
||||||
else setPhase({ kind: 'playing' });
|
else setPhase({ kind: 'playing' });
|
||||||
})
|
})
|
||||||
.catch((error: unknown) => alive && setPhase({ kind: 'error', error }));
|
.catch((error: unknown) => {
|
||||||
|
if (!alive) return;
|
||||||
|
// فصلِ غیرِ اول بیاشتراک (۴۰۲)؛ صفحهی خطا معنا ندارد، کاربر یکراست به اشتراکها میرود.
|
||||||
|
if (error instanceof ApiError && error.code === 'subscription_required') nav.replace({ name: 'subscription', view: 'subscription' });
|
||||||
|
else setPhase({ kind: 'error', error });
|
||||||
|
});
|
||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,21 +1,35 @@
|
|||||||
import { motion } from 'motion/react';
|
import { motion } from 'motion/react';
|
||||||
import { SeyarMascot } from '@/mascots/SeyarMascot';
|
import { SeyarMascot } from '@/mascots/SeyarMascot';
|
||||||
|
import { BrandMark } from '@/components/BrandMark';
|
||||||
|
|
||||||
/** تا وقتی نشستِ قبلی بررسی میشود نمایش داده میشود. */
|
/** نمادهای ریاضی که آرام از پایینِ صفحه بالا میروند؛ فقط CSS (transform/opacity)، بیبارِ اضافه. */
|
||||||
|
const GLYPHS = [
|
||||||
|
{ c: 'π', x: 12, d: 0 }, { c: '+', x: 28, d: 2.6 }, { c: '√', x: 46, d: 1.2 },
|
||||||
|
{ c: '÷', x: 64, d: 3.8 }, { c: '∑', x: 80, d: 0.6 }, { c: '×', x: 90, d: 4.6 },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** تا وقتی نشستِ قبلی بررسی میشود نمایش داده میشود؛ عمداً تأخیرِ ساختگی ندارد. */
|
||||||
export function SplashScreen() {
|
export function SplashScreen() {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col items-center justify-center bg-rose">
|
<div className="relative flex h-full flex-col items-center justify-center overflow-hidden bg-rose">
|
||||||
|
<div aria-hidden="true" className="pointer-events-none absolute inset-x-0 bottom-0 h-1/2">
|
||||||
|
{GLYPHS.map((g) => (
|
||||||
|
<span key={g.c} className="rise absolute bottom-0 text-3xl font-black text-white/60" style={{ left: `${g.x}%`, animationDelay: `${g.d}s` }}>
|
||||||
|
{g.c}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ scale: 0.5, opacity: 0 }}
|
initial={{ scale: 0.5, opacity: 0 }}
|
||||||
animate={{ scale: 1, opacity: 1 }}
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
transition={{ type: 'spring', stiffness: 220, damping: 18 }}
|
transition={{ type: 'spring', stiffness: 220, damping: 18 }}
|
||||||
className="flex flex-col items-center gap-4"
|
className="relative flex flex-col items-center gap-4"
|
||||||
>
|
>
|
||||||
<div className="rounded-[32px] bg-white/15 p-6 backdrop-blur-sm">
|
<div className="rounded-[32px] bg-white/15 p-6 backdrop-blur-sm">
|
||||||
<SeyarMascot char="sigma" mood="happy" size={130} />
|
<SeyarMascot char="sigma" mood="happy" size={130} />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-3xl font-black text-white">تدریسینو</h1>
|
<BrandMark size={44} />
|
||||||
<p className="text-sm text-white/75">ریاضی، قدمبهقدم، با همراههات</p>
|
<p className="text-sm text-white/80">ریاضی، قدمبهقدم، از روی کتابِ درسی</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
<motion.div
|
<motion.div
|
||||||
animate={{ rotate: 360 }}
|
animate={{ rotate: 360 }}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState, type ReactNode } from 'react';
|
||||||
import { Check, Crown, Heart, Sparkles, Ticket } from 'lucide-react';
|
import { Check, ChevronLeft, ChevronRight, Crown, Heart, Sparkles, Ticket } from 'lucide-react';
|
||||||
import { api, type Plan } from '@/api';
|
import { api, type Plan, type SubscriptionStatus, type TutorQuota } from '@/api';
|
||||||
|
import { daysLeft } from '@/lib/subscription';
|
||||||
import { newIdempotencyKey } from '@/api/http';
|
import { newIdempotencyKey } from '@/api/http';
|
||||||
import { BazaarPurchaseError, bazaarConsume, bazaarPending, bazaarPurchase, inBazaarApp } from '@/lib/bazaar';
|
import { BazaarPurchaseError, bazaarConsume, bazaarPending, bazaarPurchase, inBazaarApp } from '@/lib/bazaar';
|
||||||
import { useAsync } from '@/lib/useAsync';
|
import { useAsync } from '@/lib/useAsync';
|
||||||
@@ -13,73 +14,97 @@ function durationLabel(days: number): string {
|
|||||||
if (days === 30 || days === 31) return 'یکماهه';
|
if (days === 30 || days === 31) return 'یکماهه';
|
||||||
if (days >= 360 && days <= 370) return 'یکساله';
|
if (days >= 360 && days <= 370) return 'یکساله';
|
||||||
if (days === 90) return 'سهماهه';
|
if (days === 90) return 'سهماهه';
|
||||||
|
if (days === 180) return 'ششماهه';
|
||||||
return days ? `${toFa(days)} روزه` : '';
|
return days ? `${toFa(days)} روزه` : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type View = 'subscription' | 'pack';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* «بستهی سوال» (از ۱۴۰۵-۰۷ بهجای اشتراکِ ماهانه/سالانه). هر کس به اندازهی مصرفش سوال از
|
* فروشگاه دو محصول دارد: «اشتراک» (یک/سه/ششماهه؛ همهی فصلها، قلبِ نامحدود، سقفِ روزانهی دستیار)
|
||||||
* دستیار میخرد؛ اعتبار منقضی نمیشود و هر بسته چند روز قلبِ نامحدود هم هدیه دارد. هر روز
|
* و «بستهی سوال» (سوالِ بیانقضا از دستیار). خواستِ سینیور: پیش از فهرست دو گزینه بیاید تا شش کارت
|
||||||
* اول سوالهای رایگانِ همان روز خرج میشوند، بعد اعتبار.
|
* یکجا کاربر را گیج نکند. از فصلِ قفل یکراست به فهرستِ اشتراک میآید (`initialView`).
|
||||||
*/
|
*/
|
||||||
export function SubscriptionScreen() {
|
export function SubscriptionScreen({ initialView }: { initialView?: View }) {
|
||||||
const plans = useAsync(() => api.subscription.plans(), []);
|
const plans = useAsync(() => api.subscription.plans(), []);
|
||||||
const status = useAsync(() => api.subscription.status(), []);
|
const status = useAsync(() => api.subscription.status(), []);
|
||||||
const quota = useAsync(() => api.ai.quota(), []);
|
const quota = useAsync(() => api.ai.quota(), []);
|
||||||
|
const [view, setView] = useState<View | null>(initialView ?? null);
|
||||||
const [buying, setBuying] = useState<Plan | null>(null);
|
const [buying, setBuying] = useState<Plan | null>(null);
|
||||||
// داخلِ اپِ بازار، طبقِ قانونِ بازار، خرید فقط با پرداختِ درونبرنامهایِ خودش است؛ بستهای که
|
// داخلِ اپِ بازار، طبقِ قانونِ بازار، خرید فقط با پرداختِ درونبرنامهایِ خودش است؛ محصولی که
|
||||||
// SKU ندارد آنجا خریدنی نیست (به زرینپال فرستاده نمیشود).
|
// SKU ندارد آنجا خریدنی نیست (به زرینپال فرستاده نمیشود).
|
||||||
const bazaar = inBazaarApp();
|
const bazaar = inBazaarApp();
|
||||||
|
|
||||||
const credits = status.data?.questionCredits ?? quota.data?.credits ?? 0;
|
const credits = status.data?.questionCredits ?? quota.data?.credits ?? 0;
|
||||||
const heartsUntil = status.data?.unlimitedHeartsUntil ?? null;
|
const heartsUntil = status.data?.unlimitedHeartsUntil ?? null;
|
||||||
const reload = () => { status.reload(); quota.reload(); };
|
const reload = () => { status.reload(); quota.reload(); };
|
||||||
|
const list = plans.data?.plans.filter((p) => p.kind === view) ?? [];
|
||||||
|
const monthly = plans.data?.plans.find((p) => p.kind === 'subscription' && p.durationDays <= 31);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Screen>
|
<Screen>
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<ScreenHeader title="بستهی سوال" subtitle="هر وقت لازم داشتی، سوال بخر" color="#ED6956" exact mascot={{ char: 'pi', mood: 'excited', size: 60 }} />
|
<ScreenHeader
|
||||||
|
title={view === 'pack' ? 'بستهی سوال' : view === 'subscription' ? 'اشتراکِ تدریسینو' : 'فروشگاه'}
|
||||||
|
subtitle={view === 'pack' ? 'سوالِ بیشتر از دستیار، بدونِ انقضا' : 'همهی فصلها، قلبِ نامحدود'}
|
||||||
|
color="#ED6956"
|
||||||
|
exact
|
||||||
|
mascot={{ char: 'pi', mood: 'excited', size: 60 }}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 px-4 py-5">
|
<div className="flex flex-col gap-4 px-4 py-5">
|
||||||
<div className="rounded-3xl border border-line bg-white p-4">
|
<StatusCard status={status.data ?? null} credits={credits} heartsUntil={heartsUntil} quota={quota.data ?? null} />
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-2xl bg-[#F5F1FF] text-violet"><Sparkles size={22} /></span>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="text-xs font-bold text-muted">اعتبارِ تو</div>
|
|
||||||
<div className="text-2xl font-black text-ink">{toFa(credits)} <span className="text-sm font-bold text-body">سوال</span></div>
|
|
||||||
</div>
|
|
||||||
{quota.data && (
|
|
||||||
<div className="text-left text-xs leading-6 text-muted">
|
|
||||||
رایگانِ امروز<br />
|
|
||||||
<b className="text-base text-ink">{toFa(quota.data.remainingQuota)}</b> از {toFa(quota.data.freeDaily)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="mt-3 border-t border-line pt-3 text-xs leading-6 text-muted">
|
|
||||||
هر روز {toFa(plans.data?.freeAiDailyQuota ?? 3)} سوالِ اول رایگانه؛ بعدش از اعتبارت کم میشه. اعتبار هیچوقت منقضی نمیشه.
|
|
||||||
</p>
|
|
||||||
{heartsUntil && (
|
|
||||||
<p className="mt-2 flex items-center gap-1.5 text-xs font-bold text-coral">
|
|
||||||
<Heart size={13} fill="#ED6956" /> قلبِ نامحدود تا {formatJalaliDate(heartsUntil)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{status.data?.active && (
|
|
||||||
<p className="mt-2 flex items-center gap-1.5 text-xs font-bold text-mint">
|
|
||||||
<Crown size={13} /> اشتراکِ قبلیت هم فعاله{status.data.expiresAt && <> تا {formatJalaliDate(status.data.expiresAt)}</>}.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
{view === null ? (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<ChoiceCard
|
||||||
|
icon={<Crown size={24} />}
|
||||||
|
tone="#FF7A3D"
|
||||||
|
title="اشتراکِ ماهانه"
|
||||||
|
text="همهی فصلها و جزیرهها باز، با قلبِ نامحدود"
|
||||||
|
hint={monthly ? `از ${formatToman(monthly.price)}` : undefined}
|
||||||
|
onClick={() => setView('subscription')}
|
||||||
|
/>
|
||||||
|
<ChoiceCard
|
||||||
|
icon={<Sparkles size={24} />}
|
||||||
|
tone="#9B7BFF"
|
||||||
|
title="بستهی سوال"
|
||||||
|
text="سوالِ بیشتر از دستیارِ هوشمند وقتی سقفِ روزت تموم شد؛ هیچوقت منقضی نمیشه"
|
||||||
|
onClick={() => setView('pack')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{!initialView && (
|
||||||
|
<button type="button" onClick={() => setView(null)} className="flex items-center gap-1 self-start text-xs font-bold text-coral">
|
||||||
|
<ChevronRight size={14} /> برگشت به انتخاب
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{plans.loading ? (
|
{plans.loading ? (
|
||||||
<LoadingState text="دارم بستهها رو میارم…" />
|
<LoadingState text="دارم فهرست رو میارم…" />
|
||||||
) : plans.error ? (
|
) : plans.error ? (
|
||||||
<ErrorState error={plans.error} onRetry={plans.reload} />
|
<ErrorState error={plans.error} onRetry={plans.reload} />
|
||||||
) : !plans.data?.plans.length ? (
|
) : !list.length ? (
|
||||||
<EmptyState text="فعلاً بستهای برای خرید تعریف نشده" />
|
<EmptyState text="فعلاً چیزی برای خرید تعریف نشده" />
|
||||||
) : (
|
) : (
|
||||||
plans.data.plans.map((plan) => (
|
list.map((plan) => (
|
||||||
<PackCard key={plan.id} plan={plan} available={!bazaar || !!plan.bazaarSku} onBuy={() => setBuying(plan)} />
|
<PackCard
|
||||||
|
key={plan.id}
|
||||||
|
plan={plan}
|
||||||
|
monthlyPrice={monthly?.price}
|
||||||
|
// اشتراک در بازار هنوز SKU و تمدیدِ آزمودهشده ندارد؛ آنجا فقط بسته خریدنی است.
|
||||||
|
available={!bazaar || (plan.kind === 'pack' && !!plan.bazaarSku)}
|
||||||
|
onBuy={() => setBuying(plan)}
|
||||||
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
{view === 'pack' && (
|
||||||
|
<p className="text-center text-xs leading-6 text-muted">
|
||||||
|
هر روز {toFa(status.data?.aiDailyQuota ?? plans.data?.freeAiDailyQuota ?? 3)} سوالِ اول از سقفِ روزانهته؛ بعدش از اعتبارِ بسته کم میشه.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<p className="pb-2 text-center text-[11px] leading-6 text-muted">
|
<p className="pb-2 text-center text-[11px] leading-6 text-muted">
|
||||||
{bazaar ? 'پرداخت از طریقِ کافهبازار انجام میشه.' : 'پرداخت از طریقِ درگاهِ امنِ زرینپال انجام میشه.'}
|
{bazaar ? 'پرداخت از طریقِ کافهبازار انجام میشه.' : 'پرداخت از طریقِ درگاهِ امنِ زرینپال انجام میشه.'}
|
||||||
@@ -92,7 +117,58 @@ export function SubscriptionScreen() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PackCard({ plan, available, onBuy }: { plan: Plan; available: boolean; onBuy: () => void }) {
|
function ChoiceCard({ icon, tone, title, text, hint, onClick }: { icon: ReactNode; tone: string; title: string; text: string; hint?: string; onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<button type="button" onClick={onClick} className="flex items-center gap-3 rounded-3xl border-2 border-line bg-white p-4 text-right active:scale-[0.99]">
|
||||||
|
<span className="flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-2xl text-white" style={{ backgroundColor: tone }}>{icon}</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block text-base font-black text-ink">{title}</span>
|
||||||
|
<span className="mt-0.5 block text-xs leading-6 text-muted">{text}</span>
|
||||||
|
{hint && <span className="mt-1 block text-xs font-black" style={{ color: tone }}>{hint}</span>}
|
||||||
|
</span>
|
||||||
|
<ChevronLeft size={18} className="flex-shrink-0 text-faint" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** وضعیتِ فعلیِ کاربر بالای فروشگاه: اشتراک و روزهای مانده، اعتبارِ سوال، قلبِ نامحدود. */
|
||||||
|
function StatusCard({ status, credits, heartsUntil, quota }: {
|
||||||
|
status: SubscriptionStatus | null; credits: number; heartsUntil: string | null; quota: TutorQuota | null;
|
||||||
|
}) {
|
||||||
|
const days = status?.active ? daysLeft(status.expiresAt) : null;
|
||||||
|
return (
|
||||||
|
<div className="rounded-3xl border border-line bg-white p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className={`flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-2xl ${status?.active ? 'bg-[#FFF4D6] text-[#E0A200]' : 'bg-soft text-muted'}`}><Crown size={22} /></span>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-xs font-bold text-muted">اشتراکِ تو</div>
|
||||||
|
<div className="text-base font-black text-ink">
|
||||||
|
{status?.active ? <>{status.planTitle ?? 'فعال'}{days !== null && <span className="text-sm font-bold text-body"> · {toFa(days)} روز مونده</span>}</> : 'نداری'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{quota && (
|
||||||
|
<div className="text-left text-xs leading-6 text-muted">
|
||||||
|
سوالِ امروز<br />
|
||||||
|
<b className="text-base text-ink">{toFa(quota.remainingQuota)}</b> از {toFa(status?.aiDailyQuota ?? quota.freeDaily)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 border-t border-line pt-3 text-xs font-bold">
|
||||||
|
<span className="flex items-center gap-1.5 text-violet"><Sparkles size={13} /> اعتبار: {toFa(credits)} سوال</span>
|
||||||
|
{(status?.active || heartsUntil) && (
|
||||||
|
<span className="flex items-center gap-1.5 text-coral">
|
||||||
|
<Heart size={13} fill="#ED6956" /> قلبِ نامحدود{!status?.active && heartsUntil && <> تا {formatJalaliDate(heartsUntil)}</>}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{status?.active && status.expiresAt && <span className="text-muted">تا {formatJalaliDate(status.expiresAt)}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PackCard({ plan, monthlyPrice, available, onBuy }: { plan: Plan; monthlyPrice?: number; available: boolean; onBuy: () => void }) {
|
||||||
|
const months = Math.round(plan.durationDays / 30);
|
||||||
|
const saving = plan.kind === 'subscription' && monthlyPrice && months > 1 ? Math.round((1 - plan.price / (monthlyPrice * months)) * 100) : 0;
|
||||||
const featured = plan.popular;
|
const featured = plan.popular;
|
||||||
const isPack = plan.kind === 'pack';
|
const isPack = plan.kind === 'pack';
|
||||||
const tone = featured ? 'text-white/80' : 'text-muted';
|
const tone = featured ? 'text-white/80' : 'text-muted';
|
||||||
@@ -105,10 +181,12 @@ function PackCard({ plan, available, onBuy }: { plan: Plan; available: boolean;
|
|||||||
<div className="mb-3 flex items-start justify-between gap-3">
|
<div className="mb-3 flex items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-2xl font-black">{isPack ? <>{toFa(plan.questionCredits)} <span className="text-base">سوال</span></> : plan.title}</h3>
|
<h3 className="text-2xl font-black">{isPack ? <>{toFa(plan.questionCredits)} <span className="text-base">سوال</span></> : plan.title}</h3>
|
||||||
<p className={`mt-0.5 text-xs ${tone}`}>{isPack ? 'از دستیارِ «پی»، بدونِ تاریخِ انقضا' : durationLabel(plan.durationDays)}</p>
|
<p className={`mt-0.5 text-xs ${tone}`}>{isPack ? 'از دستیارِ هوشمند، بدونِ تاریخِ انقضا' : durationLabel(plan.durationDays)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<p className="text-base font-black">{formatToman(plan.price)}</p>
|
<p className="text-base font-black">{formatToman(plan.price)}</p>
|
||||||
|
{months > 1 && !isPack && <p className={`text-[11px] ${tone}`}>ماهی {formatToman(Math.round(plan.price / months / 1000) * 1000)}</p>}
|
||||||
|
{saving > 0 && <p className={`mt-0.5 text-[11px] font-black ${featured ? 'text-white' : 'text-mint'}`}>{toFa(saving)}٪ ارزانتر</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ul className="mb-4 flex flex-col gap-2 text-sm">
|
<ul className="mb-4 flex flex-col gap-2 text-sm">
|
||||||
@@ -233,7 +311,7 @@ function CheckoutSheet({ plan, bazaar, onClose, onPaid }: { plan: Plan | null; b
|
|||||||
{done ? (
|
{done ? (
|
||||||
<>
|
<>
|
||||||
<p className="mb-5 text-sm leading-7 text-muted">
|
<p className="mb-5 text-sm leading-7 text-muted">
|
||||||
{done === 'code' ? 'کدِ تخفیفت کلِ مبلغ رو پوشوند و همین الان به حسابت اضافه شد.' : 'پرداختت در بازار تأیید شد و سوالها به اعتبارت اضافه شدن.'}
|
{done === 'code' ? 'کدِ تخفیفت کلِ مبلغ رو پوشوند و همین الان به حسابت اضافه شد.' : 'پرداختت در بازار تأیید شد و به حسابت اضافه شد.'}
|
||||||
</p>
|
</p>
|
||||||
<PrimaryButton onClick={close}>عالیه!</PrimaryButton>
|
<PrimaryButton onClick={close}>عالیه!</PrimaryButton>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Sparkles } from 'lucide-react';
|
import { Crown, Sparkles } from 'lucide-react';
|
||||||
import type { HomeNode } from '@/api';
|
import type { HomeNode } from '@/api';
|
||||||
import { useAppData } from '@/state/AppData';
|
import { useAppData } from '@/state/AppData';
|
||||||
import { findUnit, unitColor, unitProgress } from '@/state/curriculum';
|
import { findUnit, unitColor, unitProgress } from '@/state/curriculum';
|
||||||
@@ -14,6 +14,7 @@ import { MathText } from '@/lib/MathText';
|
|||||||
export function UnitScreen({ unitId }: { unitId: string }) {
|
export function UnitScreen({ unitId }: { unitId: string }) {
|
||||||
const { home, homeError, refreshHome, companion } = useAppData();
|
const { home, homeError, refreshHome, companion } = useAppData();
|
||||||
const nav = useNav();
|
const nav = useNav();
|
||||||
|
const paywall = () => nav.push({ name: 'subscription', view: 'subscription' });
|
||||||
const [openNode, setOpenNode] = useState<HomeNode | null>(null);
|
const [openNode, setOpenNode] = useState<HomeNode | null>(null);
|
||||||
const found = findUnit(home, unitId);
|
const found = findUnit(home, unitId);
|
||||||
|
|
||||||
@@ -58,9 +59,14 @@ export function UnitScreen({ unitId }: { unitId: string }) {
|
|||||||
nodes={unit.nodes}
|
nodes={unit.nodes}
|
||||||
color={color}
|
color={color}
|
||||||
currentId={current?.id ?? null}
|
currentId={current?.id ?? null}
|
||||||
onOpen={setOpenNode}
|
onOpen={(node) => (unit.requiresSubscription ? paywall() : setOpenNode(node))}
|
||||||
onStart={(node) => nav.push({ name: 'quiz', nodeId: node.id })}
|
onStart={(node) => (unit.requiresSubscription ? paywall() : nav.push({ name: 'quiz', nodeId: node.id }))}
|
||||||
/>
|
/>
|
||||||
|
{unit.requiresSubscription && (
|
||||||
|
<button type="button" onClick={paywall} className="mx-auto mt-6 flex items-center gap-2 rounded-2xl bg-orange px-5 py-3 text-sm font-black text-white shadow-lg shadow-orange/30">
|
||||||
|
<Crown size={16} /> این فصل با اشتراک باز میشه
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<div className="mt-6 flex flex-col items-center">
|
<div className="mt-6 flex flex-col items-center">
|
||||||
<span className="mb-1 text-[11px] font-bold text-muted">{progress.done === progress.total ? 'این فصل تموم شد!' : 'ادامه بده!'}</span>
|
<span className="mb-1 text-[11px] font-bold text-muted">{progress.done === progress.total ? 'این فصل تموم شد!' : 'ادامه بده!'}</span>
|
||||||
<span className="flex h-11 w-11 items-center justify-center rounded-2xl" style={{ backgroundColor: `${color}18` }}>
|
<span className="flex h-11 w-11 items-center justify-center rounded-2xl" style={{ backgroundColor: `${color}18` }}>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export interface NodeRef {
|
|||||||
export function findContinue(home: HomeResponse | null): NodeRef | null {
|
export function findContinue(home: HomeResponse | null): NodeRef | null {
|
||||||
if (!home) return null;
|
if (!home) return null;
|
||||||
for (let i = 0; i < home.units.length; i++) {
|
for (let i = 0; i < home.units.length; i++) {
|
||||||
|
if (home.units[i].requiresSubscription) continue;
|
||||||
const node = home.units[i].nodes.find((n) => n.status !== 'completed' && n.status !== 'locked');
|
const node = home.units[i].nodes.find((n) => n.status !== 'completed' && n.status !== 'locked');
|
||||||
if (node) return { unit: home.units[i], unitIndex: i, node };
|
if (node) return { unit: home.units[i], unitIndex: i, node };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,3 +109,13 @@
|
|||||||
/* ناحیهی لمسِ بزرگتر بدونِ تغییرِ ظاهر: دکمههای کوچکِ سربرگ (۲۸–۳۲ پیکسل) برای انگشت کوچکاند. */
|
/* ناحیهی لمسِ بزرگتر بدونِ تغییرِ ظاهر: دکمههای کوچکِ سربرگ (۲۸–۳۲ پیکسل) برای انگشت کوچکاند. */
|
||||||
.hit { position: relative; }
|
.hit { position: relative; }
|
||||||
.hit::after { content: ''; position: absolute; inset: -7px -3px; }
|
.hit::after { content: ''; position: absolute; inset: -7px -3px; }
|
||||||
|
|
||||||
|
/* نوارِ اشتراک و نشانِ تدریسینو: فقط transform/opacity تا روی GPU بماند و روانی حفظ شود. */
|
||||||
|
@keyframes bar-fill { from { transform: scaleX(0); } to { transform: scaleX(1); } }
|
||||||
|
.bar-fill { transform-origin: right; animation: bar-fill 0.9s cubic-bezier(.2,.8,.2,1) both; }
|
||||||
|
@keyframes shine { 0% { transform: translateX(120%); } 60%, 100% { transform: translateX(-220%); } }
|
||||||
|
.shine { animation: shine 3.2s ease-in-out infinite; will-change: transform; }
|
||||||
|
@keyframes spark { 0%, 100% { opacity: .25; transform: scale(.7); } 50% { opacity: 1; transform: scale(1); } }
|
||||||
|
.spark { animation: spark 2.4s ease-in-out infinite; transform-box: fill-box; transform-origin: center; }
|
||||||
|
@keyframes rise { 0% { transform: translateY(16px); opacity: 0; } 15%, 85% { opacity: .55; } 100% { transform: translateY(-110px); opacity: 0; } }
|
||||||
|
.rise { animation: rise 7s linear infinite; will-change: transform, opacity; }
|
||||||
|
|||||||
Reference in New Issue
Block a user