- 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.
169 lines
7.5 KiB
TypeScript
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>
|
|
);
|
|
}
|