From 0eb19bbebde3a3b39a2a7147ce67c098a18ca61c Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Tue, 8 Sep 2026 16:24:26 +0330 Subject: [PATCH] Add SMS templates UI, prepaid balance settings, and list filter layout. Business settings manage templates and balance; Super Admin verifies templates and tops up via SMS settings; filters use a separate panel with even field gaps. Co-authored-by: Cursor --- apps/business/src/App.tsx | 2 +- .../components/ConfirmDeleteModal.module.css | 2 +- .../components/EPaymentSettingsSection.tsx | 465 +++++++++++++++ .../src/components/SendCustomerSmsModal.tsx | 69 ++- .../src/components/SmsSettingsSection.tsx | 536 ++++++++++++++++++ apps/business/src/i18n/messages.ts | 106 +++- .../src/pages/SettingsPage.module.css | 309 ++++++++++ apps/business/src/pages/SettingsPage.tsx | 467 +-------------- .../src/pages/WebsiteSettingsPage.module.css | 4 + .../src/pages/WebsiteSettingsPage.tsx | 6 + apps/business/src/services/customerService.ts | 18 +- apps/business/src/services/settingsService.ts | 5 + .../src/services/smsTemplateService.ts | 75 +++ apps/super-admin/src/App.tsx | 2 + .../BusinessActionsOverflowMenu.tsx | 17 +- apps/super-admin/src/components/Sidebar.tsx | 3 +- .../src/pages/BusinessesPage.module.css | 37 +- apps/super-admin/src/pages/BusinessesPage.tsx | 167 +++++- apps/super-admin/src/pages/HomePage.tsx | 9 +- .../src/pages/SmsTemplatesPage.tsx | 441 ++++++++++++++ .../src/pages/TemplatesPage.module.css | 24 +- apps/super-admin/src/pages/UsersPage.tsx | 6 +- apps/super-admin/src/pages/WebsitesPage.tsx | 2 +- .../src/services/businessService.ts | 1 + .../src/services/smsTemplateService.ts | 61 ++ apps/super-admin/src/types/business.ts | 2 + docs/PROJECT_CONTEXT.md | 15 +- 27 files changed, 2324 insertions(+), 527 deletions(-) create mode 100644 apps/business/src/components/EPaymentSettingsSection.tsx create mode 100644 apps/business/src/components/SmsSettingsSection.tsx create mode 100644 apps/business/src/services/smsTemplateService.ts create mode 100644 apps/super-admin/src/pages/SmsTemplatesPage.tsx create mode 100644 apps/super-admin/src/services/smsTemplateService.ts diff --git a/apps/business/src/App.tsx b/apps/business/src/App.tsx index 6108a95..d6e4561 100644 --- a/apps/business/src/App.tsx +++ b/apps/business/src/App.tsx @@ -167,7 +167,7 @@ function App() { } /> } /> } /> - } /> + } /> diff --git a/apps/business/src/components/ConfirmDeleteModal.module.css b/apps/business/src/components/ConfirmDeleteModal.module.css index 120c62b..3019203 100644 --- a/apps/business/src/components/ConfirmDeleteModal.module.css +++ b/apps/business/src/components/ConfirmDeleteModal.module.css @@ -7,7 +7,7 @@ display: flex; align-items: center; justify-content: center; - z-index: 200; + z-index: 1100; padding: 20px; } diff --git a/apps/business/src/components/EPaymentSettingsSection.tsx b/apps/business/src/components/EPaymentSettingsSection.tsx new file mode 100644 index 0000000..f4fd919 --- /dev/null +++ b/apps/business/src/components/EPaymentSettingsSection.tsx @@ -0,0 +1,465 @@ +import { useEffect, useState, type FormEvent } from 'react' +import { Eye, EyeOff } from 'lucide-react' +import { Switch } from './Switch' +import { Tooltip } from './Tooltip' +import { useToast } from '../context/ToastContext' +import { useT } from '../i18n/useT' +import type { BusinessMessageKey } from '../i18n/messages' +import { ApiError } from '../lib/api' +import { + DEFAULT_EPAYMENT_SETTINGS, + getSettings, + updateStoreSettings, + type EPaymentSettings, + type PaymentGatewayId, +} from '../services/settingsService' +import sharedStyles from '../pages/ProductSettingsPage.module.css' +import fieldStyles from '../pages/AddNewProductPage.module.css' +import styles from '../pages/SettingsPage.module.css' + +const COMING_SOON_GATEWAYS: { + id: Exclude + nameKey: BusinessMessageKey + descKey: BusinessMessageKey +}[] = [ + { + id: 'sep', + nameKey: 'website.ePayment.gateway.sep', + descKey: 'website.ePayment.gateway.sepDesc', + }, + { + id: 'snappay', + nameKey: 'website.ePayment.gateway.snappay', + descKey: 'website.ePayment.gateway.snappayDesc', + }, + { + id: 'digipay', + nameKey: 'website.ePayment.gateway.digipay', + descKey: 'website.ePayment.gateway.digipayDesc', + }, +] + +function nextDefaultGateway( + current: EPaymentSettings, + gatewayId: PaymentGatewayId, + enabled: boolean, +): PaymentGatewayId | null { + if (enabled && !current.defaultGateway) return gatewayId + if (!enabled && current.defaultGateway === gatewayId) { + if (gatewayId !== 'mellat' && current.gateways.mellat.enabled) return 'mellat' + if (gatewayId !== 'zarinpal' && current.gateways.zarinpal.enabled) { + return 'zarinpal' + } + return null + } + return current.defaultGateway +} + +export function EPaymentSettingsSection() { + const t = useT() + const { showToast } = useToast() + const [ePayment, setEPayment] = useState(DEFAULT_EPAYMENT_SETTINGS) + const [passwordDraft, setPasswordDraft] = useState('') + const [showPassword, setShowPassword] = useState(false) + const [isLoading, setIsLoading] = useState(true) + const [savingKey, setSavingKey] = useState(null) + const [error, setError] = useState('') + + useEffect(() => { + const controller = new AbortController() + void loadSettings(controller.signal) + return () => controller.abort() + }, []) + + async function loadSettings(signal?: AbortSignal) { + setIsLoading(true) + setError('') + + try { + const data = await getSettings(signal) + setEPayment(data.settings.store.ePayment ?? DEFAULT_EPAYMENT_SETTINGS) + setPasswordDraft('') + } catch (err) { + if (err instanceof DOMException && err.name === 'AbortError') return + if (err instanceof ApiError) { + setError(err.message) + } else { + setError(t('productSettings.errorLoad')) + } + } finally { + setIsLoading(false) + } + } + + async function patchEPayment( + saving: string, + next: EPaymentSettings, + password?: string, + ) { + setSavingKey(saving) + setError('') + + try { + const payload: EPaymentSettings = { + enabled: next.enabled, + defaultGateway: next.defaultGateway, + gateways: { + mellat: { + enabled: next.gateways.mellat.enabled, + terminalId: next.gateways.mellat.terminalId, + username: next.gateways.mellat.username, + password: password ?? '', + }, + sep: { enabled: next.gateways.sep.enabled }, + snappay: { enabled: next.gateways.snappay.enabled }, + digipay: { enabled: next.gateways.digipay.enabled }, + zarinpal: { + enabled: next.gateways.zarinpal.enabled, + merchantId: next.gateways.zarinpal.merchantId.trim(), + sandbox: next.gateways.zarinpal.sandbox, + }, + }, + } + + const data = await updateStoreSettings({ ePayment: payload }) + setEPayment(data.settings.store.ePayment ?? DEFAULT_EPAYMENT_SETTINGS) + if (password) setPasswordDraft('') + showToast(t('productSettings.saved'), 'success') + } catch (err) { + if (err instanceof ApiError) { + setError(err.message) + } else { + setError(t('productSettings.errorSave')) + } + } finally { + setSavingKey(null) + } + } + + async function handleMasterToggle(checked: boolean) { + await patchEPayment('master', { + ...ePayment, + enabled: checked, + defaultGateway: checked + ? ePayment.defaultGateway ?? + (ePayment.gateways.mellat.enabled + ? 'mellat' + : ePayment.gateways.zarinpal.enabled + ? 'zarinpal' + : 'mellat') + : ePayment.defaultGateway, + }) + } + + async function handleMellatToggle(checked: boolean) { + await patchEPayment('mellat-enabled', { + ...ePayment, + enabled: checked ? true : ePayment.enabled, + gateways: { + ...ePayment.gateways, + mellat: { ...ePayment.gateways.mellat, enabled: checked }, + }, + defaultGateway: nextDefaultGateway(ePayment, 'mellat', checked), + }) + } + + async function handleZarinpalToggle(checked: boolean) { + await patchEPayment('zarinpal-enabled', { + ...ePayment, + enabled: checked ? true : ePayment.enabled, + gateways: { + ...ePayment.gateways, + zarinpal: { ...ePayment.gateways.zarinpal, enabled: checked }, + }, + defaultGateway: nextDefaultGateway(ePayment, 'zarinpal', checked), + }) + } + + async function handleSaveMellatCredentials(event: FormEvent) { + event.preventDefault() + const terminalId = ePayment.gateways.mellat.terminalId.trim() + const username = ePayment.gateways.mellat.username.trim() + + if (ePayment.gateways.mellat.enabled && (!terminalId || !username)) { + setError(t('website.ePayment.error.credentials')) + return + } + + await patchEPayment( + 'mellat-credentials', + { + ...ePayment, + gateways: { + ...ePayment.gateways, + mellat: { + ...ePayment.gateways.mellat, + terminalId, + username, + }, + }, + }, + passwordDraft.trim() || undefined, + ) + } + + async function handleSaveZarinpalCredentials(event: FormEvent) { + event.preventDefault() + const merchantId = ePayment.gateways.zarinpal.merchantId.trim() + + if (ePayment.gateways.zarinpal.enabled && !merchantId) { + setError(t('website.ePayment.error.zarinpalMerchant')) + return + } + + await patchEPayment('zarinpal-credentials', { + ...ePayment, + gateways: { + ...ePayment.gateways, + zarinpal: { + ...ePayment.gateways.zarinpal, + merchantId, + }, + }, + }) + } + + if (isLoading) { + return

{t('productSettings.loading')}

+ } + + return ( +
+ {error ?
{error}
: null} + +
+

{t('title.ePayment')}

+
+
+
+ +

+ {t('website.ePayment.masterDesc')} +

+
+ void handleMasterToggle(checked)} + /> +
+ {!ePayment.enabled && + (ePayment.gateways.mellat.enabled || ePayment.gateways.zarinpal.enabled) ? ( +

+ {t('website.ePayment.masterOffHint')} +

+ ) : null} +
+
+ +
+
+
+

+ {t('website.ePayment.gateway.mellat')} +

+

+ {t('website.ePayment.gateway.mellatDesc')} +

+
+ void handleMellatToggle(checked)} + /> +
+ +
+
+ + + setEPayment((current) => ({ + ...current, + gateways: { + ...current.gateways, + mellat: { + ...current.gateways.mellat, + terminalId: e.target.value, + }, + }, + })) + } + /> +
+ +
+ + + setEPayment((current) => ({ + ...current, + gateways: { + ...current.gateways, + mellat: { + ...current.gateways.mellat, + username: e.target.value, + }, + }, + })) + } + /> +
+ +
+ +
+ setPasswordDraft(e.target.value)} + /> + + + +
+
+ +
+ +
+
+
+ +
+
+
+

+ {t('website.ePayment.gateway.zarinpal')} +

+

+ {t('website.ePayment.gateway.zarinpalDesc')} +

+
+ void handleZarinpalToggle(checked)} + /> +
+ +
+ + + setEPayment((current) => ({ + ...current, + gateways: { + ...current.gateways, + zarinpal: { + ...current.gateways.zarinpal, + merchantId: e.target.value, + }, + }, + })) + } + /> + +
+
+ +
+ {COMING_SOON_GATEWAYS.map((gateway) => ( +
+
+
+

{t(gateway.nameKey)}

+

{t(gateway.descKey)}

+
+ {t('website.ePayment.comingSoon')} +
+
+ ))} +
+
+ ) +} diff --git a/apps/business/src/components/SendCustomerSmsModal.tsx b/apps/business/src/components/SendCustomerSmsModal.tsx index 63ba239..508233b 100644 --- a/apps/business/src/components/SendCustomerSmsModal.tsx +++ b/apps/business/src/components/SendCustomerSmsModal.tsx @@ -3,13 +3,14 @@ import { createPortal } from 'react-dom' import { X } from 'lucide-react' import { useLocale } from '@meshkee/dashboard-ui' import { useT } from '../i18n/useT' -import { ApiError } from '../lib/api' +import { ApiError, isAbortError } from '../lib/api' import { formatCellForDisplay } from '../lib/cellNumber' import { sendCustomerSms, sendCustomersBulkSms, type BusinessCustomerListItem, } from '../services/customerService' +import { listSmsTemplates, type SmsTemplate } from '../services/smsTemplateService' import modalStyles from './VariationsModal.module.css' export type SmsComposeTarget = @@ -39,6 +40,9 @@ export function SendCustomerSmsModal({ const [mounted, setMounted] = useState(open) const [closing, setClosing] = useState(false) const [message, setMessage] = useState('') + const [templateId, setTemplateId] = useState('') + const [templates, setTemplates] = useState([]) + const [templatesLoading, setTemplatesLoading] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false) const [error, setError] = useState('') @@ -47,6 +51,7 @@ export function SendCustomerSmsModal({ setMounted(true) setClosing(false) setMessage('') + setTemplateId('') setError('') } else if (mounted) { setClosing(true) @@ -58,6 +63,24 @@ export function SendCustomerSmsModal({ } }, [open, target, mounted]) + useEffect(() => { + if (!open || !target) return + const controller = new AbortController() + setTemplatesLoading(true) + void listSmsTemplates(controller.signal) + .then((res) => { + if (!controller.signal.aborted) setTemplates(res.items) + }) + .catch((err) => { + if (isAbortError(err)) return + if (!controller.signal.aborted) setTemplates([]) + }) + .finally(() => { + if (!controller.signal.aborted) setTemplatesLoading(false) + }) + return () => controller.abort() + }, [open, target]) + useEffect(() => { if (!mounted || closing) return const onKey = (e: KeyboardEvent) => { @@ -69,16 +92,25 @@ export function SendCustomerSmsModal({ if (!mounted || !target) return null + const selectedTemplate = templates.find((item) => item.id === templateId) ?? null const trimmed = message.trim() const canSend = trimmed.length > 0 && trimmed.length <= MAX_LEN + function applyTemplate(nextId: string) { + setTemplateId(nextId) + if (!nextId) return + const tpl = templates.find((item) => item.id === nextId) + if (tpl) setMessage(tpl.body.slice(0, MAX_LEN)) + } + async function handleSubmit() { if (!target || !canSend) return setIsSubmitting(true) setError('') try { + const tplId = templateId || null if (target.mode === 'single') { - const result = await sendCustomerSms(target.customer.id, trimmed) + const result = await sendCustomerSms(target.customer.id, trimmed, tplId) if (!result.enabled) { setError(result.message || t('customers.smsModal.errorDisabled')) return @@ -90,7 +122,7 @@ export function SendCustomerSmsModal({ }) } else { const userIds = target.mode === 'selected' ? target.userIds : undefined - const result = await sendCustomersBulkSms(trimmed, userIds) + const result = await sendCustomersBulkSms(trimmed, userIds, tplId) if (!result.enabled) { setError(result.message || t('customers.smsModal.errorDisabled')) return @@ -154,6 +186,34 @@ export function SendCustomerSmsModal({
{error ?

{error}

: null} +
+ + + {templatesLoading ? ( +

{t('customers.smsModal.templateLoading')}

+ ) : null} + {selectedTemplate ? ( +

+ {selectedTemplate.isVerified + ? t('customers.smsModal.templateHintVerified') + : t('customers.smsModal.templateHintPending')} +

+ ) : null} +
+