feat(payment): add test mode check for payment callback IP validation
This commit is contained in:
@@ -6,6 +6,7 @@ import { z } from 'zod';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
ArrowUpCircleIcon, ArrowDownCircleIcon, DevicePhoneMobileIcon,
|
||||
ClockIcon, CheckCircleIcon, XCircleIcon, BellAlertIcon, ChatBubbleLeftEllipsisIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { api } from '../lib/api';
|
||||
import type { ApiResponse, PaginatedResponse } from '../lib/api';
|
||||
@@ -82,23 +83,19 @@ function SmsWalletPageInner() {
|
||||
<>
|
||||
<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 */}
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, oklch(0.52 0.22 256), oklch(0.40 0.18 256))',
|
||||
borderRadius: 'var(--r)',
|
||||
padding: '24px 24px 20px',
|
||||
color: '#fff',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
borderRadius: 'var(--r)', padding: '24px 24px 20px',
|
||||
color: '#fff', position: 'relative', overflow: 'hidden', gridColumn: '1 / 2',
|
||||
}}>
|
||||
{/* دایره تزئینی */}
|
||||
<div style={{
|
||||
position: 'absolute', top: -30, left: -30,
|
||||
width: 140, height: 140, borderRadius: '50%',
|
||||
background: 'rgba(255,255,255,0.06)',
|
||||
}} />
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
||||
<div style={{ fontSize: 13, opacity: 0.85 }}>موجودی کیف پول</div>
|
||||
<span style={{
|
||||
@@ -109,23 +106,16 @@ function SmsWalletPageInner() {
|
||||
{isLowBalance ? 'موجودی کم' : 'فعال'}
|
||||
</span>
|
||||
</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)}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, opacity: 0.8, marginBottom: 16 }}>
|
||||
معادل {formatNumber(smsCount)} پیامک
|
||||
{balance?.sms_price_rials ? ` (هر پیامک ${formatRial(balance.sms_price_rials)})` : ''}
|
||||
</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={{
|
||||
height: '100%', borderRadius: 8,
|
||||
width: `${Math.min(100, (smsCount / 500) * 100)}%`,
|
||||
@@ -133,12 +123,10 @@ function SmsWalletPageInner() {
|
||||
transition: 'width 0.4s ease',
|
||||
}} />
|
||||
</div>
|
||||
|
||||
<button
|
||||
style={{
|
||||
background: '#fff', color: 'var(--primary)',
|
||||
border: 'none', borderRadius: 8, padding: '8px 18px',
|
||||
fontWeight: 700, fontSize: 13.5, cursor: 'pointer',
|
||||
background: '#fff', color: 'var(--primary)', border: 'none', borderRadius: 8,
|
||||
padding: '8px 18px', fontWeight: 700, fontSize: 13.5, cursor: 'pointer',
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
}}
|
||||
onClick={() => setChargeOpen(true)}
|
||||
@@ -150,128 +138,210 @@ function SmsWalletPageInner() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* تنظیمات پیامک */}
|
||||
<div className="card">
|
||||
<div style={{
|
||||
fontWeight: 600, padding: '14px 16px', borderBottom: '1px solid var(--border)',
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
}}>
|
||||
<DevicePhoneMobileIcon style={{ width: 16, color: 'var(--primary)' }} />
|
||||
تنظیمات ارسال
|
||||
{/* آمار پیامک */}
|
||||
<div className="card" style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', padding: '20px 24px', gap: 20 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{ width: 40, height: 40, borderRadius: 10, background: 'var(--primary-subtle)', display: 'grid', placeItems: 'center', flexShrink: 0 }}>
|
||||
<ChatBubbleLeftEllipsisIcon style={{ width: 20, color: 'var(--primary)' }} />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 22, fontWeight: 700 }}>{formatNumber(smsCount)}</div>
|
||||
<div style={{ fontSize: 12.5, color: 'var(--text-3)' }}>پیامک باقیمانده</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: '16px' }}>
|
||||
{currentSettings ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{/* toggle یادآوری */}
|
||||
<div style={{
|
||||
background: currentSettings.reminder_enabled ? 'var(--primary-subtle)' : 'oklch(0.97 0 0)',
|
||||
border: `1px solid ${currentSettings.reminder_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',
|
||||
}}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{ width: 40, height: 40, borderRadius: 10, background: 'oklch(0.96 0.02 162)', display: 'grid', placeItems: 'center', flexShrink: 0 }}>
|
||||
<ClockIcon style={{ width: 20, color: 'oklch(0.45 0.17 162)' }} />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 22, fontWeight: 700 }}>
|
||||
{currentSettings?.reminder_enabled ? `${currentSettings.reminder_hours_before} ساعت` : '—'}
|
||||
</div>
|
||||
<div style={{ fontSize: 12.5, color: 'var(--text-3)' }}>یادآوری قبل از نوبت</div>
|
||||
</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 })}
|
||||
>
|
||||
<input
|
||||
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>
|
||||
/>
|
||||
</div>
|
||||
|
||||
{currentSettings.reminder_enabled && (
|
||||
<div className="field" style={{ marginBottom: 0, marginRight: 4 }}>
|
||||
<label style={{ fontSize: 13 }}>چند ساعت قبل از نوبت</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: 90 }}
|
||||
/>
|
||||
</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 style={{ padding: '16px 0' }}>
|
||||
{/* header ردیف */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{ width: 36, height: 36, borderRadius: 9, background: 'oklch(0.96 0.02 162)', display: 'grid', placeItems: 'center', flexShrink: 0 }}>
|
||||
<ChatBubbleLeftEllipsisIcon style={{ width: 18, color: 'oklch(0.45 0.17 162)' }} />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: 14 }}>پیامک بعد از ویزیت</div>
|
||||
<div style={{ color: 'var(--text-3)', fontSize: 12.5, marginTop: 3 }}>متن تشکر پس از پایان مراجعه برای بیمار ارسال میشود</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>
|
||||
|
||||
{/* محتوای بازشونده */}
|
||||
{currentSettings.post_visit_enabled && (
|
||||
<div style={{ marginRight: 4 }}>
|
||||
<div className="field" style={{ marginBottom: 8 }}>
|
||||
<label style={{ fontSize: 13 }}>متن پیامک</label>
|
||||
<div style={{
|
||||
marginTop: 16,
|
||||
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
|
||||
value={currentSettings.post_visit_text_pending ?? currentSettings.post_visit_text ?? ''}
|
||||
onChange={(e) => setLocalSettings({ ...currentSettings, post_visit_text: e.target.value })}
|
||||
rows={3}
|
||||
rows={4}
|
||||
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>
|
||||
|
||||
{/* وضعیت تأیید */}
|
||||
{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 }}>
|
||||
<span style={{ fontSize: 14 }}>⏳</span>
|
||||
<span>متن پیامک در انتظار تأیید ادمین است</span>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
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>
|
||||
)}
|
||||
{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 }}>
|
||||
<span style={{ fontSize: 14 }}>✅</span>
|
||||
<span>متن پیامک تأیید شده و فعال است</span>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
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>
|
||||
)}
|
||||
{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 }}>
|
||||
<span style={{ fontSize: 14 }}>❌</span>
|
||||
<div>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'flex-start', gap: 10,
|
||||
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>
|
||||
{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>
|
||||
|
||||
{/* footer ذخیره */}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', paddingTop: 16, borderTop: '1px solid var(--border)' }}>
|
||||
<button
|
||||
className="btn primary sm"
|
||||
style={{ alignSelf: 'flex-start', marginTop: 4 }}
|
||||
disabled={saveMutation.isPending}
|
||||
onClick={() => currentSettings && saveMutation.mutate(currentSettings)}
|
||||
>
|
||||
{saveMutation.isPending ? 'در حال ذخیره...' : 'ذخیره تنظیمات'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ color: 'var(--text-3)', fontSize: 13, padding: '8px 0' }}>در حال بارگذاری...</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user