Files
clinicpro/assets/admin/pages/PaymentSuccessPage.tsx
T
hamed 6876135a53 feat: add BlogBodySanitizer for HTML sanitization on article save
- Implemented BlogBodySanitizer to clean HTML content before saving articles, ensuring security against XSS attacks.
- Added tests for BlogBodySanitizer to verify that unsafe tags and attributes are stripped from the content.
- Introduced ApiLeastPrivilegeTest to ensure that unauthorized users cannot access sensitive API routes, maintaining strict access control.
2026-08-07 21:13:38 +03:30

169 lines
7.5 KiB
TypeScript

import React, { useEffect } from 'react';
import { useSearchParams, useNavigate, Link } from 'react-router';
import { useQuery } from '@tanstack/react-query';
import { CheckBadgeIcon, SparklesIcon, CalendarDaysIcon, CreditCardIcon, ArrowLeftIcon } from '@heroicons/react/24/outline';
import { toast } from 'sonner';
import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import type { MySubscriptionData } from '../types';
import { formatRial, formatDate } from '../lib/utils';
import BackButton from '../components/ui/BackButton';
import { PLAN_FEATURE_LABELS, planMetaOf } from './SubscriptionPage';
interface PaymentDetails {
uuid: string;
order_id: string;
amount_rials: number;
status: string;
gateway: string;
reference_id: string | null;
created_at: number;
}
/**
* PaymentSuccessPage — landing shown when the payment gateway redirects back
* after a subscription purchase (frontend_address + ?payment_uuid&status).
* On success it shows the transaction receipt and the newly-active plan; any
* non-success status redirects back to the plans page with an error toast.
*/
export default function PaymentSuccessPage() {
const [params] = useSearchParams();
const navigate = useNavigate();
const paymentUuid = params.get('payment_uuid') ?? '';
const status = params.get('status') ?? '';
const isSuccess = status === 'success';
useEffect(() => {
if (!isSuccess) {
toast.error('پرداخت انجام نشد یا لغو شد');
navigate('/admin/subscription', { replace: true });
}
}, [isSuccess, navigate]);
const { data: payData, isLoading: payLoading } = useQuery<ApiResponse<PaymentDetails>>({
queryKey: ['payment', paymentUuid],
queryFn: () => api.get(`/api/v1/payment/${paymentUuid}`),
enabled: isSuccess && paymentUuid !== '',
});
const { data: myData } = useQuery<ApiResponse<MySubscriptionData>>({
queryKey: ['subscription-my'],
queryFn: () => api.get('/api/v1/subscription/my'),
enabled: isSuccess,
});
if (!isSuccess) return null;
const payment = payData?.data ?? null;
const sub = myData?.data?.subscription ?? null;
const meta = sub ? planMetaOf(sub.plan.name) : null;
const months = sub?.period?.duration_months ?? 0;
return (
<div className="fade-in" style={{ maxWidth: 860, margin: '0 auto', padding: '8px 0 40px' }}>
<div style={{ marginBottom: 14 }}>
<BackButton fallback="/admin/subscription" />
</div>
{/* ── Success header ── */}
<div style={{ textAlign: 'center', marginBottom: 24 }}>
<div style={{
width: 72, height: 72, borderRadius: '50%', margin: '0 auto 16px',
background: 'var(--success-bg)', display: 'grid', placeItems: 'center',
}}>
<CheckBadgeIcon style={{ width: 40, color: 'var(--success)' }} />
</div>
<h1 style={{ fontSize: 22, fontWeight: 800, color: 'var(--text)', margin: 0 }}>
پرداخت شما با موفقیت انجام شد!
</h1>
</div>
{/* ── Transaction receipt ── */}
<div style={{
border: '1px solid var(--border)', borderRadius: 'var(--r-lg)',
padding: '18px 22px', marginBottom: 28,
display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 16,
}}>
<Receipt label="شماره تراکنش" value={payment?.reference_id ?? payment?.order_id ?? '—'} loading={payLoading} />
<Receipt label="تاریخ پرداخت" value={payment ? formatDate(payment.created_at) : '—'} loading={payLoading} center />
<Receipt label="مبلغ پرداخت شده" value={payment ? formatRial(payment.amount_rials) : '—'} loading={payLoading} left />
</div>
{/* ── Purchased plan ── */}
<h2 style={{ textAlign: 'center', fontSize: 17, fontWeight: 800, color: 'var(--accent)', marginBottom: 16 }}>
پلن خریداری شده شما:
</h2>
{sub && meta && (
<div style={{
background: 'var(--primary-soft)', border: '1.5px solid var(--primary)',
borderRadius: 'var(--r-lg)', padding: 24,
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, marginBottom: 8 }}>
<SparklesIcon style={{ width: 18, color: 'var(--accent)' }} />
<span style={{ fontWeight: 800, fontSize: 19, color: 'var(--text)' }}>پلن {meta.label}</span>
<SparklesIcon style={{ width: 18, color: 'var(--accent)' }} />
</div>
<div style={{ textAlign: 'center', fontSize: 13.5, color: 'var(--text-2)', marginBottom: 18 }}>{meta.desc}</div>
<div style={{
display: 'flex', justifyContent: 'center', gap: 28, flexWrap: 'wrap',
paddingBottom: 18, borderBottom: '1px solid var(--border)', marginBottom: 18,
}}>
{payment && months > 0 && (
<span style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13.5, color: 'var(--text-2)' }}>
<CreditCardIcon style={{ width: 16, color: 'var(--text-3)' }} />
هزینه ماهیانه: <b style={{ color: 'var(--text)' }}>{formatRial(Math.round(payment.amount_rials / months))}</b>
</span>
)}
{sub.expires_at && (
<span style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13.5, color: 'var(--text-2)' }}>
<CalendarDaysIcon style={{ width: 16, color: 'var(--text-3)' }} />
تاریخ انقضا: <b style={{ color: 'var(--text)' }}>{formatDate(sub.expires_at)}</b>
</span>
)}
</div>
<div style={{ textAlign: 'center', fontWeight: 700, fontSize: 14, color: 'var(--text)', marginBottom: 14 }}>امکانات</div>
<ul style={{
listStyle: 'none', padding: 0, margin: '0 auto', maxWidth: 520,
display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 10,
}}>
{Object.entries(sub.plan.features).filter(([, v]) => v).map(([key]) => (
<li key={key} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13.5, color: 'var(--text)' }}>
<span style={{
width: 20, height: 20, borderRadius: 6, flexShrink: 0,
display: 'grid', placeItems: 'center', background: 'var(--surface)', border: '1px solid var(--primary)',
}}>
<CheckBadgeIcon style={{ width: 13, color: 'var(--primary)' }} />
</span>
{PLAN_FEATURE_LABELS[key] ?? key}
</li>
))}
</ul>
</div>
)}
{/* ── Back ── */}
<div style={{ marginTop: 28 }}>
<Link to="/admin/subscription" className="btn primary" style={{ height: 46, padding: '0 22px' }}>
<ArrowLeftIcon style={{ width: 18 }} />
صفحه اشتراک‌ها
</Link>
</div>
</div>
);
}
function Receipt({ label, value, loading, center, left }: { label: string; value: string; loading?: boolean; center?: boolean; left?: boolean }) {
return (
<div style={{ textAlign: center ? 'center' : left ? 'left' : 'right' }}>
<div style={{ fontSize: 12.5, color: 'var(--text-3)', marginBottom: 6 }}>{label}</div>
{loading
? <div className="skeleton" style={{ height: 18, width: 90, borderRadius: 6, display: 'inline-block' }} />
: <div style={{ fontSize: 15, fontWeight: 700, color: 'var(--text)' }}>{value}</div>}
</div>
);
}