admin/app/(dashboard)/notifications/_components/NotificationRulesPanel.tsx
alisaza 96603d31f7 feat(alert-modal): integrate alert modal for confirmation actions
Enhanced various components to utilize the alert modal for user confirmations before executing critical actions. This includes marking messages as read, saving notification rules, restoring reviews, sending replies, changing ticket statuses, and managing event commissions. The integration improves user experience by ensuring actions are intentional and provides clear feedback on the outcomes.
2026-09-13 10:14:18 +03:30

336 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { coerceToString } from '@/helpers'
import { addToast } from '@/lib/toast'
import { renderNotificationActionUrlPreview, renderNotificationTemplate } from '@/lib/notificationTemplate'
import { countSmsSegments } from '@/lib/smsSegments'
import Button from '@/components/formElements/Button'
import Input from '@/components/formElements/Input'
import AdminState from '@/components/feedback/AdminState'
import axiosInstance from '@/config/axios'
import useAlertModal from '@/hooks/useAlertModal'
import { unwrapApiData, type ApiSuccessBody } from '@/services/apiResponse'
import { API_ROUTES } from '@/services/config'
import { extractServerErrorDetail } from '@/services/errorHandler'
type Channel = 'in_app' | 'sms' | 'both'
interface Rule {
eventKey: string
displayName: string
description: string
enabled: boolean
channel: Channel
titleTemplate: string
bodyTemplate: string
actionUrlTemplate: string | null
allowedVariables: string[]
sampleVariables: Record<string, string>
smsAllowed: boolean
updatedAt: string
}
type RuleDraft = Pick<Rule, 'enabled' | 'channel' | 'titleTemplate' | 'bodyTemplate'> & {
actionUrlTemplate: string
}
const CHANNELS: { code: Channel; name: string }[] = [
{ code: 'in_app', name: 'درون‌برنامه‌ای + پوش' },
{ code: 'sms', name: 'پیامک' },
{ code: 'both', name: 'درون‌برنامه‌ای + پوش + پیامک' },
]
const toDraft = (rule: Rule): RuleDraft => ({
enabled: rule.enabled,
channel: rule.channel,
titleTemplate: rule.titleTemplate,
bodyTemplate: rule.bodyTemplate,
actionUrlTemplate: rule.actionUrlTemplate ?? '',
})
const isDirtyDraft = (current: RuleDraft, saved: RuleDraft | undefined) =>
Boolean(saved) && JSON.stringify(current) !== JSON.stringify(saved)
const NotificationRulesPanel = () => {
const { showAlert } = useAlertModal()
const [rules, setRules] = useState<Rule[]>([])
const [savedDrafts, setSavedDrafts] = useState<Record<string, RuleDraft>>({})
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [saving, setSaving] = useState<string | null>(null)
const load = useCallback(async () => {
setLoading(true)
setError(null)
try {
const response = await axiosInstance.get(API_ROUTES.NOTIFICATION_RULES.ADMIN_LIST)
const payload = unwrapApiData<Rule[]>(response.data as ApiSuccessBody<Rule[]>)
const nextRules = Array.isArray(payload) ? payload : []
setRules(nextRules)
setSavedDrafts(Object.fromEntries(nextRules.map((rule) => [rule.eventKey, toDraft(rule)])))
} catch {
setError('دریافت تنظیمات اعلان‌ها ناموفق بود.')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void load()
}, [load])
const change = (eventKey: string, patch: Partial<Rule>) => {
setRules((current) => current.map((rule) => (rule.eventKey === eventKey ? { ...rule, ...patch } : rule)))
}
const save = async (rule: Rule) => {
setSaving(rule.eventKey)
try {
const response = await axiosInstance.patch(API_ROUTES.NOTIFICATION_RULES.ADMIN_UPDATE(rule.eventKey), {
enabled: rule.enabled,
channel: rule.channel,
titleTemplate: rule.titleTemplate,
bodyTemplate: rule.bodyTemplate,
actionUrlTemplate: rule.actionUrlTemplate ?? '',
})
const updated = unwrapApiData<Rule>(response.data as ApiSuccessBody<Rule>)
if (updated) {
setRules((current) => current.map((item) => (item.eventKey === rule.eventKey ? { ...item, ...updated } : item)))
setSavedDrafts((current) => ({ ...current, [rule.eventKey]: toDraft({ ...rule, ...updated }) }))
}
addToast({ title: 'تنظیمات اعلان ذخیره شد', color: 'success' })
} catch (saveError) {
addToast({
title:
extractServerErrorDetail((saveError as { response?: { data?: unknown } })?.response?.data) ?? 'ذخیره تنظیمات اعلان ناموفق بود',
color: 'danger',
})
} finally {
setSaving(null)
}
}
const confirmSave = (rule: Rule) => {
showAlert(`تغییرات قاعدهٔ «${rule.displayName}» ذخیره شود؟`, () => {
void save(rule)
})
}
return (
<div className="space-y-4">
<div className="rounded-2xl border border-blue-100 bg-blue-50 p-4 text-sm leading-7 text-blue-900">
متغیرهای داخل دو آکولاد مثل <span dir="ltr">{'{{eventTitle}}'}</span> هنگام ارسال با اطلاعات واقعی جایگزین میشوند. فقط متغیرهای
همان رویداد مجاز است. اعلان درونبرنامهای در صورت فعالبودن اشتراک مرورگر، Web Push هم ارسال میکند.
</div>
{loading ? <p className="py-10 text-center text-sm text-secondary-30">در حال دریافت تنظیمات</p> : null}
{!loading && error ? (
<AdminState
actionLabel="تلاش دوباره"
description={error}
title="خطا"
variant="error"
onAction={() => void load()}
/>
) : null}
{!loading && !error && rules.length === 0 ? (
<AdminState
description="هنوز قاعدهٔ اعلانی برای ویرایش وجود ندارد."
title="تنظیماتی یافت نشد"
/>
) : null}
{rules.map((rule) => (
<RuleCard
key={rule.eventKey}
dirty={isDirtyDraft(toDraft(rule), savedDrafts[rule.eventKey])}
rule={rule}
saving={saving === rule.eventKey}
onChange={change}
onSave={() => {
confirmSave(rule)
}}
/>
))}
</div>
)
}
const RuleCard = ({
dirty,
rule,
saving,
onChange,
onSave,
}: {
dirty: boolean
rule: Rule
saving: boolean
onChange: (eventKey: string, patch: Partial<Rule>) => void
onSave: () => void
}) => {
const channelOptions = useMemo(() => {
const allowed = CHANNELS.filter((channel) => channel.code === 'in_app' || rule.smsAllowed)
if (allowed.some((channel) => channel.code === rule.channel)) return allowed
const current = CHANNELS.find((channel) => channel.code === rule.channel)
return current ? [...allowed, current] : allowed
}, [rule.channel, rule.smsAllowed])
const includesSms = rule.channel === 'sms' || rule.channel === 'both'
const smsCount = includesSms ? countSmsSegments(rule.bodyTemplate) : null
const previewTitle = renderNotificationTemplate(rule.titleTemplate, rule.sampleVariables)
const previewBody = renderNotificationTemplate(rule.bodyTemplate, rule.sampleVariables)
const previewActionUrl = rule.actionUrlTemplate ? renderNotificationActionUrlPreview(rule.actionUrlTemplate, rule.sampleVariables) : ''
return (
<article className="admin-surface space-y-4 p-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="flex flex-wrap items-center gap-2">
<h2 className="font-bold text-secondary-10">{rule.displayName}</h2>
{dirty ? (
<span
className="rounded-full bg-fourth-900/15 px-2 py-0.5 text-xs text-fourth-900"
role="status"
>
ذخیره نشده
</span>
) : null}
</div>
<p className="mt-1 text-sm text-secondary-30">{rule.description}</p>
<code className="mt-2 inline-block text-xs text-secondary-30">{rule.eventKey}</code>
</div>
<Input
generalType="switch"
label={rule.enabled ? 'فعال' : 'غیرفعال'}
name={`enabled-${rule.eventKey}`}
value={rule.enabled}
onValueChange={(enabled) => {
onChange(rule.eventKey, { enabled: Boolean(enabled) })
}}
/>
</div>
{rule.allowedVariables.length === 0 ? (
<p className="text-xs text-secondary-30">این رویداد متغیر قابلجایگزینی ندارد.</p>
) : (
<div>
<p className="mb-2 text-xs text-secondary-30">متغیرهای مجاز</p>
<div className="flex flex-wrap gap-2">
{rule.allowedVariables.map((variable) => (
<code
key={variable}
className="rounded-lg bg-surface-secondary px-2 py-1 text-xs text-secondary-30"
dir="ltr"
>
{`{{${variable}}}`}
</code>
))}
</div>
</div>
)}
<Input
description={rule.smsAllowed ? undefined : 'پیامک برای پیام‌های چت ارسال نمی‌شود؛ فقط اعلان درون‌برنامه‌ای و پوش.'}
generalType="select"
label="کانال ارسال"
name={`channel-${rule.eventKey}`}
selectKey="code"
selectOptions={channelOptions}
selectValue="name"
value={rule.channel}
variant="bordered"
onValueChange={(next) => {
if (next) onChange(rule.eventKey, { channel: coerceToString(next) as Channel })
}}
/>
{rule.channel === 'sms' ? (
<p
className="rounded-2xl border border-fourth-900/40 bg-fourth-900/10 p-3 text-sm leading-6 text-fourth-900"
role="status"
>
با انتخاب فقط پیامک، اعلان درونبرنامهای و پوش ارسال نمیشود.
</p>
) : null}
{!rule.smsAllowed && rule.channel !== 'in_app' ? (
<p
className="rounded-2xl border border-fourth-900/40 bg-fourth-900/10 p-3 text-sm leading-6 text-fourth-900"
role="alert"
>
پیامک برای این رویداد مجاز نیست. کانال را به درونبرنامهای برگردانید.
</p>
) : null}
<Input
generalType="input"
label="عنوان"
name={`title-${rule.eventKey}`}
value={rule.titleTemplate}
onValueChange={(titleTemplate) => {
onChange(rule.eventKey, { titleTemplate: coerceToString(titleTemplate) })
}}
/>
<Input
description={
smsCount
? `${smsCount.chars.toLocaleString('fa-IR')} کاراکتر · ${smsCount.segments.toLocaleString('fa-IR')} پیامک${
smsCount.segments > 1 ? ' — متن را کوتاه نگه دارید' : ''
}`
: undefined
}
generalType="textarea"
label="متن اعلان"
name={`body-${rule.eventKey}`}
value={rule.bodyTemplate}
onValueChange={(bodyTemplate) => {
onChange(rule.eventKey, { bodyTemplate: coerceToString(bodyTemplate) })
}}
/>
<Input
direction="ltr"
generalType="input"
label="مسیر مقصد پس از کلیک"
name={`actionUrl-${rule.eventKey}`}
value={rule.actionUrlTemplate ?? ''}
onValueChange={(actionUrlTemplate) => {
onChange(rule.eventKey, { actionUrlTemplate: coerceToString(actionUrlTemplate) })
}}
/>
<div className="rounded-2xl border border-border bg-surface-secondary p-4">
<p className="text-xs text-muted">پیشنمایش با دادهٔ نمونه</p>
<p className="mt-2 font-bold text-foreground">{previewTitle || '—'}</p>
<p className="mt-1 whitespace-pre-wrap text-sm leading-7 text-foreground">{previewBody || '—'}</p>
{previewActionUrl ? (
<code
className="mt-2 block text-xs text-muted"
dir="ltr"
>
{previewActionUrl}
</code>
) : null}
</div>
<div className="flex justify-end">
<Button
disabled={!dirty}
isLoading={saving}
variant="solid"
onClick={onSave}
>
ذخیره
</Button>
</div>
</article>
)
}
export default NotificationRulesPanel