Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
376 lines
14 KiB
TypeScript
376 lines
14 KiB
TypeScript
'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
|