Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
326 lines
12 KiB
TypeScript
326 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 { 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 [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)
|
||
}
|
||
}
|
||
|
||
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={() => void save(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
|