import React, { useState, useEffect, useMemo } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { CreditCardIcon } from '@heroicons/react/24/outline'; import { CheckCircleIcon } from '@heroicons/react/24/solid'; import { toast } from 'sonner'; import { api } from '../lib/api'; import type { ApiResponse } from '../lib/api'; import type { SubscriptionPlan, MySubscriptionData, SubscriptionPeriod } from '../types'; import { usePaymentConfig } from '../hooks/usePaymentConfig'; import { formatRial, formatNumber, formatDate } from '../lib/utils'; import Modal from '../components/ui/Modal'; import SettingsLayout from '../components/layout/SettingsLayout'; import { SparkleIcon, PlanCardPerson, CloseCircleFilled, GiftIcon, InfoCircleRedFilled, } from './subscriptionIcons'; // ── Constants ───────────────────────────────────────────────────────────── /** Shared feature labels (also consumed by PaymentSuccessPage). */ export const PLAN_FEATURE_LABELS: Record = { patient_records: 'پرونده بیمار', services: 'مدیریت سرویس‌ها', sms_panel: 'پنل پیامک', insurance: 'مدیریت بیمه', }; /** Feature labels used on the plan cards — wording copied from clinic-pro-tauri. */ const CARD_FEATURE_LABELS: Record = { patient_records: 'مدیریت پرونده بیمار', services: 'مدیریت سرویس ها', sms_panel: 'پنل پیامک', insurance: 'مدیریت بیمه', }; /** The plan highlighted as "most popular" in the UI (no backend flag exists); * matches clinic-pro-tauri, which flags the professional (top) plan. */ const POPULAR_PLAN_NAME = 'professional'; const PLAN_META: Record = { free: { label: 'رایگان', desc: 'مناسب برای شروع کار', tint: 'var(--surface-2)' }, basic: { label: 'پایه', desc: 'مناسب برای مطب‌های کوچک و متوسط', tint: 'var(--primary-soft)' }, professional: { label: 'حرفه‌ای', desc: 'مناسب برای کلینیک‌های بزرگ', tint: 'var(--violet-bg)' }, }; /** Gradient blur behind each plan card — matches the tauri source per plan tier. */ const PLAN_GRADIENT: Record = { free: 'radial-gradient(circle, #f5ecfd 0%, #e9eaf9 70%, transparent 100%)', basic: 'radial-gradient(circle, #e2eee5 0%, #f5edfd 60%, transparent 100%)', professional: 'linear-gradient(180deg, #f9eedd 0%, #e8e9fa 0%, rgba(255,255,255,0) 100%)', }; export const planMetaOf = (name: string) => PLAN_META[name] ?? PLAN_META.free; // ── Main Page ───────────────────────────────────────────────────────────── export default function SubscriptionPage() { const qc = useQueryClient(); const [purchaseTarget, setPurchaseTarget] = useState<{ period: SubscriptionPeriod; planName: string } | null>(null); const [selectedGateway, setSelectedGateway] = useState(''); const { data: plansData, isLoading: plansLoading } = useQuery>({ queryKey: ['subscription-plans'], queryFn: () => api.get('/api/v1/subscription/plans'), }); const { data: myData, isLoading: myLoading } = useQuery>({ queryKey: ['subscription-my'], queryFn: () => api.get('/api/v1/subscription/my'), }); const { isTestMode, gateways } = usePaymentConfig(); useEffect(() => { if (gateways.length > 0 && !gateways.some((g) => g.name === selectedGateway)) { setSelectedGateway(gateways[0].name); } }, [gateways, selectedGateway]); // Highest plan first so, under RTL, the top-tier plan sits on the right (source order). const plans = [...(plansData?.data ?? [])].sort((a, b) => b.level - a.level); const myRaw = myData?.data; const my = myRaw?.subscription ?? null; const usedTrial = myRaw?.used_trial ?? false; const trialMutation = useMutation({ mutationFn: () => api.post('/api/v1/subscription/trial', {}), onSuccess: () => { qc.invalidateQueries({ queryKey: ['subscription-my'] }); toast.success('تریال رایگان با موفقیت فعال شد'); }, onError: (err: any) => toast.error(err.message), }); const purchaseMutation = useMutation({ mutationFn: ({ period_uuid, gateway, amount_rials }: { period_uuid: string; gateway: string; amount_rials: number }) => api.post<{ data: { pay_url?: string; redirect_url?: string; payment_url?: string } }>('/api/v1/subscription-payment', { period_uuid, gateway, amount_rials, // Gateway returns here (with ?payment_uuid&status); the success page reads them. frontend_address: `${window.location.origin}/admin/subscription/success`, }), onSuccess: (res: any) => { const url = res?.data?.pay_url ?? res?.data?.redirect_url ?? res?.data?.payment_url; if (url) window.location.href = url; else toast.error('خطا در دریافت لینک پرداخت'); }, onError: (err: any) => toast.error(err.message), }); // Open the payment modal for the current plan's best-value paid period (renew). const renewCurrentPlan = () => { const current = plans.find((p) => p.name === my?.plan.name); const paid = (current?.periods ?? []).filter((p) => !p.is_trial) .sort((a, b) => a.duration_months - b.duration_months); const period = paid[paid.length - 1]; if (period) setPurchaseTarget({ period, planName: current!.name }); }; return ( <>
{/* ── Header: current plan (left) + title (right) ── */}
{!myLoading && }
انتخاب پلن اشتراک
پلن اشتراک مناسب خود را انتخاب نمایید:
{/* ── Plan cards ── */} {plansLoading ? (
{[1, 2, 3].map((i) => (
))}
) : plans.length === 0 ? (
در حال حاضر پلنی برای نمایش وجود ندارد.
) : (
{plans.map((plan) => ( 0} usedTrial={usedTrial} onPurchase={(period) => setPurchaseTarget({ period, planName: plan.name })} onTrial={() => trialMutation.mutate()} trialPending={trialMutation.isPending} /> ))}
)}
{/* ── Payment modal (gateway selection) ── */} { setPurchaseTarget(null); setSelectedGateway(gateways[0]?.name ?? ''); }} title="پرداخت اشتراک" size="sm" > {purchaseTarget && (
دوره انتخابی
{planMetaOf(purchaseTarget.planName).label} — {purchaseTarget.period.label}
{formatNumber(purchaseTarget.period.duration_months)} ماه
مبلغ قابل پرداخت
{formatRial(purchaseTarget.period.price_rials)}
{isTestMode ? (
⚠️
درگاه آزمایشی فعال است
پول واقعی کسر نخواهد شد — این تراکنش آزمایشی است
) : gateways.length === 0 ? (
در حال حاضر هیچ درگاه پرداخت فعالی وجود ندارد. لطفاً با پشتیبانی تماس بگیرید.
) : (
انتخاب درگاه پرداخت
{gateways.map((gw) => ( ))}
)}
)}
); } // ── Current plan card (365×104, ported from tauri CurrentPlanCard.jsx) ─────── function CurrentPlanCard({ my, onRenew }: { my: MySubscriptionData['subscription']; onRenew: () => void }) { const daysLeft = my?.days_remaining ?? 0; const expiresAt = my?.expires_at ?? null; return (
{my ? ( <>
پلن فعلی شما: {planMetaOf(my.plan.name).label}
{expiresAt ? (
زمان پایان اشتراک: {formatDate(expiresAt)} {daysLeft > 0 && ( ({formatNumber(daysLeft)} روز مانده) )}
) : (
بدون تاریخ انقضا
)} {daysLeft < 3 && (
{daysLeft <= 0 ? 'اشتراک شما به پایان رسیده است' : `${formatNumber(daysLeft)} روز تا پایان اشتراک`}
)} ) : ( <>
پلن فعلی شما: اشتراک ندارید
برای استفاده از امکانات یک پلن تهیه کنید
)}
); } // ── PlanCard (522px, ported from tauri PlanCard.jsx) ───────────────────────── function PlanCard({ plan, currentPlanName, currentPlanLevel, currentPlanActive, usedTrial, onPurchase, onTrial, trialPending, }: { plan: SubscriptionPlan; currentPlanName: string; currentPlanLevel: number; currentPlanActive: boolean; usedTrial: boolean; onPurchase: (period: SubscriptionPeriod) => void; onTrial: () => void; trialPending: boolean; }) { const meta = planMetaOf(plan.name); const isCurrent = plan.name === currentPlanName; const isPopular = plan.name === POPULAR_PLAN_NAME; const isFree = plan.level <= 0; const isDowngrade = currentPlanActive && (plan.level ?? 0) < currentPlanLevel; const paidPeriods = useMemo( () => [...plan.periods].filter((p) => !p.is_trial).sort((a, b) => a.duration_months - b.duration_months), [plan.periods], ); const trialPeriod = plan.periods.find((p) => p.is_trial); // Default to the longest (best-value) period, matching the source default. const [selectedUuid, setSelectedUuid] = useState(''); useEffect(() => { if (paidPeriods.length > 0 && !paidPeriods.some((p) => p.uuid === selectedUuid)) { setSelectedUuid(paidPeriods[paidPeriods.length - 1].uuid); } }, [paidPeriods, selectedUuid]); const selectedPeriod = paidPeriods.find((p) => p.uuid === selectedUuid) ?? paidPeriods[0]; const isSelected = isPopular; // the source highlights the popular card with the indigo border return (
{/* Gradient blur backdrop */}
{/* Popular badge (top-right corner) */} {isPopular && (
محبوب‌ترین
)}
پلن {meta.label}
{meta.desc}
{/* Secretaries pill */}
{formatNumber(plan.max_secretaries)} منشی
{/* Period toggle */}
{paidPeriods.length > 0 && (
{paidPeriods.map((p, idx) => { const on = p.uuid === selectedPeriod?.uuid; return ( {idx < paidPeriods.length - 1 && (
)} ); })}
)}
{/* Price */}
{!isFree && selectedPeriod && (
{formatRial(selectedPeriod.price_rials)} : قیمت
)}
{/* Features */}
{Object.entries(plan.features).map(([key, enabled]) => { const label = CARD_FEATURE_LABELS[key] ?? PLAN_FEATURE_LABELS[key] ?? key; return (
{label} {enabled ? : }
); })}
{/* Trial box (dashed) */} {!isFree && trialPeriod && !usedTrial && !isDowngrade && (
تریال {formatNumber(trialPeriod.duration_months)} ماهه رایگان
)}
{/* Buy button */} {isFree ? ( ) : isDowngrade ? ( ) : ( )}
); }