Files
clinicpro/assets/admin/pages/SubscriptionPage.tsx
T
hamedandClaude Opus 4.8 f33c61365d refactor(settings): unify settings sidebar across all pages
SettingsLayout rendered its own role-gated aside while the subscription page
rendered PurchaseSubscriptionSidebar, so /admin/subscription and other settings
pages (sms-wallet, ...) showed two different settings menus. Make SettingsLayout
render the shared PurchaseSubscriptionSidebar and have SubscriptionPage use
SettingsLayout too, so every settings page shows one identical menu. menuForRole
/ SETTINGS_MENU are kept for the mobile settings list (SettingsMenuPage).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 15:25:10 +03:30

538 lines
25 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 } 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 } 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<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, #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<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, 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 (
<>
<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 && <CurrentPlanCard my={my} onRenew={renewCurrentPlan} />}
<div>
<div style={{ fontSize: 22, fontWeight: 800, color: 'var(--text)' }}>انتخاب پلن اشتراک</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 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(purchaseTarget.period.price_rials)}
</div>
</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, amount_rials: purchaseTarget.period.price_rials })
}
>
{purchaseMutation.isPending
? 'در حال انتقال...'
: `پرداخت ${formatRial(purchaseTarget.period.price_rials)}`}
</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;
return (
<div
dir="ltr"
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>
{daysLeft < 3 && (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 4 }}>
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<InfoCircleRedFilled color="#d22d32" />
<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 #494cb3' : '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: '#3d4395', color: '#fff', padding: '0 16px', borderRadius: '0 10px 0 10px',
fontSize: 13, fontWeight: 500, display: 'flex', alignItems: 'center', gap: 8, zIndex: 1,
}}>
محبوب‌ترین
<SparkleIcon size={15} color="#FDD835" />
</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 pill */}
<div style={{ width: '100%', display: 'flex', justifyContent: 'flex-end' }}>
<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>
{/* 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(selectedPeriod.price_rials)}
</span>
<span style={{ fontSize: 14, color: 'var(--text)', fontWeight: 500 }}>: قیمت</span>
</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: '#3c9a4f' }} />
: <CloseCircleFilled size={22} color="#d7d7d7" />}
</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: '#5559ce' }}
>
{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" stroke="#636ed0" strokeWidth="1" strokeDasharray="6 4" />
</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: '#fff', fontWeight: 700, fontSize: 16,
fontFamily: 'inherit', cursor: selectedPeriod ? 'pointer' : 'not-allowed',
}}
>
{isCurrent ? 'تمدید اشتراک' : 'خرید اشتراک'}
</button>
)}
</div>
);
}