Files
clinicpro/assets/admin/pages/SubscriptionPage.tsx
T

239 lines
9.6 KiB
TypeScript

import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { CheckIcon, SparklesIcon } from '@heroicons/react/24/outline';
import { toast } from 'sonner';
import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import type { SubscriptionPlan, MySubscription, SubscriptionPeriod } from '../types';
import { formatDate, formatRial, formatNumber } from '../lib/utils';
import Modal from '../components/ui/Modal';
import PageHeader from '../components/ui/PageHeader';
const PLAN_FEATURE_LABELS: Record<string, string> = {
patient_records: 'پرونده بیمار',
services: 'مدیریت سرویس‌ها',
sms_panel: 'پنل پیامک',
};
const PLAN_DISPLAY: Record<string, { label: string; color: string }> = {
free: { label: 'رایگان', color: '#64748b' },
basic: { label: 'پایه', color: '#3b82f6' },
professional: { label: 'حرفه‌ای', color: '#8b5cf6' },
};
const GATEWAY_LABELS: Record<string, string> = { mellat: 'ملت', sep: 'سپ' };
export default function SubscriptionPage() {
const qc = useQueryClient();
const [purchaseTarget, setPurchaseTarget] = useState<SubscriptionPeriod | null>(null);
const [selectedGateway, setSelectedGateway] = useState<'mellat' | 'sep'>('mellat');
const { data: plansData, isLoading: plansLoading } = useQuery<ApiResponse<SubscriptionPlan[]>>({
queryKey: ['subscription-plans'],
queryFn: () => api.get('/api/v1/subscription/plans'),
});
const { data: myData } = useQuery<ApiResponse<MySubscription>>({
queryKey: ['subscription-my'],
queryFn: () => api.get('/api/v1/subscription/my'),
});
const plans = plansData?.data ?? [];
const my = myData?.data;
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: { payment_url: string } }>('/api/v1/subscription-payment', { period_uuid, gateway }),
onSuccess: (res: any) => {
const url = res?.data?.payment_url;
if (url) window.location.href = url;
else toast.error('خطا در دریافت لینک پرداخت');
},
onError: (err: any) => toast.error(err.message),
});
const daysTotal = my?.expires_at && my?.starts_at
? Math.round((my.expires_at - my.starts_at) / 86400)
: null;
const daysProgress = daysTotal && my?.days_remaining != null
? Math.max(0, Math.min(100, ((daysTotal - my.days_remaining) / daysTotal) * 100))
: null;
return (
<>
<PageHeader title="پنل اشتراکی" description="مدیریت اشتراک و ارتقای پنل" />
{/* وضعیت اشتراک فعلی */}
{my && (
<div className="card" style={{ marginBottom: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
<SparklesIcon style={{ width: 22, color: PLAN_DISPLAY[my.plan.name]?.color ?? '#64748b' }} />
<div>
<div style={{ fontWeight: 700, fontSize: 16 }}>
پنل {PLAN_DISPLAY[my.plan.name]?.label ?? my.plan.name}
{my.is_trial && <span className="badge amber" style={{ marginRight: 8, fontSize: 11 }}>تریال</span>}
</div>
{my.expires_at
? <div style={{ fontSize: 13, color: 'var(--text-3)' }}>
انقضا: {formatDate(my.expires_at)} {formatNumber(my.days_remaining ?? 0)} روز باقی‌مانده
</div>
: <div style={{ fontSize: 13, color: 'var(--text-3)' }}>بدون تاریخ انقضا</div>
}
</div>
{!my.used_trial && (
<button
className="btn primary sm"
style={{ marginRight: 'auto' }}
onClick={() => trialMutation.mutate()}
disabled={trialMutation.isPending}
>
{trialMutation.isPending ? 'در حال فعال‌سازی...' : 'فعال‌سازی تریال رایگان'}
</button>
)}
</div>
{daysProgress !== null && (
<div style={{ background: 'var(--surface-2)', borderRadius: 6, height: 6, overflow: 'hidden' }}>
<div style={{ width: `${daysProgress}%`, height: '100%', background: PLAN_DISPLAY[my.plan.name]?.color ?? '#3b82f6', transition: 'width .3s' }} />
</div>
)}
</div>
)}
{/* کارت‌های پلن */}
{plansLoading ? (
<div style={{ padding: 40, textAlign: 'center', color: 'var(--text-3)' }}>در حال بارگذاری...</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: 16 }}>
{plans.map((plan) => (
<PlanCard
key={plan.uuid}
plan={plan}
currentPlanLevel={my?.plan.level ?? 0}
usedTrial={my?.used_trial ?? false}
onPurchase={(period) => setPurchaseTarget(period)}
/>
))}
</div>
)}
{/* Modal پرداخت */}
<Modal
open={!!purchaseTarget}
onClose={() => setPurchaseTarget(null)}
title={`خرید ${purchaseTarget?.label ?? ''}`}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div style={{ display: 'flex', gap: 8 }}>
{(['mellat', 'sep'] as const).map((gw) => (
<button
key={gw}
className={`btn ${selectedGateway === gw ? 'primary' : ''}`}
onClick={() => setSelectedGateway(gw)}
>
{GATEWAY_LABELS[gw]}
</button>
))}
</div>
<p style={{ margin: 0, color: 'var(--text-3)', fontSize: 14 }}>
مبلغ: <b style={{ color: 'var(--text-1)' }}>{purchaseTarget ? formatRial(purchaseTarget.price_rials) : ''}</b>
</p>
<div style={{ display: 'flex', gap: 8 }}>
<button
className="btn primary"
disabled={purchaseMutation.isPending}
onClick={() =>
purchaseTarget &&
purchaseMutation.mutate({ period_uuid: purchaseTarget.uuid, gateway: selectedGateway })
}
>
{purchaseMutation.isPending ? 'در حال انتقال...' : 'پرداخت'}
</button>
<button className="btn" onClick={() => setPurchaseTarget(null)}>انصراف</button>
</div>
</div>
</Modal>
</>
);
}
function PlanCard({
plan,
currentPlanLevel,
usedTrial,
onPurchase,
}: {
plan: SubscriptionPlan;
currentPlanLevel: number;
usedTrial: boolean;
onPurchase: (period: SubscriptionPeriod) => void;
}) {
const display = PLAN_DISPLAY[plan.name] ?? { label: plan.name, color: '#64748b' };
const isCurrent = plan.level === currentPlanLevel;
const paidPeriods = plan.periods.filter((p) => !p.is_trial);
const trialPeriod = plan.periods.find((p) => p.is_trial);
return (
<div
className="card"
style={{ border: isCurrent ? `2px solid ${display.color}` : undefined, position: 'relative' }}
>
{isCurrent && (
<span className="badge green" style={{ position: 'absolute', top: 12, left: 12, fontSize: 11 }}>
پنل فعلی
</span>
)}
<div style={{ fontWeight: 700, fontSize: 18, color: display.color, marginBottom: 8 }}>
{display.label}
</div>
<div style={{ fontSize: 13, color: 'var(--text-3)', marginBottom: 12 }}>
حداکثر {plan.max_secretaries} منشی
</div>
<ul style={{ listStyle: 'none', padding: 0, margin: '0 0 16px', display: 'flex', flexDirection: 'column', gap: 6 }}>
{Object.entries(plan.features).map(([key, enabled]) => (
<li key={key} style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13 }}>
<CheckIcon style={{ width: 14, color: enabled ? '#22c55e' : '#94a3b8' }} />
<span style={{ color: enabled ? 'var(--text-1)' : 'var(--text-3)' }}>
{PLAN_FEATURE_LABELS[key] ?? key}
</span>
</li>
))}
</ul>
{paidPeriods.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{paidPeriods.map((period) => (
<div key={period.uuid} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<span style={{ fontSize: 13 }}>{period.label}</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontWeight: 600, fontSize: 13 }}>{formatRial(period.price_rials)}</span>
<button
className="btn primary sm"
onClick={() => onPurchase(period)}
>
{isCurrent ? 'تمدید' : 'خرید'}
</button>
</div>
</div>
))}
</div>
)}
{trialPeriod && !usedTrial && plan.level > 0 && (
<div style={{ marginTop: 12, fontSize: 12, color: 'var(--text-3)', borderTop: '1px solid var(--border)', paddingTop: 10 }}>
تریال {trialPeriod.duration_months} ماهه رایگان از دکمه بالای صفحه فعال کنید
</div>
)}
</div>
);
}