diff --git a/android/app/src/main/java/com/tadrisino/app/MainActivity.kt b/android/app/src/main/java/com/tadrisino/app/MainActivity.kt index c9fc606..3cf064b 100644 --- a/android/app/src/main/java/com/tadrisino/app/MainActivity.kt +++ b/android/app/src/main/java/com/tadrisino/app/MainActivity.kt @@ -3,7 +3,11 @@ package com.tadrisino.app import android.annotation.SuppressLint import android.content.Intent import android.net.Uri +import android.os.Build import android.os.Bundle +import android.os.VibrationEffect +import android.os.Vibrator +import android.os.VibratorManager import android.util.Log import android.webkit.JavascriptInterface import android.webkit.WebResourceRequest @@ -16,6 +20,7 @@ import ir.cafebazaar.poolakey.Payment import ir.cafebazaar.poolakey.config.PaymentConfiguration import ir.cafebazaar.poolakey.config.SecurityCheck import ir.cafebazaar.poolakey.request.PurchaseRequest +import org.json.JSONArray import org.json.JSONObject /** @@ -54,6 +59,8 @@ class MainActivity : AppCompatActivity() { // WebView فقط آدرسِ وب را می‌خواند؛ دسترسی به فایل و content:// لازم نیست. webView.settings.allowFileAccess = false webView.settings.allowContentAccess = false + // صداهای کوتاهِ رابط (Web Audio) بعد از پاسخِ سرور پخش می‌شوند، نه درست در لحظه‌ی لمس. + webView.settings.mediaPlaybackRequiresUserGesture = false webView.webViewClient = object : WebViewClient() { // هر آدرسی بیرونِ دامنه‌ی خودمان در مرورگرِ گوشی باز می‌شود، نه داخلِ اپ؛ پس پل فقط // در صفحه‌های خودمان در دسترس است. @@ -160,6 +167,32 @@ class MainActivity : AppCompatActivity() { } } + /** + * لرزشِ کوتاهِ رابط (src/lib/sound.tsِ فرانت). `navigator.vibrate` در همه‌ی WebViewها اجرا نمی‌شود؛ + * این مستقیم از Vibratorِ گوشی است. ورودی آرایه‌ی JSON از میلی‌ثانیه‌ها: [لرزش، مکث، لرزش، …]. + */ + @JavascriptInterface + fun vibrate(patternJson: String) { + val pattern = try { + val arr = JSONArray(patternJson) + LongArray(arr.length()) { i -> arr.getLong(i).coerceIn(0, 1000) } + } catch (e: Exception) { + Log.w(TAG, "bad vibrate pattern", e) + return + } + if (pattern.isEmpty()) return + val vibrator = if (Build.VERSION.SDK_INT >= 31) { + (getSystemService(VIBRATOR_MANAGER_SERVICE) as VibratorManager).defaultVibrator + } else { + @Suppress("DEPRECATION") + getSystemService(VIBRATOR_SERVICE) as Vibrator + } + // [لرزش، مکث، …] → قالبِ اندروید که با مکث شروع می‌شود. + val timings = longArrayOf(0) + pattern + if (Build.VERSION.SDK_INT >= 26) vibrator.vibrate(VibrationEffect.createWaveform(timings, -1)) + else @Suppress("DEPRECATION") vibrator.vibrate(timings, -1) + } + @JavascriptInterface fun bazaarConsume(purchaseToken: String) { runOnUiThread { diff --git a/src/api/types.ts b/src/api/types.ts index 378c430..bf3cacd 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -46,7 +46,10 @@ export interface UserSummary { gradeId: number | null; xp: number; coins: number; - streak: { current: number; longest: number }; + /** + * activeToday: امروز تمرین شده (آتش روشن). lost: طولِ زنجیره‌ای که دیروز شکست (> ۰ یعنی «یخ‌زده»). + */ + streak: { current: number; longest: number; activeToday: boolean; lost: number }; subscription: string | null; } diff --git a/src/components/BottomNav.tsx b/src/components/BottomNav.tsx index 09a8282..4956194 100644 --- a/src/components/BottomNav.tsx +++ b/src/components/BottomNav.tsx @@ -1,3 +1,4 @@ +import { sounds } from '@/lib/sound'; import { Brain, ShoppingBag, Home, BookOpen, UserRound, type LucideIcon } from 'lucide-react'; import type { TabName } from '@/nav/routes'; @@ -18,7 +19,10 @@ export function BottomNav({ active, onChange }: { active: TabName | null; onChan ))} )} diff --git a/src/screens/StreakScreen.tsx b/src/screens/StreakScreen.tsx index 39cf128..7a529cf 100644 --- a/src/screens/StreakScreen.tsx +++ b/src/screens/StreakScreen.tsx @@ -3,6 +3,8 @@ import { api } from '@/api'; import { useAsync } from '@/lib/useAsync'; import { ErrorState, LoadingState, Screen, ScreenBody, ScreenHeader } from '@/components/ui'; import { toFa } from '@/lib/format'; +import { useAppData } from '@/state/AppData'; +import { StreakFlame, flameState } from '@/components/StreakFlame'; const WEEKS = 6; const WEEKDAYS = ['ش', 'ی', 'د', 'س', 'چ', 'پ', 'ج']; @@ -30,6 +32,8 @@ const dayOfMonth = (d: Date) => new Intl.DateTimeFormat('fa-IR-u-ca-persian', { export function StreakScreen() { const calendar = useAsync(() => api.streakCalendar(), [], { key: 'streak-calendar' }); + const { summary } = useAppData(); + const flame = flameState(summary?.streak); const active = new Set(calendar.data?.activeDays ?? []); const today = isoDay(new Date()); const weeks = recentWeeks(); @@ -48,16 +52,16 @@ export function StreakScreen() { {[ { label: 'زنجیره‌ی فعلی', value: calendar.data.currentStreak }, { label: 'بیشترین زنجیره', value: calendar.data.longestStreak }, - ].map((s) => ( + ].map((s, i) => (
- + {i === 0 ? : } {toFa(s.value)} {s.label} (روز)
))} - +
@@ -96,12 +100,14 @@ export function StreakScreen() { /** نقطه‌عطف‌های زنجیره؛ فقط پیام و نوار، هیچ پاداشِ واقعی‌ای پشتش نیست (پاداش تصمیمِ محصولی است). */ const MILESTONES = [3, 7, 14, 30, 60, 100]; -function StreakGoal({ current, doneToday }: { current: number; doneToday: boolean }) { +function StreakGoal({ current, doneToday, lost }: { current: number; doneToday: boolean; lost: number }) { const next = MILESTONES.find((m) => m > current); const reached = MILESTONES.filter((m) => m <= current).pop(); // زنجیره‌ی صفر: «۳ روز دیگه می‌شه زنجیره‌ی ۳ روزه» هدفی دور بود؛ قدمِ امروز کوچک و روشن گفته می‌شود. // زنجیره‌ی روشن و امروزِ خالی: یادآوریِ این‌که امروز چیزی مانده تا زنجیره نسوزد. - const today = current === 0 + const today = current === 0 && lost > 0 + ? `زنجیره‌ی ${toFa(lost)} روزه‌ت یخ زد! امروز یه درس بزن تا دوباره آتیشش روشن بشه.` + : current === 0 ? 'امروز فقط یه درس یا یه جزیره تموم کن تا زنجیره‌ت روشن بشه.' : !doneToday ? `امروز هنوز تمرین نکردی؛ یه درس بزن تا زنجیره‌ی ${toFa(current)} روزه‌ت نسوزه.` diff --git a/src/screens/SubscriptionScreen.tsx b/src/screens/SubscriptionScreen.tsx index 7129e68..a1eba6b 100644 --- a/src/screens/SubscriptionScreen.tsx +++ b/src/screens/SubscriptionScreen.tsx @@ -1,14 +1,15 @@ -import { useState, type ReactNode } from 'react'; -import { Check, ChevronLeft, ChevronRight, Crown, Heart, Sparkles, Ticket } from 'lucide-react'; -import { api, type Plan, type SubscriptionStatus, type TutorQuota } from '@/api'; +import { useEffect, useState, type ReactNode } from 'react'; +import { Check, ChevronLeft, Crown, CreditCard, Heart, ShieldCheck, ShoppingBag, Sparkles, Ticket } from 'lucide-react'; +import { api, type Plan, type SubscriptionStatus } from '@/api'; import { daysLeft } from '@/lib/subscription'; import { newIdempotencyKey } from '@/api/http'; import { BazaarPurchaseError, bazaarConsume, bazaarPending, bazaarPurchase, inBazaarApp } from '@/lib/bazaar'; import { useAsync } from '@/lib/useAsync'; import { Sheet } from '@/components/Sheet'; +import { useBackStep } from '@/nav/Navigator'; import { EmptyState, ErrorState, InlineError, LoadingState, PrimaryButton, Screen, ScreenHeader } from '@/components/ui'; import { friendlyError } from '@/lib/errors'; -import { formatJalaliDate, formatToman, toFa } from '@/lib/format'; +import { formatJalaliDate, formatNumber, formatToman, toFa } from '@/lib/format'; function durationLabel(days: number): string { if (days === 30 || days === 31) return 'یک‌ماهه'; @@ -18,208 +19,241 @@ function durationLabel(days: number): string { return days ? `${toFa(days)} روزه` : ''; } +/** «۷۹۸ هزار تومان» به‌جای «۷۹۸٬۰۰۰ تومان»؛ صفرهای پشتِ هم برای بچه خواندنِ قیمت را سخت می‌کرد. */ +function price(value: number): string { + return value >= 1000 && value % 1000 === 0 ? `${formatNumber(value / 1000)} هزار تومان` : formatToman(value); +} + type View = 'subscription' | 'pack'; /** * فروشگاه دو محصول دارد: «اشتراک» (یک/سه/شش‌ماهه؛ همه‌ی فصل‌ها، قلبِ نامحدود، سقفِ روزانه‌ی دستیار) * و «بسته‌ی سوال» (سوالِ بی‌انقضا از دستیار). خواستِ سینیور: پیش از فهرست دو گزینه بیاید تا شش کارت * یک‌جا کاربر را گیج نکند. از فصلِ قفل یک‌راست به فهرستِ اشتراک می‌آید (`initialView`). + * + * چیدمان (۱۴۰۵-۰۷، خواستِ مالک: «شلوغه و برای بچه گیج‌کننده»): مزیت‌های اشتراک یک بار بالای فهرست + * می‌آیند نه روی هر کارت (هر کارت همان سه خط و «۳ سوال در روز» را دو بار داشت)، هر پلن یک ردیفِ قابلِ + * انتخاب است با نام و قیمت، و فقط یک دکمه‌ی بزرگ پایینِ صفحه هست. روشِ پرداخت در برگه‌ی پرداخت انتخاب می‌شود. */ export function SubscriptionScreen({ initialView }: { initialView?: View }) { const plans = useAsync(() => api.subscription.plans(), [], { key: 'plans' }); const status = useAsync(() => api.subscription.status(), [], { key: 'subscription-status' }); - const quota = useAsync(() => api.ai.quota(), [], { key: 'ai-quota' }); const [view, setView] = useState(initialView ?? null); + const [picked, setPicked] = useState(null); const [buying, setBuying] = useState(null); - // داخلِ اپِ بازار، طبقِ قانونِ بازار، خرید فقط با پرداختِ درون‌برنامه‌ایِ خودش است؛ محصولی که - // SKU ندارد آن‌جا خریدنی نیست (به زرین‌پال فرستاده نمی‌شود). const bazaar = inBazaarApp(); + // از فهرست، «برگشت» اول به دو گزینه برمی‌گردد. + useBackStep(view !== null && !initialView, () => setView(null)); - const credits = status.data?.questionCredits ?? quota.data?.credits ?? 0; - const heartsUntil = status.data?.unlimitedHeartsUntil ?? null; - 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); + // پرداخت با درگاه داخلِ اپ در مرورگرِ گوشی باز می‌شود؛ برگشت به اپ یعنی وضعیت را دوباره بپرس. + useEffect(() => { + const onVisible = () => document.visibilityState === 'visible' && status.reload(); + document.addEventListener('visibilitychange', onVisible); + return () => document.removeEventListener('visibilitychange', onVisible); + }, [status.reload]); + + const all = plans.data?.plans ?? []; + const list = all.filter((p) => p.kind === view); + const monthly = all.find((p) => p.kind === 'subscription' && p.durationDays <= 31); + const cheapestSub = all.filter((p) => p.kind === 'subscription').sort((x, y) => x.price - y.price)[0]; + const selected = list.find((p) => p.id === picked) ?? list.find((p) => p.popular) ?? list[0]; + const active = status.data?.active ?? false; return (
setView(null) : true} mascot={{ char: 'pi', mood: 'excited', size: 60 }} />
- + {view === null && } {view === null ? (
} + icon={} tone="#FF7A3D" - title="اشتراکِ ماهانه" - text="همه‌ی فصل‌ها و جزیره‌ها باز، با قلبِ نامحدود" - hint={monthly ? `از ${formatToman(monthly.price)}` : undefined} + title={active ? 'تمدیدِ اشتراک' : 'اشتراک'} + text="همه‌ی فصل‌ها باز، با قلبِ نامحدود" + hint={cheapestSub ? `از ${price(cheapestSub.price)}` : undefined} onClick={() => setView('subscription')} /> } + icon={} tone="#9B7BFF" title="بسته‌ی سوال" - text="سوالِ بیشتر از دستیارِ هوشمند وقتی سقفِ روزت تموم شد؛ هیچ‌وقت منقضی نمی‌شه" + text="سوالِ بیشتر از پی، هیچ‌وقت تموم نمی‌شه" onClick={() => setView('pack')} />
+ ) : plans.loading && !plans.data ? ( + + ) : plans.error && !plans.data ? ( + + ) : !list.length ? ( + ) : ( <> - {!initialView && ( - - )} - {plans.loading ? ( - - ) : plans.error ? ( - - ) : !list.length ? ( - + {view === 'subscription' ? ( + ) : ( - list.map((plan) => ( - setBuying(plan)} - /> - )) - )} - {view === 'pack' && ( -

- هر روز {toFa(status.data?.aiDailyQuota ?? plans.data?.freeAiDailyQuota ?? 3)} سوالِ اول از سقفِ روزانه‌ته؛ بعدش از اعتبارِ بسته کم می‌شه. +

+ هر روز اول از سوال‌های رایگانِ همون روز خرج می‌شه، بعد از بسته.

)} +
+ {list.map((plan) => ( + setPicked(plan.id)} /> + ))} +
)} - -

- {bazaar ? 'پرداخت از طریقِ کافه‌بازار انجام می‌شه.' : 'پرداخت از طریقِ درگاهِ امنِ زرین‌پال انجام می‌شه.'} -

- setBuying(null)} onPaid={() => { setBuying(null); reload(); }} /> + {view !== null && selected && ( +
+ setBuying(selected)}> + {view === 'pack' ? `خریدِ ${toFa(selected.questionCredits)} سوال` : `خریدِ اشتراکِ ${durationLabel(selected.durationDays) || selected.title}`} + +
+ )} + + setBuying(null)} onPaid={() => { setBuying(null); status.reload(); }} />
); } function ChoiceCard({ icon, tone, title, text, hint, onClick }: { icon: ReactNode; tone: string; title: string; text: string; hint?: string; onClick: () => void }) { return ( - ); } -/** وضعیتِ فعلیِ کاربر بالای فروشگاه: اشتراک و روزهای مانده، اعتبارِ سوال، قلبِ نامحدود. */ -/** تا وضعیت نرسیده جای خالیِ هم‌اندازه نشان داده می‌شود؛ قبلاً «نداری» می‌آمد و برای مشترک غلط بود. */ -const Placeholder = ({ className }: { className: string }) => ; - -function StatusCard({ status, statusLoading, credits, heartsUntil, quota, quotaLoading }: { - status: SubscriptionStatus | null; statusLoading: boolean; credits: number; heartsUntil: string | null; quota: TutorQuota | null; quotaLoading: boolean; -}) { - const days = status?.active ? daysLeft(status.expiresAt) : null; +/** + * وضعیتِ کاربر بالای فروشگاه. مشترک: یک کارتِ واضح با روزهای مانده. بی‌اشتراک: هیچ کارتی (دو گزینه‌ی زیر + * خودشان حرف را می‌زنند)، مگر سوالِ خریده‌شده یا قلبِ هدیه داشته باشد. تا وضعیت نرسیده جای خالیِ هم‌اندازه + * می‌آید، نه «نداری» که برای مشترک غلط بود. + */ +function StatusCard({ status, loading }: { status: SubscriptionStatus | null; loading: boolean }) { + if (loading) return
; + if (!status) return null; + const days = status.active ? daysLeft(status.expiresAt) : null; + const hearts = !status.active && status.unlimitedHeartsUntil; + const extras = ( + <> + {status.questionCredits > 0 && } text={`${toFa(status.questionCredits)} سوالِ خریده‌شده`} />} + {hearts && } text={`قلبِ نامحدود تا ${formatJalaliDate(status.unlimitedHeartsUntil!)}`} />} + + ); + if (!status.active) { + return status.questionCredits > 0 || hearts ?
{extras}
: null; + } + const total = status.planDurationDays ?? 30; + const percent = days === null ? 100 : Math.min(100, Math.max(4, Math.round((days / total) * 100))); return ( -
-
- -
-
اشتراکِ تو
-
- {statusLoading ? : status?.active ? <>{status.planTitle ?? 'فعال'}{days !== null && · {toFa(days)} روز مونده} : 'نداری'} +
+
+
+ +
+
اشتراکت فعاله
+
{status.planTitle ?? 'اشتراک'}
+ {days !== null && ( +
+
{toFa(days)}
+
روز مونده
+
+ )} +
+
+
- {quotaLoading && } - {quota && ( -
- سوالِ امروز
- {toFa(quota.remainingQuota)} از {toFa(status?.aiDailyQuota ?? quota.freeDaily)} -
- )} -
-
- اعتبار: {statusLoading && quotaLoading ? : <>{toFa(credits)} سوال} - {(status?.active || heartsUntil) && ( - - قلبِ نامحدود{!status?.active && heartsUntil && <> تا {formatJalaliDate(heartsUntil)}} - - )} - {status?.active && status.expiresAt && تا {formatJalaliDate(status.expiresAt)}}
+ {(status.questionCredits > 0) &&
{extras}
}
); } -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 isPack = plan.kind === 'pack'; - const tone = featured ? 'text-white/80' : 'text-muted'; +function Pill({ icon, text }: { icon: ReactNode; text: string }) { return ( -
+ + {icon} {text} + + ); +} + +/** مزیت‌های اشتراک، یک بار. */ +function Perks({ items }: { items: string[] }) { + return ( +
    + {items.map((f) => ( +
  • + + {f} +
  • + ))} +
+ ); +} + +/** یک ردیفِ انتخابی: نام و قیمت درشت؛ فقط یک نکته‌ی کوچک (تخفیف یا هدیه)، نه پنج خطِ ریز. */ +function PlanRow({ plan, monthlyPrice, selected, onSelect }: { plan: Plan; monthlyPrice?: number; selected: boolean; onSelect: () => void }) { + const isPack = plan.kind === 'pack'; + const months = Math.round(plan.durationDays / 30); + const saving = !isPack && monthlyPrice && months > 1 ? Math.round((1 - plan.price / (monthlyPrice * months)) * 100) : 0; + const tone = isPack ? '#9B7BFF' : '#FF7A3D'; + const note = isPack + ? plan.heartsDays > 0 ? `+ ${toFa(plan.heartsDays)} روز قلبِ نامحدود هدیه` : '' + : saving > 0 ? `${toFa(saving)}٪ ارزان‌تر از ماهانه` : ''; + return ( + -
-
+ + {selected && } + + + + {isPack ? <>{toFa(plan.questionCredits)} سوال : durationLabel(plan.durationDays) || plan.title} + + {note && {note}} + + {price(plan.price)} + ); } @@ -229,11 +263,16 @@ function PackCard({ plan, monthlyPrice, available, onBuy }: { plan: Plan; monthl * Safariِ آیفون مسدود می‌کند؛ برای همین جابه‌جاییِ همین صفحه است. */ function CheckoutSheet({ plan, bazaar, onClose, onPaid }: { plan: Plan | null; bazaar: boolean; onClose: () => void; onPaid: () => void }) { + // داخلِ اپِ بازار دو راه هست (خواستِ مالک، ۱۴۰۵-۰۷): درگاهِ بانکی پیش‌فرض است، بازار فقط برای بسته‌ای که SKU دارد. + // ⚠️ قانونِ کافه‌بازار پرداختِ محتوای دیجیتال بیرون از پرداختِ درون‌برنامه‌ای را محدود می‌کند؛ این انتخاب تصمیمِ مالک است + // و با بازار هماهنگ نشده. + const canBazaar = bazaar && !!plan?.bazaarSku; + const [method, setMethod] = useState<'gateway' | 'bazaar'>('gateway'); const [code, setCode] = useState(''); const [showCode, setShowCode] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(''); - const [done, setDone] = useState<'code' | 'bazaar' | null>(null); + const [done, setDone] = useState<'code' | 'bazaar' | 'gateway' | null>(null); /** * داخلِ اپِ بازار: سفارش (با کلیدِ ثابتِ همین SKU) → خرید در بازار → سنجشِ سرور → مصرفِ خرید. @@ -272,7 +311,7 @@ function CheckoutSheet({ plan, bazaar, onClose, onPaid }: { plan: Plan | null; b if (!plan || busy) return; setBusy(true); setError(''); - if (bazaar) { + if (canBazaar && method === 'bazaar') { try { if (await payWithBazaar(plan)) setDone('bazaar'); } catch (err) { @@ -295,6 +334,8 @@ function CheckoutSheet({ plan, bazaar, onClose, onPaid }: { plan: Plan | null; b // فقط http(s): یک «javascript:» یا «data:» از پاسخِ API نباید با assign اجرا شود. if (!/^https?:\/\//i.test(order.paymentUrl)) throw new Error('paymentUrl is not http(s)'); window.location.assign(order.paymentUrl); + // داخلِ اپ آدرسِ درگاه در مرورگرِ گوشی باز می‌شود و این صفحه همین‌جا می‌ماند؛ دکمه نباید تا ابد بچرخد. + if (bazaar) setTimeout(() => { setBusy(false); setDone('gateway'); }, 1500); } catch (err) { setError(friendlyError(err, 'اتصال به درگاه انجام نشد، دوباره امتحان کن')); setBusy(false); @@ -308,26 +349,36 @@ function CheckoutSheet({ plan, bazaar, onClose, onPaid }: { plan: Plan | null; b setShowCode(false); setError(''); setDone(null); + setMethod('gateway'); }; return ( - + {done ? ( <>

- {done === 'code' ? 'کدِ تخفیفت کلِ مبلغ رو پوشوند و همین الان به حسابت اضافه شد.' : 'پرداختت در بازار تأیید شد و به حسابت اضافه شد.'} + {done === 'gateway' + ? 'صفحه‌ی پرداخت در مرورگرِ گوشی باز شد. بعد از پرداخت برگرد همین‌جا؛ خریدت خودش فعال می‌شه.' + : done === 'code' ? 'کدِ تخفیفت کلِ مبلغ رو پوشوند و همین الان به حسابت اضافه شد.' : 'پرداختت در بازار تأیید شد و به حسابت اضافه شد.'}

- عالیه! + {done === 'gateway' ? 'باشه' : 'عالیه!'} ) : plan && ( <> -
- مبلغ - {formatToman(plan.price)} +
+ مبلغ + {price(plan.price)}
- {bazaar ? null : showCode ? ( + {canBazaar && ( +
+ با چی پرداخت می‌کنی؟ + setMethod('gateway')} icon={} title="کارتِ بانکی" note="درگاهِ امنِ زرین‌پال" /> + setMethod('bazaar')} icon={} title="کافه‌بازار" note="با حسابِ بازارت" /> +
+ )} + {method === 'gateway' && (showCode ? ( ) : ( - )} + ))} {error &&
{error}
} void pay()}>پرداخت + {method === 'gateway' && ( +

+ {bazaar ? 'صفحه‌ی پرداخت در مرورگرِ گوشی باز می‌شه؛ بعدش برگرد به اپ.' : 'پرداخت در درگاهِ امنِ زرین‌پال انجام می‌شه.'} +

+ )} )} ); } + +function MethodRow({ selected, onSelect, icon, title, note }: { selected: boolean; onSelect: () => void; icon: ReactNode; title: string; note: string }) { + return ( + + ); +} diff --git a/src/styles/index.css b/src/styles/index.css index 9d45de7..5ae878c 100644 --- a/src/styles/index.css +++ b/src/styles/index.css @@ -123,3 +123,13 @@ /* نوارِ انتظارِ میانِ دو صفحه (MainApp → HoldBar). */ @keyframes hold-bar { 0% { transform: translateX(120%); } 100% { transform: translateX(-320%); } } .hold-bar { animation: hold-bar 0.9s cubic-bezier(.4,0,.2,1) infinite; will-change: transform; } + +/* شعله‌ی زنجیره (StreakFlame): دو لایه با ریتمِ متفاوت، از پایین تکان می‌خورند. */ +@keyframes flame-a { 0%, 100% { transform: scale(1, 1) skewX(0deg); } 33% { transform: scale(.97, 1.04) skewX(-2deg); } 66% { transform: scale(1.02, .97) skewX(2deg); } } +@keyframes flame-b { 0%, 100% { transform: scale(1, 1); } 50% { transform: scale(.94, 1.08); } } +.flame-outer { transform-box: fill-box; transform-origin: 50% 100%; animation: flame-a 1.4s ease-in-out infinite; } +.flame-inner { transform-box: fill-box; transform-origin: 50% 100%; animation: flame-b .9s ease-in-out infinite; } +@keyframes ice-glint { 0%, 70%, 100% { opacity: 0; } 80% { opacity: 1; } } +.ice-glint { animation: ice-glint 2.8s ease-in-out infinite; } +@keyframes spark-rise { 0% { transform: translateY(0) scale(1); opacity: 0; } 15% { opacity: 1; } 100% { transform: translateY(-180px) scale(.3); opacity: 0; } } +.spark-rise { animation: spark-rise 1.8s ease-out infinite; }