Files
clinicpro/assets/admin/pages/SubscriptionPage.tsx
T
hamed ecdefa3c24 feat(tour): add onboarding tours for various admin pages
- Integrated TourButton component into SettingsMenuPage, SkillsPage, SmsWalletPage, StaffPage, StaffSessionDetailPage, StaffTreatmentSessionsPage, SubscriptionPage, TagsSettingsPage, TreatmentCasesPage to enhance user onboarding experience.
- Created new tour definitions for appointments, clinics, staff management, financial management, and patient management, ensuring comprehensive guidance for users navigating the admin panel.
- Updated documentation to reflect the addition of tours and their implementation details.
2026-08-10 10:10:20 +03:30

610 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { CreditCardIcon, CubeIcon } 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, formatResourceLimit } from '../lib/utils';
import Modal from '../components/ui/Modal';
import SettingsLayout from '../components/layout/SettingsLayout';
import TourButton from '../components/ui/TourButton';
import {
SparkleIcon, PlanCardPerson, CloseCircleFilled, GiftIcon, InfoCircleRedFilled,
} from './subscriptionIcons';
// ── Tax helpers ───────────────────────────────────────────────────────────
// `price_rials` خالص است و مالیات رویش می‌نشیند. فیلدهای مالیاتی را بک‌اند حساب
// می‌کند؛ fallback فقط برای پاسخِ کش‌شدهٔ نسخهٔ قبلی است.
export const taxOf = (p: Pick<SubscriptionPeriod, 'tax_rials'>): number => p.tax_rials ?? 0;
export const payableOf = (p: Pick<SubscriptionPeriod, 'price_rials' | 'payable_rials'>): number =>
p.payable_rials ?? p.price_rials;
// ── Constants ─────────────────────────────────────────────────────────────
/** Shared feature labels (also consumed by PaymentSuccessPage). */
export const PLAN_FEATURE_LABELS: Record<string, string> = {
patient_records: 'پرونده بیمار',
services: 'مدیریت سرویس‌ها',
sms_panel: 'پنل پیامک',
insurance: 'مدیریت بیمه',
};
/** Feature labels used on the plan cards — wording copied from clinic-pro-tauri. */
const CARD_FEATURE_LABELS: Record<string, string> = {
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<string, { label: string; desc: string; tint: string }> = {
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<string, string> = {
free: 'radial-gradient(circle, var(--violet-bg) 0%, var(--primary-soft) 70%, transparent 100%)',
basic: 'radial-gradient(circle, var(--success-bg) 0%, var(--violet-bg) 60%, transparent 100%)',
professional: 'linear-gradient(180deg, var(--warning-bg) 0%, var(--primary-soft) 0%, transparent 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<string>('');
const { data: plansData, isLoading: plansLoading } = useQuery<ApiResponse<SubscriptionPlan[]>>({
queryKey: ['subscription-plans'],
queryFn: () => api.get('/api/v1/subscription/plans'),
});
const { data: myData, isLoading: myLoading } = useQuery<ApiResponse<MySubscriptionData>>({
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 }: { period_uuid: string; gateway: string }) =>
api.post<{ data: { pay_url?: string; redirect_url?: string; payment_url?: string } }>('/api/v1/subscription-payment', {
period_uuid, gateway,
// 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 (
<>
<SettingsLayout active="subscription">
<div
style={{ background: 'var(--surface)', minWidth: 0 }}
className="px-4 py-4 md:px-6 md:py-6 rounded-[var(--r-lg)]"
>
<div dir="ltr" style={{ width: '100%', display: 'flex', flexDirection: 'column', gap: 32, paddingTop: 8 }}>
{/* ── Header: current plan (left) + title (right) ── */}
<div style={{ display: 'flex', width: '100%', justifyContent: 'space-between', alignItems: 'center', gap: 16, flexWrap: 'wrap' }}>
{!myLoading && <div data-tour="subscription-current"><CurrentPlanCard my={my} onRenew={renewCurrentPlan} /></div>}
<div>
<div style={{ fontSize: 22, fontWeight: 800, color: 'var(--text)', display: 'flex', alignItems: 'center', gap: 4 }} data-tour="page-title">
انتخاب پلن اشتراک
<TourButton tourId="subscription" ready={!plansLoading} />
</div>
<div style={{ fontSize: 14, fontWeight: 500, color: 'var(--text-3)', marginTop: 4, textAlign: 'left' }}>
پلن اشتراک مناسب خود را انتخاب نمایید:
</div>
</div>
</div>
{/* ── Plan cards ── */}
{plansLoading ? (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(230px, 1fr))', gap: 16, width: '100%' }}>
{[1, 2, 3].map((i) => (
<div key={i} className="skeleton" style={{ height: 522, borderRadius: 10 }} />
))}
</div>
) : plans.length === 0 ? (
<div style={{ padding: '48px 0', textAlign: 'center', color: 'var(--text-3)', fontSize: 14 }}>
در حال حاضر پلنی برای نمایش وجود ندارد.
</div>
) : (
<div data-tour="subscription-plans" style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(230px, 1fr))', gap: 16, width: '100%' }}>
{plans.map((plan) => (
<PlanCard
key={plan.uuid}
plan={plan}
currentPlanName={my?.plan.name ?? 'free'}
currentPlanLevel={my?.plan.level ?? 0}
currentPlanActive={!!my && (my.days_remaining ?? 0) > 0}
usedTrial={usedTrial}
onPurchase={(period) => setPurchaseTarget({ period, planName: plan.name })}
onTrial={() => trialMutation.mutate()}
trialPending={trialMutation.isPending}
/>
))}
</div>
)}
</div>
</div>
</SettingsLayout>
{/* ── Payment modal (gateway selection) ── */}
<Modal
open={!!purchaseTarget}
onClose={() => { setPurchaseTarget(null); setSelectedGateway(gateways[0]?.name ?? ''); }}
title="پرداخت اشتراک"
size="sm"
>
{purchaseTarget && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
<div style={{
background: 'var(--surface-2)', borderRadius: 'var(--r-sm)',
padding: '14px 16px', display: 'flex', justifyContent: 'space-between', alignItems: 'center',
}}>
<div>
<div style={{ fontSize: 13, color: 'var(--text-3)', marginBottom: 2 }}>دوره انتخابی</div>
<div style={{ fontWeight: 700, fontSize: 15 }}>
{planMetaOf(purchaseTarget.planName).label} {purchaseTarget.period.label}
</div>
<div style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 1 }}>
{formatNumber(purchaseTarget.period.duration_months)} ماه
</div>
</div>
<div style={{ textAlign: 'left' }}>
<div style={{ fontSize: 12, color: 'var(--text-3)', marginBottom: 2 }}>مبلغ قابل پرداخت</div>
<div style={{ fontWeight: 800, fontSize: 20, color: 'var(--primary)' }}>
{formatRial(payableOf(purchaseTarget.period))}
</div>
</div>
</div>
{taxOf(purchaseTarget.period) > 0 && (
<div style={{
display: 'flex', flexDirection: 'column', gap: 6,
background: 'var(--surface-2)', borderRadius: 'var(--r-sm)',
padding: '10px 12px', fontSize: 13, color: 'var(--text-2)',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span>قیمت دوره</span>
<span style={{ direction: 'ltr' }}>{formatRial(purchaseTarget.period.price_rials)}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span>مالیات بر ارزش افزوده {formatNumber(purchaseTarget.period.tax_percent)}٪</span>
<span style={{ direction: 'ltr' }}>{formatRial(taxOf(purchaseTarget.period))}</span>
</div>
<div style={{
display: 'flex', justifyContent: 'space-between',
borderTop: '1px solid var(--border)', paddingTop: 6,
color: 'var(--text)', fontWeight: 700,
}}>
<span>جمع کل</span>
<span style={{ direction: 'ltr' }}>{formatRial(payableOf(purchaseTarget.period))}</span>
</div>
</div>
)}
{isTestMode ? (
<div style={{
background: 'var(--warning-bg)', border: '1px solid var(--warning)',
borderRadius: 10, padding: '12px 16px', display: 'flex', alignItems: 'center', gap: 10,
}}>
<span style={{ fontSize: 20 }}>⚠️</span>
<div>
<div style={{ fontWeight: 700, fontSize: 13.5, color: 'var(--warning)' }}>درگاه آزمایشی فعال است</div>
<div style={{ fontSize: 12, color: 'var(--warning)', marginTop: 2, opacity: 0.8 }}>پول واقعی کسر نخواهد شد این تراکنش آزمایشی است</div>
</div>
</div>
) : gateways.length === 0 ? (
<div style={{
background: 'var(--danger-bg)', border: '1px solid var(--danger)',
borderRadius: 10, padding: '12px 16px', fontSize: 13, color: 'var(--danger)',
}}>
در حال حاضر هیچ درگاه پرداخت فعالی وجود ندارد. لطفاً با پشتیبانی تماس بگیرید.
</div>
) : (
<div>
<div style={{ fontSize: 12.5, fontWeight: 600, color: 'var(--text-2)', marginBottom: 10 }}>
انتخاب درگاه پرداخت
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
{gateways.map((gw) => (
<button
key={gw.name}
onClick={() => setSelectedGateway(gw.name)}
style={{
padding: '12px 16px', borderRadius: 'var(--r-sm)', cursor: 'pointer',
border: `2px solid ${selectedGateway === gw.name ? 'var(--primary)' : 'var(--border)'}`,
background: selectedGateway === gw.name ? 'var(--primary-soft)' : 'var(--surface)',
color: selectedGateway === gw.name ? 'var(--primary-700)' : 'var(--text-2)',
fontWeight: selectedGateway === gw.name ? 700 : 500,
fontSize: 13, transition: '.14s', fontFamily: 'inherit',
display: 'flex', alignItems: 'center', gap: 8,
}}
>
<CreditCardIcon style={{ width: 17, flexShrink: 0 }} />
{gw.label}
</button>
))}
</div>
</div>
)}
<div style={{ display: 'flex', gap: 10, marginTop: 4 }}>
<button
className="btn primary"
style={{ flex: 1, height: 44 }}
disabled={purchaseMutation.isPending || (!isTestMode && (gateways.length === 0 || selectedGateway === ''))}
onClick={() =>
purchaseMutation.mutate({ period_uuid: purchaseTarget.period.uuid, gateway: selectedGateway })
}
>
{purchaseMutation.isPending
? 'در حال انتقال...'
: `پرداخت ${formatRial(payableOf(purchaseTarget.period))}`}
</button>
<button className="btn ghost" style={{ height: 44 }} onClick={() => setPurchaseTarget(null)}>
انصراف
</button>
</div>
</div>
)}
</Modal>
</>
);
}
// ── 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 (
<div
dir="rtl"
style={{
width: 365, maxWidth: '100%', minHeight: 104,
background: 'var(--surface-2)', border: '1px solid var(--border)',
borderRadius: 16, display: 'flex', flexDirection: 'column', justifyContent: 'center',
padding: '0 16px',
}}
>
{my ? (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-2)' }}>پلن فعلی شما:</span>
<span style={{ fontSize: 14, fontWeight: 700, color: 'var(--text)' }}>{planMetaOf(my.plan.name).label}</span>
</div>
{expiresAt ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 4 }}>
<span style={{ fontSize: 12, fontWeight: 500, color: 'var(--text-3)' }}>زمان پایان اشتراک:</span>
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-2)' }}>{formatDate(expiresAt)}</span>
{daysLeft > 0 && (
<span style={{ fontSize: 12, fontWeight: 500, color: 'var(--text-3)' }}>
({formatNumber(daysLeft)} روز مانده)
</span>
)}
</div>
) : (
<div style={{ marginTop: 4 }}>
<span style={{ fontSize: 12, fontWeight: 500, color: 'var(--text-3)' }}>بدون تاریخ انقضا</span>
</div>
)}
{daysLeft < 3 && (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 4 }}>
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<InfoCircleRedFilled color="var(--danger)" />
<span style={{ fontSize: 14, fontWeight: 500, color: 'var(--text)' }}>
{daysLeft <= 0 ? 'اشتراک شما به پایان رسیده است' : `${formatNumber(daysLeft)} روز تا پایان اشتراک`}
</span>
</div>
<button
onClick={onRenew}
className="btn ghost sm"
style={{ fontWeight: 600, color: 'var(--primary)', minWidth: 0, padding: '4px 12px' }}
>
تمدید اشتراک
</button>
</div>
)}
</>
) : (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-2)' }}>پلن فعلی شما:</span>
<span style={{ fontSize: 13, fontWeight: 700, color: 'var(--text)' }}>اشتراک ندارید</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', marginTop: 4 }}>
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-3)' }}>
برای استفاده از امکانات یک پلن تهیه کنید
</span>
</div>
</>
)}
</div>
);
}
// ── 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<string>('');
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 (
<div
data-testid={`plan-card-${plan.name}`}
style={{
width: '100%', minWidth: 0, height: 522, borderRadius: 10,
border: isSelected ? '2px solid var(--primary-600)' : '2px solid var(--border)',
background: 'var(--surface)', display: 'flex', flexDirection: 'column',
padding: 16, position: 'relative', overflow: 'hidden',
}}
>
{/* Gradient blur backdrop */}
<div style={{
position: 'absolute', top: -80, left: 0, width: 550, height: 160,
background: PLAN_GRADIENT[plan.name] ?? PLAN_GRADIENT.free,
opacity: 0.8, filter: 'blur(80px)', zIndex: 0, pointerEvents: 'none',
}} />
<div style={{ position: 'relative', zIndex: 1 }}>
{/* Popular badge (top-right corner) */}
{isPopular && (
<div style={{
position: 'absolute', width: 'fit-content', height: 32, top: -18, right: -20,
background: 'var(--primary-700)', color: 'var(--on-primary)', padding: '0 16px', borderRadius: '0 10px 0 10px',
fontSize: 13, fontWeight: 500, display: 'flex', alignItems: 'center', gap: 8, zIndex: 1,
}}>
محبوب‌ترین
<SparkleIcon size={15} color="var(--warning)" />
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ fontSize: 20, fontWeight: 800, color: 'var(--text)', textAlign: 'left' }}>
پلن {meta.label}
</div>
<div style={{ fontSize: 14, color: 'var(--text-2)', fontWeight: 500, textAlign: 'left', lineHeight: 1.6 }}>
{meta.desc}
</div>
{/* Secretaries + resources pills — دو سقفِ عددیِ پلن، کنار هم */}
<div style={{ width: '100%', display: 'flex', justifyContent: 'flex-end', gap: 6, flexWrap: 'wrap' }}>
<div dir="rtl" style={{
minWidth: 86, height: 32, borderRadius: 29, background: 'var(--surface-3)',
color: 'var(--text)', display: 'flex', gap: 6, alignItems: 'center', justifyContent: 'center',
padding: '0 12px',
}}>
<span style={{ fontSize: 14, fontWeight: 500, whiteSpace: 'nowrap' }}>
{formatNumber(plan.max_secretaries)} منشی
</span>
<PlanCardPerson size={18} />
</div>
<div dir="rtl" style={{
minWidth: 86, height: 32, borderRadius: 29, background: 'var(--surface-3)',
color: 'var(--text)', display: 'flex', gap: 6, alignItems: 'center', justifyContent: 'center',
padding: '0 12px',
}}>
<span style={{ fontSize: 14, fontWeight: 500, whiteSpace: 'nowrap' }}>
{formatResourceLimit(plan.max_resources)} منبع
</span>
<CubeIcon style={{ width: 17 }} />
</div>
</div>
{/* Period toggle */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: '100%', minHeight: 48 }}>
{paidPeriods.length > 0 && (
<div role="tablist" aria-label="دوره اشتراک" style={{
width: 263, maxWidth: '100%', height: 48, border: '1px solid var(--border)',
borderRadius: 8, background: 'var(--surface)', display: 'flex', alignItems: 'center', overflow: 'hidden',
}}>
{paidPeriods.map((p, idx) => {
const on = p.uuid === selectedPeriod?.uuid;
return (
<React.Fragment key={p.uuid}>
<button
role="tab" aria-selected={on}
onClick={() => setSelectedUuid(p.uuid)}
style={{
flex: 1, height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center',
background: on ? 'var(--primary-soft)' : 'transparent', cursor: 'pointer',
border: 'none', fontFamily: 'inherit',
fontSize: 14, fontWeight: 500, color: on ? 'var(--primary)' : 'var(--text)',
}}
>
{p.label}
</button>
{idx < paidPeriods.length - 1 && (
<div style={{ width: 1, alignSelf: 'stretch', background: 'var(--border)' }} />
)}
</React.Fragment>
);
})}
</div>
)}
</div>
{/* Price */}
<div style={{ minHeight: 22 }}>
{!isFree && selectedPeriod && (
<div style={{
marginBottom: 8, display: 'flex', alignItems: 'center', gap: 4,
justifyContent: 'flex-end', width: '100%',
}}>
<span style={{ fontSize: 14, color: 'var(--text)', fontWeight: 600, direction: 'ltr' }}>
{formatRial(payableOf(selectedPeriod))}
</span>
<span style={{ fontSize: 14, color: 'var(--text)', fontWeight: 500 }}>: قیمت</span>
</div>
)}
{!isFree && selectedPeriod && taxOf(selectedPeriod) > 0 && (
<div style={{
fontSize: 11, color: 'var(--text-3)', textAlign: 'left', marginTop: -4,
}}>
شامل {formatNumber(selectedPeriod.tax_percent)}٪ مالیات بر ارزش افزوده
</div>
)}
</div>
</div>
</div>
<div style={{ borderTop: '1px solid var(--border)' }} />
{/* Features */}
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
{Object.entries(plan.features).map(([key, enabled]) => {
const label = CARD_FEATURE_LABELS[key] ?? PLAN_FEATURE_LABELS[key] ?? key;
return (
<div key={key} style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', justifyContent: 'flex-end', padding: '9.6px 0', gap: 8 }}>
<span style={{ fontSize: 14, color: enabled ? 'var(--text)' : 'var(--text-3)', fontWeight: 500, textAlign: 'left' }}>
{label}
</span>
<span style={{ position: 'relative', display: 'inline-flex', width: 22, height: 22 }}>
{enabled
? <CheckCircleIcon style={{ width: 22, height: 22, color: 'var(--success)' }} />
: <CloseCircleFilled size={22} color="var(--border-2)" />}
</span>
</div>
);
})}
</div>
{/* Trial box (dashed) */}
{!isFree && trialPeriod && !usedTrial && !isDowngrade && (
<div style={{ position: 'relative', width: 268, maxWidth: '100%', height: 59, margin: '0 auto 16px' }}>
<div style={{
position: 'relative', zIndex: 1, display: 'flex', alignItems: 'center', justifyContent: 'space-between',
height: '100%', padding: '0 16px',
}}>
<button
onClick={onTrial} disabled={trialPending}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit', fontSize: 16, fontWeight: 500, color: 'var(--primary)' }}
>
{trialPending ? 'در حال فعال‌سازی...' : 'فعال سازی'}
</button>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<span style={{ fontSize: 14, fontWeight: 500, color: 'var(--text-2)' }}>
تریال {formatNumber(trialPeriod.duration_months)} ماهه رایگان
</span>
<GiftIcon />
</div>
</div>
<svg style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', pointerEvents: 'none' }} viewBox="0 0 268 59" preserveAspectRatio="none">
<rect x="0.5" y="0.5" width="267" height="58" rx="6" fill="transparent" strokeWidth="1" strokeDasharray="6 4" style={{ stroke: 'var(--primary)' }}/>
</svg>
</div>
)}
<div style={{ borderTop: '1px solid var(--border)', margin: '0 8px 16px' }} />
{/* Buy button */}
{isFree ? (
<button className="btn" style={{ marginTop: 'auto', width: '100%', height: 48, cursor: 'default' }} disabled>
{isCurrent ? 'پلن فعلی شما' : 'رایگان'}
</button>
) : isDowngrade ? (
<button className="btn" style={{ marginTop: 'auto', width: '100%', height: 48, opacity: 0.6, cursor: 'not-allowed' }} disabled>
تا پایان اشتراک فعلی قابل انتخاب نیست
</button>
) : (
<button
onClick={() => selectedPeriod && onPurchase(selectedPeriod)}
disabled={!selectedPeriod}
style={{
marginTop: 'auto', width: '100%', height: 48, borderRadius: 4, border: 'none',
background: 'var(--primary)', color: 'var(--on-primary)', fontWeight: 700, fontSize: 16,
fontFamily: 'inherit', cursor: selectedPeriod ? 'pointer' : 'not-allowed',
}}
>
{isCurrent ? 'تمدید اشتراک' : 'خرید اشتراک'}
</button>
)}
</div>
);
}