Files
clinicpro/assets/admin/pages/SettingsPage.tsx
T
hamedandClaude Opus 4.8 00cb9aaa1a feat(admin): normalize Persian/Arabic digits in every numeric field
Users typing on a Persian keyboard produced two distinct failures. Fields with
type="number" silently returned an empty string — the browser rejects Persian
digits, so the value was lost and saved as empty or zero. Text fields passed the
Persian characters straight through to the database, where a mobile stored as
۰۹۱۲… never matches 09… again. The secretary form hit the second case with no
validation at all.

Frontend:
- Adds digitsOnly() and the national-code schemas to lib/utils, plus lib/forms
  with numericField()/latinDigitsField() wrappers for React Hook Form fields.
- Converts every type="number" input to type="text" inputMode="numeric" with
  digit normalization; none remain. Fields that legitimately carry non-digits
  (sheba, landline) only get the digits translated, keeping IR and separators.
- Points the patient national-code and mobile schemas at the shared normalizing
  schemas, which accept Persian input instead of rejecting it.
- Drops two duplicate local digit converters in favour of the shared helper.

Backend:
- Adds NumericFieldNormalizerSubscriber, translating digits in whitelisted
  numeric keys of JSON request bodies under /api/v1/ before controllers run, so
  nobat724_front and clinic-pro-tauri are covered too. Translation only — no
  characters are stripped, non-string values and other keys are untouched.

Three component tests asserted on role="spinbutton" and numeric input values;
both are properties of type="number", so they were updated to match the new
text inputs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 10:38:56 +03:30

534 lines
28 KiB
TypeScript

import React, { useEffect, useMemo, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import { formatDateTime, rialToToman, tomanToRial } from '../lib/utils';
import { numericField } from '../lib/forms';
import {
Cog6ToothIcon, ClockIcon, CalculatorIcon, CreditCardIcon, ChatBubbleLeftRightIcon,
MagnifyingGlassIcon, CheckCircleIcon, ExclamationTriangleIcon, EyeIcon, EyeSlashIcon,
} from '@heroicons/react/24/outline';
interface TaxHistoryRow {
tax_percent: number;
enabled: boolean;
changed_by_name: string | null;
changed_at: number;
}
// ── Schema ────────────────────────────────────────────────────────────────
const schema = z.object({
site_name: z.string().min(1, 'نام سایت الزامی است'),
support_phone: z.string(),
altcha_enabled: z.string(),
max_cancel_hours_before: z.string(),
appointment_reminder_hours: z.string(),
// financial engine
appointment_commission_enabled: z.string(),
upgrade_commission_enabled: z.string(),
upgrade_commission_percent: z.string(),
tax_enabled: z.string(),
tax_percent: z.string(),
sms_panel_fee_rials: z.string(),
sms_price_rials: z.string(),
appointment_fee_rials: z.string(),
// payment gateways
payment_test_mode: z.string(),
mellat_enabled: z.string(),
mellat_sandbox: z.string(),
mellat_wsdl_url: z.string(),
mellat_terminal_id: z.string(),
mellat_username: z.string(),
mellat_password: z.string(),
sep_enabled: z.string(),
sep_terminal_id: z.string(),
});
type FormValues = z.infer<typeof schema>;
interface Settings extends FormValues {
sms_api_key_configured: boolean;
}
const toForm = (s: Partial<Settings>): FormValues => ({
site_name: s.site_name ?? 'ClinicPro',
support_phone: s.support_phone ?? '',
altcha_enabled: s.altcha_enabled ?? '0',
max_cancel_hours_before: s.max_cancel_hours_before ?? '24',
appointment_reminder_hours: s.appointment_reminder_hours ?? '2',
appointment_commission_enabled: s.appointment_commission_enabled ?? '0',
upgrade_commission_enabled: s.upgrade_commission_enabled ?? '0',
upgrade_commission_percent: s.upgrade_commission_percent ?? '20',
tax_enabled: s.tax_enabled ?? '0',
tax_percent: s.tax_percent ?? '10',
// ذخیره ریال است؛ در فرم به تومان نمایش/ویرایش می‌شود.
sms_panel_fee_rials: String(rialToToman(Number(s.sms_panel_fee_rials ?? 1500000))),
sms_price_rials: String(rialToToman(Number(s.sms_price_rials ?? 500))),
appointment_fee_rials: String(rialToToman(Number(s.appointment_fee_rials ?? 150000))),
payment_test_mode: s.payment_test_mode ?? '0',
mellat_enabled: s.mellat_enabled ?? '1',
mellat_sandbox: s.mellat_sandbox ?? '0',
mellat_wsdl_url: s.mellat_wsdl_url ?? '',
mellat_terminal_id: s.mellat_terminal_id ?? '',
mellat_username: s.mellat_username ?? '',
mellat_password: s.mellat_password ?? '',
sep_enabled: s.sep_enabled ?? '1',
sep_terminal_id: s.sep_terminal_id ?? '',
});
// ── Section definitions (drive the nav rail + search) ───────────────────────
type SectionId = 'general' | 'appointments' | 'financial' | 'payment' | 'sms';
interface SectionDef {
id: SectionId;
label: string;
desc: string;
Icon: typeof Cog6ToothIcon;
bg: string; fg: string;
keywords: string;
}
const SECTIONS: SectionDef[] = [
{ id: 'general', label: 'عمومی', desc: 'اطلاعات پایه پلتفرم', Icon: Cog6ToothIcon, bg: 'var(--primary-soft)', fg: 'var(--primary)', keywords: 'نام سایت پشتیبانی شماره تلفن brand کپچا امنیت altcha captcha بات' },
{ id: 'appointments', label: 'نوبت‌دهی', desc: 'قوانین لغو و یادآوری', Icon: ClockIcon, bg: 'var(--warning-bg)', fg: 'var(--warning)', keywords: 'نوبت لغو یادآور reminder cancel ساعت' },
{ id: 'financial', label: 'مالی', desc: 'پورسانت، مالیات و کارمزدها', Icon: CalculatorIcon, bg: 'var(--info-bg)', fg: 'var(--info)', keywords: 'پورسانت مالیات کارمزد پیامک نوبت مبلغ ریال tax commission' },
{ id: 'payment', label: 'درگاه پرداخت', desc: 'ملت، سپ و حالت تست', Icon: CreditCardIcon, bg: 'var(--success-bg)', fg: 'var(--success)', keywords: 'درگاه پرداخت ملت سپ mellat sep terminal تست gateway' },
{ id: 'sms', label: 'پیامک', desc: 'پیکربندی سرویس پیامک', Icon: ChatBubbleLeftRightIcon, bg: 'var(--violet-bg)', fg: 'var(--violet)', keywords: 'پیامک sms کاوه‌نگار kavenegar api' },
];
// ── Small presentational helpers ────────────────────────────────────────────
function SectionHead({ s }: { s: SectionDef }) {
return (
<div className="settings-sec-head">
<div className="ico" style={{ background: s.bg, color: s.fg }}><s.Icon style={{ width: 19, height: 19 }} /></div>
<div>
<h2>{s.label}</h2>
<p>{s.desc}</p>
</div>
</div>
);
}
function Toggle({ checked, onChange, label }: { checked: boolean; onChange: () => void; label: string }) {
return (
<label className="switch" title={label}>
<input type="checkbox" checked={checked} onChange={onChange} aria-label={label} />
<span className="switch-track"><span className="switch-thumb" /></span>
</label>
);
}
function Field({ label, hint, required, optional, error, children, span2 }: {
label: string; hint?: string; required?: boolean; optional?: boolean;
error?: string; children: React.ReactNode; span2?: boolean;
}) {
return (
<div className={`field-block${span2 ? ' span2' : ''}`}>
<label>
{label}
{required && <span className="req">*</span>}
{optional && <span className="opt">(اختیاری)</span>}
</label>
{children}
{hint && <p className="field-hint">{hint}</p>}
{error && <p className="field-err">{error}</p>}
</div>
);
}
// ── Component ─────────────────────────────────────────────────────────────
export default function SettingsPage() {
const qc = useQueryClient();
const [showMellatPassword, setShowMellatPassword] = useState(false);
const [active, setActive] = useState<SectionId>('general');
const [search, setSearch] = useState('');
const [savedFlash, setSavedFlash] = useState(false);
const { data, isLoading } = useQuery({
queryKey: ['admin-settings'],
queryFn: () => api.get<ApiResponse<Settings>>('/api/v1/admin/settings'),
staleTime: 30_000,
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const settings: Settings | undefined = (data?.data as any)?.data ?? data?.data;
const taxHistoryQ = useQuery({
queryKey: ['tax-history'],
queryFn: () => api.get<ApiResponse<TaxHistoryRow[]>>('/api/v1/admin/settings/tax-history'),
staleTime: 30_000,
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const taxHistory: TaxHistoryRow[] = (taxHistoryQ.data?.data as any)?.data ?? taxHistoryQ.data?.data ?? [];
const {
register, handleSubmit, reset, watch, setValue,
formState: { errors, isDirty },
} = useForm<FormValues>({ resolver: zodResolver(schema) });
useEffect(() => { if (settings) reset(toForm(settings)); }, [settings, reset]);
const mutation = useMutation({
mutationFn: (values: FormValues) => api.patch<ApiResponse<Settings>>('/api/v1/admin/settings', values),
onSuccess: (res) => {
qc.invalidateQueries({ queryKey: ['admin-settings'] });
qc.invalidateQueries({ queryKey: ['tax-history'] });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const fresh: Settings | undefined = (res?.data as any)?.data ?? res?.data;
if (fresh) reset(toForm(fresh));
setSavedFlash(true);
},
});
// auto-hide the "saved" flash
useEffect(() => {
if (!savedFlash) return;
const t = setTimeout(() => setSavedFlash(false), 2600);
return () => clearTimeout(t);
}, [savedFlash]);
const onSubmit = (values: FormValues) => mutation.mutate({
...values,
// فرم به تومان است؛ برای ذخیره به ریال تبدیل کن.
sms_panel_fee_rials: String(tomanToRial(Number(values.sms_panel_fee_rials))),
sms_price_rials: String(tomanToRial(Number(values.sms_price_rials))),
appointment_fee_rials: String(tomanToRial(Number(values.appointment_fee_rials))),
});
// Ctrl/Cmd+S saves
useEffect(() => {
const h = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') {
e.preventDefault();
if (isDirty && !mutation.isPending) handleSubmit(onSubmit)();
}
};
window.addEventListener('keydown', h);
return () => window.removeEventListener('keydown', h);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isDirty, mutation.isPending]);
const paymentTestMode = watch('payment_test_mode') === '1';
const mellatEnabled = watch('mellat_enabled') === '1';
const mellatSandbox = watch('mellat_sandbox') === '1';
const sepEnabled = watch('sep_enabled') === '1';
const apptCommissionEnabled = watch('appointment_commission_enabled') === '1';
const upgradeCommissionEnabled = watch('upgrade_commission_enabled') === '1';
const taxEnabled = watch('tax_enabled') === '1';
const altchaEnabled = watch('altcha_enabled') === '1';
const toggle = (name: keyof FormValues, current: boolean) =>
setValue(name, current ? '0' : '1', { shouldDirty: true });
// search filters the nav rail
const q = search.trim();
const filtered = useMemo(
() => q === '' ? SECTIONS : SECTIONS.filter(s => (s.label + ' ' + s.desc + ' ' + s.keywords).includes(q)),
[q],
);
const current = filtered.find(s => s.id === active) ?? filtered[0] ?? SECTIONS[0];
if (isLoading) {
return (
<div className="fade-in settings-layout">
<div className="card card-pad"><div className="skeleton" style={{ height: 220, borderRadius: 'var(--r)' }} /></div>
<div className="card card-pad"><div className="skeleton" style={{ height: 360, borderRadius: 'var(--r)' }} /></div>
</div>
);
}
return (
<div className="fade-in">
<div style={{ marginBottom: 'var(--gap)' }}>
<h1 className="section-title">تنظیمات</h1>
<div className="muted" style={{ fontSize: 13, marginTop: 3 }}>پیکربندی کلی پلتفرم تغییرات پس از ذخیره اعمال می‌شوند</div>
</div>
<form onSubmit={handleSubmit(onSubmit)}>
<div className="settings-layout">
{/* ── نوار ناوبری کناری ── */}
<aside className="settings-nav">
<div className="settings-search">
<MagnifyingGlassIcon style={{ width: 16, height: 16 }} />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="جستجوی تنظیمات..."
aria-label="جستجوی تنظیمات"
/>
</div>
<nav className="settings-nav-list" aria-label="بخش‌های تنظیمات">
{filtered.map((s) => (
<button
key={s.id}
type="button"
className={`settings-nav-item${current.id === s.id ? ' active' : ''}`}
aria-current={current.id === s.id ? 'page' : undefined}
onClick={() => setActive(s.id)}
>
<s.Icon />
{s.label}
<span className="dot" style={{ background: s.fg }} />
</button>
))}
{filtered.length === 0 && <div className="settings-nav-empty">موردی یافت نشد</div>}
</nav>
</aside>
{/* ── محتوای بخش فعال ── */}
<div className="card card-pad">
<SectionHead s={current} />
{/* عمومی */}
{current.id === 'general' && (
<>
<div className="settings-grid">
<Field label="نام سایت" required error={errors.site_name?.message}
hint="عنوانی که در سربرگ سایت و پیامک‌ها استفاده می‌شود.">
<input {...register('site_name')} className={`input${errors.site_name ? ' err' : ''}`} placeholder="نام سایت" />
</Field>
<Field label="شماره پشتیبانی" optional hint="برای نمایش به کاربران در بخش تماس.">
<input {...register('support_phone')} className="input" dir="ltr" placeholder="021-12345678" />
</Field>
</div>
{/* کپچا (ALTCHA) */}
<div className={`toggle-row${altchaEnabled ? ' on' : ''}`} style={{ marginTop: 18 }}>
<div>
<div className="tr-title">کپچای امنیتی (ALTCHA)</div>
<div className="tr-desc">
محافظت فرم‌های ورود/ثبت‌نام در برابر بات. پیش‌فرض از متغیر محیطی سرور خوانده می‌شود؛ این کلید آن را override می‌کند.
</div>
</div>
<Toggle checked={altchaEnabled} onChange={() => toggle('altcha_enabled', altchaEnabled)} label="کپچای امنیتی ALTCHA" />
</div>
</>
)}
{/* نوبت‌دهی */}
{current.id === 'appointments' && (
<div className="settings-grid">
<Field label="مهلت مجاز لغو نوبت" hint="بیمار تا این تعداد ساعت پیش از نوبت اجازه لغو دارد.">
<div className="input-suffix">
<input {...numericField(register('max_cancel_hours_before'))} className="input" style={{ maxWidth: 130 }} />
<span className="suf">ساعت قبل</span>
</div>
</Field>
<Field label="ارسال یادآور نوبت" hint="پیامک یادآوری این تعداد ساعت پیش از نوبت ارسال می‌شود.">
<div className="input-suffix">
<input {...numericField(register('appointment_reminder_hours'))} className="input" style={{ maxWidth: 130 }} />
<span className="suf">ساعت قبل</span>
</div>
</Field>
</div>
)}
{/* مالی */}
{current.id === 'financial' && (
<>
<div className="field-hint" style={{ padding: '12px 14px', background: 'var(--info-bg)', borderRadius: 'var(--r-sm)', color: 'var(--text-2)', marginBottom: 18 }}>
ترتیب کسرها: ابتدا هزینه پنل پیامک، سپس مالیات بر ارزش افزوده (استخراجی از مبلغ شامل مالیات)، و در نهایت پورسانت نماینده از مبلغِ خالصِ پس از مالیات.
</div>
{/* پورسانت نوبت */}
<div className={`toggle-row${apptCommissionEnabled ? ' on' : ''}`}>
<div>
<div className="tr-title">پورسانت نوبت نمایندگان</div>
<div className="tr-desc">درصد از پروفایل هر نماینده خوانده می‌شود.</div>
</div>
<Toggle checked={apptCommissionEnabled} onChange={() => toggle('appointment_commission_enabled', apptCommissionEnabled)} label="پورسانت نوبت نمایندگان" />
</div>
{/* پورسانت ارتقاء */}
<div className={`toggle-row${upgradeCommissionEnabled ? ' on' : ''}`}>
<div style={{ flex: 1 }}>
<div className="tr-title">پورسانت ارتقاء اشتراک</div>
<div className="tr-desc">درصد پورسانت هنگام ارتقاء اشتراک نمایندگان.</div>
{upgradeCommissionEnabled && (
<div className="toggle-sub">
<div className="input-suffix">
<input {...numericField(register('upgrade_commission_percent'), 3)} className="input" style={{ maxWidth: 120 }} />
<span className="suf">درصد (۰–۱۰۰)</span>
</div>
</div>
)}
</div>
<Toggle checked={upgradeCommissionEnabled} onChange={() => toggle('upgrade_commission_enabled', upgradeCommissionEnabled)} label="پورسانت ارتقاء اشتراک" />
</div>
{/* مالیات */}
<div className={`toggle-row${taxEnabled ? ' on' : ''}`}>
<div style={{ flex: 1 }}>
<div className="tr-title">مالیات بر ارزش افزوده</div>
<div className="tr-desc">از مبلغ تراکنش‌ها کسر و در تاریخچه ثبت می‌شود.</div>
{taxEnabled && (
<div className="toggle-sub">
<div className="input-suffix">
<input {...numericField(register('tax_percent'), 3)} className="input" style={{ maxWidth: 120 }} />
<span className="suf">درصد (۰–۱۰۰)</span>
</div>
</div>
)}
</div>
<Toggle checked={taxEnabled} onChange={() => toggle('tax_enabled', taxEnabled)} label="مالیات بر ارزش افزوده" />
</div>
{taxHistory.length > 0 && (
<div style={{ marginTop: 16 }}>
<div style={{ fontSize: 12.5, fontWeight: 700, marginBottom: 8, color: 'var(--text-2)' }}>تاریخچه تغییرات مالیات</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, maxWidth: 480 }}>
{taxHistory.map((h, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, fontSize: 12.5, padding: '7px 11px', borderRadius: 'var(--r-sm)', background: 'var(--surface-2)' }}>
<span>{h.enabled ? `${h.tax_percent}٪` : 'غیرفعال'}{h.changed_by_name && <span className="muted"> {h.changed_by_name}</span>}</span>
<span className="muted">{formatDateTime(h.changed_at)}</span>
</div>
))}
</div>
</div>
)}
<div className="settings-grid" style={{ marginTop: 18 }}>
<Field label="مبلغ هر نوبت" hint="مبلغی که بیمار هنگام رزرو آنلاین پرداخت می‌کند (تومان).">
<div className="input-suffix">
<input {...numericField(register('appointment_fee_rials'))} className="input" style={{ maxWidth: 200 }} placeholder="15000" />
<span className="suf">تومان</span>
</div>
</Field>
<Field label="هزینه ثابت پنل پیامک" hint="از مبلغ هر تراکنش کسر می‌شود (تومان).">
<div className="input-suffix">
<input {...numericField(register('sms_panel_fee_rials'))} className="input" style={{ maxWidth: 200 }} placeholder="150000" />
<span className="suf">تومان</span>
</div>
</Field>
</div>
</>
)}
{/* درگاه پرداخت */}
{current.id === 'payment' && (
<>
<div className={`toggle-row warn${paymentTestMode ? ' on' : ''}`} style={{ marginBottom: 18 }}>
<div>
<div className="tr-title">{paymentTestMode ? 'حالت تست فعال' : 'حالت تست غیرفعال'}</div>
<div className="tr-desc">
{paymentTestMode
? 'همه پرداخت‌ها از درگاه آزمایشی رد می‌شوند (پول واقعی کسر نمی‌شود).'
: 'پرداخت‌ها از درگاه واقعی انجام می‌شوند.'}
</div>
</div>
<Toggle checked={paymentTestMode} onChange={() => toggle('payment_test_mode', paymentTestMode)} label="حالت تست پرداخت" />
</div>
{/* ملت */}
<div className={`gw-card${mellatEnabled ? '' : ' off'}`}>
<div className="gw-head">
<div className="gw-name"><span className="dot" style={{ background: 'var(--violet)' }} />درگاه ملت (Mellat)</div>
<div className="gw-state" style={{ color: mellatEnabled ? 'var(--success)' : 'var(--text-3)' }}>
{mellatEnabled ? 'فعال' : 'غیرفعال'}
<Toggle checked={mellatEnabled} onChange={() => toggle('mellat_enabled', mellatEnabled)} label="فعال‌سازی درگاه ملت" />
</div>
</div>
<div className="settings-grid" style={{ gridTemplateColumns: '1fr 1fr 1fr' }}>
<Field label="شناسه پایانه">
<input {...register('mellat_terminal_id')} className="input" dir="ltr" placeholder="12345678" />
</Field>
<Field label="نام کاربری">
<input {...register('mellat_username')} className="input" dir="ltr" placeholder="username" />
</Field>
<Field label="رمز عبور">
<div style={{ position: 'relative' }}>
<input {...register('mellat_password')} type={showMellatPassword ? 'text' : 'password'} className="input" dir="ltr" placeholder="••••••••" style={{ paddingLeft: 44 }} />
<button type="button" onClick={() => setShowMellatPassword(p => !p)} aria-label={showMellatPassword ? 'پنهان کردن رمز' : 'نمایش رمز'}
style={{ position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)', background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-3)', display: 'grid', placeItems: 'center' }}>
{showMellatPassword ? <EyeSlashIcon style={{ width: 17, height: 17 }} /> : <EyeIcon style={{ width: 17, height: 17 }} />}
</button>
</div>
</Field>
</div>
<div className="settings-grid" style={{ gridTemplateColumns: '1fr auto', alignItems: 'end', marginTop: 12 }}>
<Field label="آدرس WSDL (اختیاری — خالی = عملیاتی)">
<input {...register('mellat_wsdl_url')} className="input" dir="ltr" placeholder="https://bpm.shaparak.ir/pgwchannel/services/pgw?wsdl" />
</Field>
<div className="gw-state" style={{ color: mellatSandbox ? 'var(--warning)' : 'var(--text-3)' }}>
{mellatSandbox ? 'Sandbox (آزمایشگاه)' : 'واقعی'}
<Toggle checked={mellatSandbox} onChange={() => toggle('mellat_sandbox', mellatSandbox)} label="حالت Sandbox ملت" />
</div>
</div>
</div>
{/* سپ */}
<div className={`gw-card${sepEnabled ? '' : ' off'}`}>
<div className="gw-head">
<div className="gw-name"><span className="dot" style={{ background: 'var(--success)' }} />درگاه سپ (SEP)</div>
<div className="gw-state" style={{ color: sepEnabled ? 'var(--success)' : 'var(--text-3)' }}>
{sepEnabled ? 'فعال' : 'غیرفعال'}
<Toggle checked={sepEnabled} onChange={() => toggle('sep_enabled', sepEnabled)} label="فعال‌سازی درگاه سپ" />
</div>
</div>
<div className="settings-grid one" style={{ maxWidth: 260 }}>
<Field label="شناسه پایانه">
<input {...register('sep_terminal_id')} className="input" dir="ltr" placeholder="12345678" />
</Field>
</div>
</div>
</>
)}
{/* پیامک */}
{current.id === 'sms' && (
<div className="settings-grid one">
<Field label="کلید API کاوه‌نگار"
hint="کلید API فقط از طریق متغیر محیطی سرور (KAVENEGAR_API_KEY) مدیریت می‌شود و در این پنل قابل ویرایش نیست.">
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, fontWeight: 600 }}>
{settings?.sms_api_key_configured
? <><CheckCircleIcon style={{ width: 18, height: 18, color: 'var(--success)' }} /><span style={{ color: 'var(--success)' }}>تنظیم‌شده</span></>
: <><ExclamationTriangleIcon style={{ width: 18, height: 18, color: 'var(--danger)' }} /><span style={{ color: 'var(--danger)' }}>تنظیم‌نشده مقدار KAVENEGAR_API_KEY را در فایل env قرار دهید</span></>}
</div>
</Field>
<Field label="هزینه هر پیامک" hint="مبلغ کسرشده از کیف پول به ازای هر پیامک ارسالی (تومان). مبنای محاسبهٔ تعداد پیامک از موجودی.">
<input {...numericField(register('sms_price_rials'))} className="input" style={{ maxWidth: 200 }} placeholder="50" />
</Field>
</div>
)}
</div>
</div>
{/* ── نوار ذخیره چسبان ── */}
{(isDirty || savedFlash) && (
<div className={`save-bar${!isDirty && savedFlash ? ' ok' : ''}`}>
{!isDirty && savedFlash ? (
<div className="sb-msg" style={{ color: 'var(--success)' }}>
<CheckCircleIcon style={{ width: 18, height: 18 }} />
تنظیمات با موفقیت ذخیره شد
</div>
) : (
<>
<div className="sb-msg">
<ExclamationTriangleIcon style={{ width: 17, height: 17, color: 'var(--warning)' }} />
تغییرات ذخیره‌نشده دارید
</div>
<div className="sb-actions">
<button type="button" className="btn ghost sm" disabled={mutation.isPending}
onClick={() => settings && reset(toForm(settings))}>
بازگردانی
</button>
<button type="submit" className="btn primary sm" disabled={mutation.isPending}>
{mutation.isPending ? 'در حال ذخیره...' : 'ذخیره تغییرات'}
</button>
</div>
</>
)}
</div>
)}
</form>
</div>
);
}