feat(payment): add test mode check for payment callback IP validation

This commit is contained in:
hamed
2026-06-15 13:17:28 +03:30
parent 5fd45b429e
commit 68dfe613a2
3 changed files with 212 additions and 120 deletions
+176 -106
View File
@@ -6,6 +6,7 @@ import { z } from 'zod';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { import {
ArrowUpCircleIcon, ArrowDownCircleIcon, DevicePhoneMobileIcon, ArrowUpCircleIcon, ArrowDownCircleIcon, DevicePhoneMobileIcon,
ClockIcon, CheckCircleIcon, XCircleIcon, BellAlertIcon, ChatBubbleLeftEllipsisIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import { api } from '../lib/api'; import { api } from '../lib/api';
import type { ApiResponse, PaginatedResponse } from '../lib/api'; import type { ApiResponse, PaginatedResponse } from '../lib/api';
@@ -82,23 +83,19 @@ function SmsWalletPageInner() {
<> <>
<PageHeader title="کیف پول پیامک" description="مدیریت موجودی و تنظیمات ارسال پیامک" /> <PageHeader title="کیف پول پیامک" description="مدیریت موجودی و تنظیمات ارسال پیامک" />
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, marginBottom: 20 }}> {/* ردیف بالا: موجودی + آمار */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 16, marginBottom: 20 }}>
{/* کارت موجودی — gradient */} {/* کارت موجودی — gradient */}
<div style={{ <div style={{
background: 'linear-gradient(135deg, oklch(0.52 0.22 256), oklch(0.40 0.18 256))', background: 'linear-gradient(135deg, oklch(0.52 0.22 256), oklch(0.40 0.18 256))',
borderRadius: 'var(--r)', borderRadius: 'var(--r)', padding: '24px 24px 20px',
padding: '24px 24px 20px', color: '#fff', position: 'relative', overflow: 'hidden', gridColumn: '1 / 2',
color: '#fff',
position: 'relative',
overflow: 'hidden',
}}> }}>
{/* دایره تزئینی */}
<div style={{ <div style={{
position: 'absolute', top: -30, left: -30, position: 'absolute', top: -30, left: -30,
width: 140, height: 140, borderRadius: '50%', width: 140, height: 140, borderRadius: '50%',
background: 'rgba(255,255,255,0.06)', background: 'rgba(255,255,255,0.06)',
}} /> }} />
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<div style={{ fontSize: 13, opacity: 0.85 }}>موجودی کیف پول</div> <div style={{ fontSize: 13, opacity: 0.85 }}>موجودی کیف پول</div>
<span style={{ <span style={{
@@ -109,23 +106,16 @@ function SmsWalletPageInner() {
{isLowBalance ? 'موجودی کم' : 'فعال'} {isLowBalance ? 'موجودی کم' : 'فعال'}
</span> </span>
</div> </div>
{balanceLoading ? <div style={{ height: 60 }} /> : (
{balanceLoading ? (
<div style={{ height: 60 }} />
) : (
<> <>
<div style={{ fontSize: 36, fontWeight: 800, letterSpacing: '-0.5px', marginBottom: 4 }}> <div style={{ fontSize: 34, fontWeight: 800, letterSpacing: '-0.5px', marginBottom: 4 }}>
{formatRial(balance?.balance_rials ?? 0)} {formatRial(balance?.balance_rials ?? 0)}
</div> </div>
<div style={{ fontSize: 13, opacity: 0.8, marginBottom: 16 }}> <div style={{ fontSize: 13, opacity: 0.8, marginBottom: 16 }}>
معادل {formatNumber(smsCount)} پیامک معادل {formatNumber(smsCount)} پیامک
{balance?.sms_price_rials ? ` (هر پیامک ${formatRial(balance.sms_price_rials)})` : ''} {balance?.sms_price_rials ? ` (هر پیامک ${formatRial(balance.sms_price_rials)})` : ''}
</div> </div>
<div style={{ background: 'rgba(255,255,255,0.2)', borderRadius: 8, height: 6, marginBottom: 16, overflow: 'hidden' }}>
{/* نوار پیشرفت */}
<div style={{
background: 'rgba(255,255,255,0.2)', borderRadius: 8, height: 6, marginBottom: 16, overflow: 'hidden',
}}>
<div style={{ <div style={{
height: '100%', borderRadius: 8, height: '100%', borderRadius: 8,
width: `${Math.min(100, (smsCount / 500) * 100)}%`, width: `${Math.min(100, (smsCount / 500) * 100)}%`,
@@ -133,12 +123,10 @@ function SmsWalletPageInner() {
transition: 'width 0.4s ease', transition: 'width 0.4s ease',
}} /> }} />
</div> </div>
<button <button
style={{ style={{
background: '#fff', color: 'var(--primary)', background: '#fff', color: 'var(--primary)', border: 'none', borderRadius: 8,
border: 'none', borderRadius: 8, padding: '8px 18px', padding: '8px 18px', fontWeight: 700, fontSize: 13.5, cursor: 'pointer',
fontWeight: 700, fontSize: 13.5, cursor: 'pointer',
display: 'inline-flex', alignItems: 'center', gap: 6, display: 'inline-flex', alignItems: 'center', gap: 6,
}} }}
onClick={() => setChargeOpen(true)} onClick={() => setChargeOpen(true)}
@@ -150,128 +138,210 @@ function SmsWalletPageInner() {
)} )}
</div> </div>
{/* تنظیمات پیامک */} {/* آمار پیامک */}
<div className="card"> <div className="card" style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', padding: '20px 24px', gap: 20 }}>
<div style={{ <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
fontWeight: 600, padding: '14px 16px', borderBottom: '1px solid var(--border)', <div style={{ width: 40, height: 40, borderRadius: 10, background: 'var(--primary-subtle)', display: 'grid', placeItems: 'center', flexShrink: 0 }}>
display: 'flex', alignItems: 'center', gap: 8, <ChatBubbleLeftEllipsisIcon style={{ width: 20, color: 'var(--primary)' }} />
}}> </div>
<DevicePhoneMobileIcon style={{ width: 16, color: 'var(--primary)' }} /> <div>
تنظیمات ارسال <div style={{ fontSize: 22, fontWeight: 700 }}>{formatNumber(smsCount)}</div>
<div style={{ fontSize: 12.5, color: 'var(--text-3)' }}>پیامک باقیمانده</div>
</div>
</div> </div>
<div style={{ padding: '16px' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
{currentSettings ? ( <div style={{ width: 40, height: 40, borderRadius: 10, background: 'oklch(0.96 0.02 162)', display: 'grid', placeItems: 'center', flexShrink: 0 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}> <ClockIcon style={{ width: 20, color: 'oklch(0.45 0.17 162)' }} />
{/* toggle یادآوری */} </div>
<div style={{ <div>
background: currentSettings.reminder_enabled ? 'var(--primary-subtle)' : 'oklch(0.97 0 0)', <div style={{ fontSize: 22, fontWeight: 700 }}>
border: `1px solid ${currentSettings.reminder_enabled ? 'oklch(0.85 0.06 256)' : 'var(--border)'}`, {currentSettings?.reminder_enabled ? `${currentSettings.reminder_hours_before} ساعت` : '—'}
borderRadius: 8, padding: '10px 14px', </div>
display: 'flex', alignItems: 'flex-start', gap: 10, cursor: 'pointer', <div style={{ fontSize: 12.5, color: 'var(--text-3)' }}>یادآوری قبل از نوبت</div>
transition: 'all 0.15s', </div>
}} </div>
</div>
{/* وضعیت سرویس */}
<div className="card" style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', padding: '20px 24px', gap: 16 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-2)', marginBottom: 4 }}>وضعیت سرویسها</div>
{[
{ label: 'یادآوری نوبت', on: currentSettings?.reminder_enabled ?? false },
{ label: 'پیامک بعد از ویزیت', on: currentSettings?.post_visit_enabled ?? false },
].map(({ label, on }) => (
<div key={label} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{ fontSize: 13, color: 'var(--text-2)' }}>{label}</span>
<span style={{
fontSize: 11.5, fontWeight: 600, padding: '2px 10px', borderRadius: 20,
background: on ? 'oklch(0.94 0.05 162)' : 'var(--surface-2)',
color: on ? 'oklch(0.38 0.14 162)' : 'var(--text-3)',
}}>
{on ? 'فعال' : 'غیرفعال'}
</span>
</div>
))}
</div>
</div>
{/* تنظیمات ارسال — full width */}
<div className="card" style={{ marginBottom: 20 }}>
<div style={{
fontWeight: 600, padding: '14px 20px', borderBottom: '1px solid var(--border)',
display: 'flex', alignItems: 'center', gap: 8,
}}>
<BellAlertIcon style={{ width: 17, color: 'var(--primary)' }} />
تنظیمات ارسال پیامک
</div>
<div style={{ padding: '20px' }}>
{currentSettings ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
{/* ردیف: یادآوری قبل از نوبت */}
<div style={{
display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16,
padding: '16px 0', borderBottom: '1px solid var(--border)',
}}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
<div style={{ width: 36, height: 36, borderRadius: 9, background: 'var(--primary-subtle)', display: 'grid', placeItems: 'center', flexShrink: 0, marginTop: 2 }}>
<ClockIcon style={{ width: 18, color: 'var(--primary)' }} />
</div>
<div>
<div style={{ fontWeight: 600, fontSize: 14 }}>یادآوری قبل از نوبت</div>
<div style={{ color: 'var(--text-3)', fontSize: 12.5, marginTop: 3 }}>پیامک یادآور به بیمار چند ساعت قبل از وقت نوبت ارسال میشود</div>
{currentSettings.reminder_enabled && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 12 }}>
<label style={{ fontSize: 13, color: 'var(--text-2)', whiteSpace: 'nowrap' }}>ارسال</label>
<input
type="number" min={1} max={72}
value={currentSettings.reminder_hours_before}
onChange={(e) => setLocalSettings({ ...currentSettings, reminder_hours_before: +e.target.value })}
dir="ltr"
style={{ width: 72, textAlign: 'center' }}
/>
<label style={{ fontSize: 13, color: 'var(--text-2)', whiteSpace: 'nowrap' }}>ساعت قبل</label>
</div>
)}
</div>
</div>
<div
className={`switch${currentSettings.reminder_enabled ? ' on' : ''}`}
style={{ marginTop: 4, flexShrink: 0 }}
onClick={() => setLocalSettings({ ...currentSettings, reminder_enabled: !currentSettings.reminder_enabled })} onClick={() => setLocalSettings({ ...currentSettings, reminder_enabled: !currentSettings.reminder_enabled })}
> />
<input </div>
type="checkbox"
checked={currentSettings.reminder_enabled}
onChange={(e) => setLocalSettings({ ...currentSettings, reminder_enabled: e.target.checked })}
style={{ width: 16, height: 16, accentColor: 'var(--primary)', marginTop: 2, cursor: 'pointer', flexShrink: 0 }}
onClick={(e) => e.stopPropagation()}
/>
<div>
<div style={{ fontWeight: 600, fontSize: 13.5 }}>یادآوری قبل از نوبت</div>
<div style={{ color: 'var(--text-3)', fontSize: 12, marginTop: 2 }}>ارسال خودکار چند ساعت قبل از نوبت</div>
</div>
</div>
{currentSettings.reminder_enabled && ( {/* ردیف: پیامک بعد از ویزیت */}
<div className="field" style={{ marginBottom: 0, marginRight: 4 }}> <div style={{ padding: '16px 0' }}>
<label style={{ fontSize: 13 }}>چند ساعت قبل از نوبت</label> {/* header ردیف */}
<input <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16 }}>
type="number" min={1} max={72} <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
value={currentSettings.reminder_hours_before} <div style={{ width: 36, height: 36, borderRadius: 9, background: 'oklch(0.96 0.02 162)', display: 'grid', placeItems: 'center', flexShrink: 0 }}>
onChange={(e) => setLocalSettings({ ...currentSettings, reminder_hours_before: +e.target.value })} <ChatBubbleLeftEllipsisIcon style={{ width: 18, color: 'oklch(0.45 0.17 162)' }} />
dir="ltr" </div>
style={{ width: 90 }} <div>
/> <div style={{ fontWeight: 600, fontSize: 14 }}>پیامک بعد از ویزیت</div>
</div> <div style={{ color: 'var(--text-3)', fontSize: 12.5, marginTop: 3 }}>متن تشکر پس از پایان مراجعه برای بیمار ارسال میشود</div>
)} </div>
{/* toggle بعد از ویزیت */}
<div style={{
background: currentSettings.post_visit_enabled ? 'var(--primary-subtle)' : 'oklch(0.97 0 0)',
border: `1px solid ${currentSettings.post_visit_enabled ? 'oklch(0.85 0.06 256)' : 'var(--border)'}`,
borderRadius: 8, padding: '10px 14px',
display: 'flex', alignItems: 'flex-start', gap: 10, cursor: 'pointer',
transition: 'all 0.15s',
}}
onClick={() => setLocalSettings({ ...currentSettings, post_visit_enabled: !currentSettings.post_visit_enabled })}
>
<input
type="checkbox"
checked={currentSettings.post_visit_enabled}
onChange={(e) => setLocalSettings({ ...currentSettings, post_visit_enabled: e.target.checked })}
style={{ width: 16, height: 16, accentColor: 'var(--primary)', marginTop: 2, cursor: 'pointer', flexShrink: 0 }}
onClick={(e) => e.stopPropagation()}
/>
<div>
<div style={{ fontWeight: 600, fontSize: 13.5 }}>پیامک بعد از ویزیت</div>
<div style={{ color: 'var(--text-3)', fontSize: 12, marginTop: 2 }}>ارسال متن تشکر پس از پایان مراجعه</div>
</div> </div>
<div
className={`switch${currentSettings.post_visit_enabled ? ' on' : ''}`}
style={{ flexShrink: 0 }}
onClick={() => setLocalSettings({ ...currentSettings, post_visit_enabled: !currentSettings.post_visit_enabled })}
/>
</div> </div>
{/* محتوای باز‌شونده */}
{currentSettings.post_visit_enabled && ( {currentSettings.post_visit_enabled && (
<div style={{ marginRight: 4 }}> <div style={{
<div className="field" style={{ marginBottom: 8 }}> marginTop: 16,
<label style={{ fontSize: 13 }}>متن پیامک</label> marginRight: 48,
padding: 16,
background: 'var(--surface-2)',
borderRadius: 10,
border: '1px solid var(--border)',
}}>
<div style={{ marginBottom: 12 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-2)', marginBottom: 8 }}>متن پیامک</div>
<textarea <textarea
value={currentSettings.post_visit_text_pending ?? currentSettings.post_visit_text ?? ''} value={currentSettings.post_visit_text_pending ?? currentSettings.post_visit_text ?? ''}
onChange={(e) => setLocalSettings({ ...currentSettings, post_visit_text: e.target.value })} onChange={(e) => setLocalSettings({ ...currentSettings, post_visit_text: e.target.value })}
rows={3} rows={4}
placeholder="ممنون از مراجعه شما..." placeholder="ممنون از مراجعه شما..."
style={{
display: 'block',
width: '100%',
boxSizing: 'border-box',
resize: 'vertical',
padding: '10px 12px',
borderRadius: 8,
border: '1px solid var(--border)',
background: 'var(--surface-1)',
fontSize: 13,
lineHeight: 1.7,
color: 'var(--text-1)',
fontFamily: 'inherit',
direction: 'rtl',
}}
/> />
<div style={{ fontSize: 11.5, color: 'var(--text-3)', marginTop: 6 }}>
متن پس از تغییر باید توسط ادمین تأیید شود تا فعال گردد
</div>
</div> </div>
{/* وضعیت تأیید */} {/* وضعیت تأیید */}
{currentSettings.post_visit_text_status === 'pending' && ( {currentSettings.post_visit_text_status === 'pending' && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px', borderRadius: 8, background: '#fef9c3', border: '1px solid #fbbf24', fontSize: 12.5, marginBottom: 8 }}> <div style={{
<span style={{ fontSize: 14 }}></span> display: 'flex', alignItems: 'center', gap: 10,
<span>متن پیامک در انتظار تأیید ادمین است</span> padding: '10px 14px', borderRadius: 8,
background: '#fef9c3', border: '1px solid #fbbf24', fontSize: 13,
}}>
<ClockIcon style={{ width: 16, color: '#b45309', flexShrink: 0 }} />
<span style={{ color: '#92400e' }}>در انتظار تأیید ادمین</span>
</div> </div>
)} )}
{currentSettings.post_visit_text_status === 'approved' && ( {currentSettings.post_visit_text_status === 'approved' && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px', borderRadius: 8, background: '#dcfce7', border: '1px solid #86efac', fontSize: 12.5, marginBottom: 8 }}> <div style={{
<span style={{ fontSize: 14 }}></span> display: 'flex', alignItems: 'center', gap: 10,
<span>متن پیامک تأیید شده و فعال است</span> padding: '10px 14px', borderRadius: 8,
background: '#dcfce7', border: '1px solid #86efac', fontSize: 13,
}}>
<CheckCircleIcon style={{ width: 16, color: '#16a34a', flexShrink: 0 }} />
<span style={{ color: '#15803d' }}>تأیید شده و فعال است</span>
</div> </div>
)} )}
{currentSettings.post_visit_text_status === 'rejected' && ( {currentSettings.post_visit_text_status === 'rejected' && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px', borderRadius: 8, background: '#fee2e2', border: '1px solid #fca5a5', fontSize: 12.5, marginBottom: 8 }}> <div style={{
<span style={{ fontSize: 14 }}></span> display: 'flex', alignItems: 'flex-start', gap: 10,
<div> padding: '10px 14px', borderRadius: 8,
background: '#fee2e2', border: '1px solid #fca5a5', fontSize: 13,
}}>
<XCircleIcon style={{ width: 16, color: '#dc2626', flexShrink: 0, marginTop: 1 }} />
<div style={{ color: '#dc2626' }}>
<div>متن پیامک رد شد</div> <div>متن پیامک رد شد</div>
{currentSettings.post_visit_text_reject_reason && ( {currentSettings.post_visit_text_reject_reason && (
<div style={{ color: '#dc2626', marginTop: 2 }}>دلیل: {currentSettings.post_visit_text_reject_reason}</div> <div style={{ marginTop: 3, fontSize: 12, opacity: 0.85 }}>دلیل: {currentSettings.post_visit_text_reject_reason}</div>
)} )}
</div> </div>
</div> </div>
)} )}
</div> </div>
)} )}
</div>
{/* footer ذخیره */}
<div style={{ display: 'flex', justifyContent: 'flex-end', paddingTop: 16, borderTop: '1px solid var(--border)' }}>
<button <button
className="btn primary sm" className="btn primary sm"
style={{ alignSelf: 'flex-start', marginTop: 4 }}
disabled={saveMutation.isPending} disabled={saveMutation.isPending}
onClick={() => currentSettings && saveMutation.mutate(currentSettings)} onClick={() => currentSettings && saveMutation.mutate(currentSettings)}
> >
{saveMutation.isPending ? 'در حال ذخیره...' : 'ذخیره تنظیمات'} {saveMutation.isPending ? 'در حال ذخیره...' : 'ذخیره تنظیمات'}
</button> </button>
</div> </div>
) : ( </div>
<div style={{ color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div> ) : (
)} <div style={{ color: 'var(--text-3)', fontSize: 13, padding: '8px 0' }}>در حال بارگذاری...</div>
</div> )}
</div> </div>
</div> </div>
+33 -12
View File
@@ -249,6 +249,8 @@ export default function SubscriptionPage() {
key={plan.uuid} key={plan.uuid}
plan={plan} plan={plan}
currentPlanName={my?.plan.name ?? 'free'} currentPlanName={my?.plan.name ?? 'free'}
currentPlanLevel={my?.plan.level ?? 0}
currentPlanActive={!!my && (my.days_remaining ?? 0) > 0}
usedTrial={usedTrial} usedTrial={usedTrial}
onPurchase={(period) => setPurchaseTarget({ period, planName: plan.name })} onPurchase={(period) => setPurchaseTarget({ period, planName: plan.name })}
onTrial={() => trialMutation.mutate()} onTrial={() => trialMutation.mutate()}
@@ -378,6 +380,8 @@ export default function SubscriptionPage() {
function PlanCard({ function PlanCard({
plan, plan,
currentPlanName, currentPlanName,
currentPlanLevel,
currentPlanActive,
usedTrial, usedTrial,
onPurchase, onPurchase,
onTrial, onTrial,
@@ -385,14 +389,16 @@ function PlanCard({
}: { }: {
plan: SubscriptionPlan; plan: SubscriptionPlan;
currentPlanName: string; currentPlanName: string;
currentPlanLevel: number;
currentPlanActive: boolean;
usedTrial: boolean; usedTrial: boolean;
onPurchase: (period: SubscriptionPeriod) => void; onPurchase: (period: SubscriptionPeriod) => void;
onTrial: () => void; onTrial: () => void;
trialPending: boolean; trialPending: boolean;
}) { }) {
const meta = PLAN_META[plan.name] ?? PLAN_META.free; const meta = PLAN_META[plan.name] ?? PLAN_META.free;
const isCurrent = plan.name === currentPlanName; const isCurrent = plan.name === currentPlanName;
const isUpgrade = (plan.level ?? 0) > ((PLAN_META[currentPlanName] ? plan.level : 0)); const isDowngrade = currentPlanActive && (plan.level ?? 0) < currentPlanLevel;
const paidPeriods = plan.periods.filter((p) => !p.is_trial); const paidPeriods = plan.periods.filter((p) => !p.is_trial);
const trialPeriod = plan.periods.find((p) => p.is_trial); const trialPeriod = plan.periods.find((p) => p.is_trial);
const Icon = meta.icon; const Icon = meta.icon;
@@ -484,7 +490,7 @@ function PlanCard({
</ul> </ul>
{/* تریال */} {/* تریال */}
{trialPeriod && !usedTrial && plan.level > 0 && ( {trialPeriod && !usedTrial && plan.level > 0 && !isDowngrade && (
<div style={{ <div style={{
marginTop: 14, padding: '10px 12px', borderRadius: 'var(--r-sm)', marginTop: 14, padding: '10px 12px', borderRadius: 'var(--r-sm)',
background: 'linear-gradient(135deg, #eff6ff, #f5f3ff)', background: 'linear-gradient(135deg, #eff6ff, #f5f3ff)',
@@ -510,31 +516,46 @@ function PlanCard({
{/* دوره‌های پرداختی */} {/* دوره‌های پرداختی */}
{paidPeriods.length > 0 && ( {paidPeriods.length > 0 && (
<div style={{ padding: '14px 20px', display: 'flex', flexDirection: 'column', gap: 8 }}> <div style={{ padding: '14px 20px', display: 'flex', flexDirection: 'column', gap: 8 }}>
{isDowngrade && (
<div style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '10px 12px', borderRadius: 'var(--r-sm)',
background: '#fef9c3', border: '1px solid #fbbf24',
fontSize: 12.5, color: '#92400e', marginBottom: 2,
}}>
<span style={{ fontSize: 15 }}>🔒</span>
<span>تا پایان اشتراک فعلی قابل انتخاب نیست</span>
</div>
)}
{paidPeriods.map((period) => ( {paidPeriods.map((period) => (
<div key={period.uuid} style={{ <div key={period.uuid} style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
gap: 8, padding: '8px 10px', borderRadius: 'var(--r-sm)', gap: 8, padding: '8px 10px', borderRadius: 'var(--r-sm)',
border: '1px solid var(--border)', background: 'var(--surface-2)', border: '1px solid var(--border)',
background: isDowngrade ? 'var(--surface-3)' : 'var(--surface-2)',
opacity: isDowngrade ? 0.6 : 1,
}}> }}>
<div> <div>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text)' }}>{period.label}</div> <div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text)' }}>{period.label}</div>
<div style={{ fontSize: 11.5, color: 'var(--text-3)' }}>{period.duration_months} ماه</div> <div style={{ fontSize: 11.5, color: 'var(--text-3)' }}>{period.duration_months} ماه</div>
</div> </div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontWeight: 700, fontSize: 13.5, color: meta.color }}> <span style={{ fontWeight: 700, fontSize: 13.5, color: isDowngrade ? 'var(--text-3)' : meta.color }}>
{formatRial(period.price_rials)} {formatRial(period.price_rials)}
</span> </span>
<button <button
onClick={() => onPurchase(period)} disabled={isDowngrade}
onClick={() => !isDowngrade && onPurchase(period)}
style={{ style={{
padding: '6px 14px', borderRadius: 'var(--r-sm)', cursor: 'pointer', padding: '6px 14px', borderRadius: 'var(--r-sm)',
background: isCurrent ? 'var(--surface)' : meta.color, cursor: isDowngrade ? 'not-allowed' : 'pointer',
color: isCurrent ? meta.color : '#fff', background: isDowngrade ? 'var(--surface-3)' : isCurrent ? 'var(--surface)' : meta.color,
border: `1.5px solid ${meta.color}`, color: isDowngrade ? 'var(--text-3)' : isCurrent ? meta.color : '#fff',
border: `1.5px solid ${isDowngrade ? 'var(--border)' : meta.color}`,
fontSize: 12.5, fontWeight: 700, transition: '.14s', fontSize: 12.5, fontWeight: 700, transition: '.14s',
fontFamily: 'inherit', fontFamily: 'inherit',
}} }}
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.opacity = '.85'; }} onMouseEnter={(e) => { if (!isDowngrade) (e.currentTarget as HTMLElement).style.opacity = '.85'; }}
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.opacity = '1'; }} onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.opacity = '1'; }}
> >
{isCurrent ? 'تمدید' : 'خرید'} {isCurrent ? 'تمدید' : 'خرید'}
+3 -2
View File
@@ -237,8 +237,9 @@ class PaymentController extends BaseController
#[Route('/api/v1/payment/callback/{gateway}', methods: ['POST', 'GET'])] #[Route('/api/v1/payment/callback/{gateway}', methods: ['POST', 'GET'])]
public function callback(string $gateway, Request $request): \Symfony\Component\HttpFoundation\Response public function callback(string $gateway, Request $request): \Symfony\Component\HttpFoundation\Response
{ {
$clientIp = $request->getClientIp() ?? ''; $clientIp = $request->getClientIp() ?? '';
if (!$this->isAllowedCallbackIp($clientIp)) { $isTestMode = $this->configRepo->get('payment_test_mode') === '1';
if (!$isTestMode && !$this->isAllowedCallbackIp($clientIp)) {
return new JsonResponse(['success' => false, 'message' => 'دسترسی ممنوع'], 403); return new JsonResponse(['success' => false, 'message' => 'دسترسی ممنوع'], 403);
} }