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.
336 lines
12 KiB
TypeScript
336 lines
12 KiB
TypeScript
'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
|