admin/app/(dashboard)/notifications/_components/SendManualNotificationModal.tsx
alisaza e1eaf5eff5 feat: initial ghabilee-admin backoffice app
Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js
app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
2026-09-05 13:12:59 +03:30

376 lines
14 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 { useEffect, useMemo, useState } from 'react'
import { addToast } from '@/lib/toast'
import Input from '@/components/formElements/Input'
import Button from '@/components/formElements/Button'
import Modal from '@/components/modals/Modal'
import axiosInstance from '@/config/axios'
import { coerceToString } from '@/helpers'
import { countSmsSegments } from '@/lib/smsSegments'
import { unwrapApiData, type ApiSuccessBody } from '@/services/apiResponse'
import { API_ROUTES } from '@/services/config'
import { extractServerErrorDetail } from '@/services/errorHandler'
type Channel = 'in_app' | 'sms' | 'both'
type FieldErrors = Partial<Record<'channel' | 'title' | 'body' | 'recipients' | 'actionUrl' | 'otpCode', string>>
const CHANNELS: { code: Channel; name: string }[] = [
{ code: 'in_app', name: 'درون‌برنامه‌ای و پوش' },
{ code: 'sms', name: 'پیامک' },
{ code: 'both', name: 'هر دو' },
]
const splitList = (value: string) =>
value
.split(/[\n,]+/)
.map((item) => item.trim())
.filter(Boolean)
const INTERNAL_PATH_PATTERN = /^\/(?!\/).*/
interface ManualNotificationResponse {
recipientCount: number
missingMobiles: string[]
missingUserIds: string[]
}
interface SendManualNotificationModalProps {
defaultChannel: Channel
isOpen: boolean
onClose: () => void
onSent: () => void
}
const SendManualNotificationModal = ({ defaultChannel, isOpen, onClose, onSent }: SendManualNotificationModalProps) => {
const [channel, setChannel] = useState<Channel>(defaultChannel)
const [title, setTitle] = useState('')
const [body, setBody] = useState('')
const [actionUrl, setActionUrl] = useState('')
const [mobilesText, setMobilesText] = useState('')
const [userIdsText, setUserIdsText] = useState('')
const [otpCode, setOtpCode] = useState('')
const [otpRequested, setOtpRequested] = useState(false)
const [otpExpiresIn, setOtpExpiresIn] = useState<number | null>(null)
const [errors, setErrors] = useState<FieldErrors>({})
const [isSubmitting, setIsSubmitting] = useState(false)
const [isRequestingOtp, setIsRequestingOtp] = useState(false)
const includesInApp = channel === 'in_app' || channel === 'both'
const includesSms = channel === 'sms' || channel === 'both'
const recipientsPreview = useMemo(
() => ({
mobiles: splitList(mobilesText),
userIds: splitList(userIdsText),
}),
[mobilesText, userIdsText]
)
const totalRecipients = recipientsPreview.mobiles.length + recipientsPreview.userIds.length
const requiresBulkOtp = includesSms && totalRecipients >= 2
const smsCount = includesSms ? countSmsSegments(body) : null
const resetForm = () => {
setChannel(defaultChannel)
setTitle('')
setBody('')
setActionUrl('')
setMobilesText('')
setUserIdsText('')
setOtpCode('')
setOtpRequested(false)
setOtpExpiresIn(null)
setErrors({})
}
useEffect(() => {
if (!isOpen) return
setChannel(defaultChannel)
setErrors({})
}, [defaultChannel, isOpen])
const handleClose = () => {
if (isSubmitting) return
resetForm()
onClose()
}
const validate = (requireOtp = true): FieldErrors => {
const nextErrors: FieldErrors = {}
if (!CHANNELS.some((item) => item.code === channel)) {
nextErrors.channel = 'کانال ارسال را انتخاب کنید'
}
if (includesInApp && !title.trim()) nextErrors.title = 'عنوان اعلان الزامی است'
if (!body.trim()) nextErrors.body = 'متن الزامی است'
if (recipientsPreview.mobiles.length === 0 && recipientsPreview.userIds.length === 0) {
nextErrors.recipients = 'حداقل یک موبایل یا شناسه کاربر وارد کنید'
}
if (includesInApp && actionUrl.trim() && !INTERNAL_PATH_PATTERN.test(actionUrl.trim())) {
nextErrors.actionUrl = 'لینک مقصد باید با / شروع شود و داخلی باشد'
}
if (requireOtp && requiresBulkOtp && !otpCode.trim()) {
nextErrors.otpCode = 'برای ارسال گروهی پیامک، کد تأیید الزامی است'
}
return nextErrors
}
const handleRequestOtp = async () => {
const nextErrors = validate(false)
if (nextErrors.recipients || nextErrors.body || nextErrors.channel) {
setErrors(nextErrors)
return
}
try {
setIsRequestingOtp(true)
const response = await axiosInstance.post(API_ROUTES.MANUAL_NOTIFICATIONS.ADMIN_REQUEST_OTP)
const payload = unwrapApiData<{ expiresIn?: number }>(response.data as ApiSuccessBody<{ expiresIn?: number }>)
setOtpRequested(true)
setOtpExpiresIn(Number(payload?.expiresIn ?? 300))
addToast({
title: 'کد تأیید ارسال شد',
description: 'کد به موبایل ادمین (همان اکانت ورود) فرستاده شد.',
color: 'success',
})
} catch (err) {
addToast({
title: 'ارسال کد ناموفق بود',
description: extractServerErrorDetail((err as { response?: { data?: unknown } })?.response?.data) ?? 'دوباره تلاش کنید.',
color: 'danger',
})
} finally {
setIsRequestingOtp(false)
}
}
const handleSubmit = async () => {
const nextErrors = validate()
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
try {
setIsSubmitting(true)
const response = await axiosInstance.post(API_ROUTES.MANUAL_NOTIFICATIONS.ADMIN_CREATE, {
channel,
title: includesInApp ? title.trim() : title.trim() || undefined,
body: body.trim(),
actionUrl: includesInApp ? actionUrl.trim() || undefined : undefined,
mobiles: recipientsPreview.mobiles,
userIds: recipientsPreview.userIds,
otpCode: requiresBulkOtp ? otpCode.trim() : undefined,
})
const payload = unwrapApiData<ManualNotificationResponse>(response.data as ApiSuccessBody<ManualNotificationResponse>)
const recipientCount = Number(payload?.recipientCount ?? 0)
const missingMobiles = Array.isArray(payload?.missingMobiles) ? payload.missingMobiles.length : 0
const missingUserIds = Array.isArray(payload?.missingUserIds) ? payload.missingUserIds.length : 0
const missingCount = missingMobiles + missingUserIds
addToast({
title: 'ارسال انجام شد',
description:
recipientCount > 0
? `${recipientCount} گیرنده پیدا شد${missingCount > 0 ? `؛ ${missingCount} ورودی هم پیدا نشد.` : '.'}`
: 'هیچ گیرنده‌ای resolve نشد.',
color: recipientCount > 0 ? 'success' : 'warning',
})
resetForm()
onSent()
} catch (err) {
addToast({
title: 'ارسال ناموفق بود',
description:
extractServerErrorDetail((err as { response?: { data?: unknown } })?.response?.data) ??
'مقادیر ورودی را بررسی کنید و دوباره تلاش کنید.',
color: 'danger',
})
} finally {
setIsSubmitting(false)
}
}
return (
<Modal
acceptBtnText="ارسال"
isLoading={isSubmitting}
isOpen={isOpen}
rejectBtnText="انصراف"
size="2xl"
title="ارسال اعلان"
onAccept={() => void handleSubmit()}
onOpenChange={(open) => {
if (!open) handleClose()
}}
onReject={handleClose}
>
<div className="flex flex-col gap-4">
<Input
generalType="select"
label="کانال ارسال"
name="channel"
selectKey="code"
selectOptions={CHANNELS}
selectValue="name"
value={channel}
variant="bordered"
onValueChange={(next) => {
if (next === 'in_app' || next === 'sms' || next === 'both') {
setChannel(next)
setOtpCode('')
setOtpRequested(false)
setOtpExpiresIn(null)
}
}}
/>
{errors.channel && <span className="text-tiny text-fourth-900">{errors.channel}</span>}
<p className="text-text-muted text-xs leading-6">
{channel === 'sms'
? 'فقط پیامک ارسال می‌شود. هزینه پیامک برای هر گیرنده محاسبه می‌شود.'
: channel === 'both'
? 'اعلان داخل اپ، پوش مرورگر (در صورت اشتراک فعال) و پیامک با هم ارسال می‌شوند.'
: 'اعلان داخل اپ ساخته می‌شود و اگر کاربر اشتراک مرورگر فعال داشته باشد، پوش هم می‌رود.'}
</p>
{includesInApp ? (
<>
<Input
generalType="input"
label="عنوان اعلان"
name="title"
value={title}
onValueChange={(next) => {
setTitle(coerceToString(next))
}}
/>
{errors.title && <span className="text-tiny text-fourth-900">{errors.title}</span>}
</>
) : null}
<Input
description={
smsCount
? `${smsCount.chars.toLocaleString('fa-IR')} کاراکتر · ${smsCount.segments.toLocaleString('fa-IR')} پیامک${
smsCount.segments > 1 ? ' — متن را کوتاه نگه دارید' : ''
}`
: undefined
}
generalType="textarea"
label={channel === 'sms' ? 'متن پیامک' : 'متن اعلان'}
name="body"
value={body}
onValueChange={(next) => {
setBody(coerceToString(next))
}}
/>
{errors.body && <span className="text-tiny text-fourth-900">{errors.body}</span>}
{includesInApp ? (
<>
<Input
direction="ltr"
generalType="input"
label="مسیر مقصد پس از کلیک (اختیاری)"
name="actionUrl"
placeholder="/profile"
value={actionUrl}
onValueChange={(next) => {
setActionUrl(coerceToString(next))
}}
/>
{errors.actionUrl && <span className="text-tiny text-fourth-900">{errors.actionUrl}</span>}
</>
) : null}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div className="flex flex-col gap-1">
<Input
generalType="textarea"
label="موبایل کاربران"
name="mobiles"
placeholder={'09153641196\n989121234567'}
value={mobilesText}
onValueChange={(next) => {
setMobilesText(coerceToString(next))
setOtpCode('')
setOtpRequested(false)
setOtpExpiresIn(null)
}}
/>
<span className="text-text-muted text-xs">هر موبایل را در یک خط جدا یا با کاما وارد کنید.</span>
</div>
<div className="flex flex-col gap-1">
<Input
direction="ltr"
generalType="textarea"
label="شناسه کاربران"
name="userIds"
placeholder={'c3c921d5-4418-42ed-bf39-0f04b1d5123b'}
value={userIdsText}
onValueChange={(next) => {
setUserIdsText(coerceToString(next))
setOtpCode('')
setOtpRequested(false)
setOtpExpiresIn(null)
}}
/>
<span className="text-text-muted text-xs">برای هدفگیری دقیق میتوانید UUID کاربر را هم وارد کنید.</span>
</div>
</div>
{errors.recipients && <span className="text-tiny text-fourth-900">{errors.recipients}</span>}
{requiresBulkOtp ? (
<div className="rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
<p className="font-medium">ارسال گروهی پیامک نیاز به تأیید دارد</p>
<p className="mt-2 leading-6">
برای جلوگیری از ارسال اشتباهی، ابتدا کد تأیید را به موبایل ادمین بفرستید و سپس همان کد را وارد کنید.
</p>
<div className="mt-4 flex flex-col gap-3">
<Button
color="warning"
disabled={isRequestingOtp}
isLoading={isRequestingOtp}
type="button"
onClick={() => void handleRequestOtp()}
>
{isRequestingOtp ? 'در حال ارسال کد…' : 'ارسال کد تأیید به موبایل ادمین'}
</Button>
<Input
direction="ltr"
generalType="input"
label="کد تأیید"
name="otpCode"
placeholder="1234"
value={otpCode}
onValueChange={(next) => {
setOtpCode(coerceToString(next))
}}
/>
{errors.otpCode && <span className="text-tiny text-fourth-900">{errors.otpCode}</span>}
{otpRequested && otpExpiresIn ? (
<span className="text-xs text-amber-800">
کد تا {Math.floor(otpExpiresIn / 60).toLocaleString('fa-IR')} دقیقه معتبر است.
</span>
) : null}
</div>
</div>
) : null}
<div className="rounded-2xl border border-secondary-40 bg-secondary-50 p-4 text-sm text-secondary-20">
<p>پیشنمایش گیرندهها:</p>
<p className="mt-2">موبایل: {recipientsPreview.mobiles.length}</p>
<p>شناسه کاربر: {recipientsPreview.userIds.length}</p>
</div>
</div>
</Modal>
)
}
export default SendManualNotificationModal