'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> 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(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(null) const [errors, setErrors] = useState({}) 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(response.data as ApiSuccessBody) 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 ( void handleSubmit()} onOpenChange={(open) => { if (!open) handleClose() }} onReject={handleClose} >
{ if (next === 'in_app' || next === 'sms' || next === 'both') { setChannel(next) setOtpCode('') setOtpRequested(false) setOtpExpiresIn(null) } }} /> {errors.channel && {errors.channel}}

{channel === 'sms' ? 'فقط پیامک ارسال می‌شود. هزینه پیامک برای هر گیرنده محاسبه می‌شود.' : channel === 'both' ? 'اعلان داخل اپ، پوش مرورگر (در صورت اشتراک فعال) و پیامک با هم ارسال می‌شوند.' : 'اعلان داخل اپ ساخته می‌شود و اگر کاربر اشتراک مرورگر فعال داشته باشد، پوش هم می‌رود.'}

{includesInApp ? ( <> { setTitle(coerceToString(next)) }} /> {errors.title && {errors.title}} ) : null} 1 ? ' — متن را کوتاه نگه دارید' : '' }` : undefined } generalType="textarea" label={channel === 'sms' ? 'متن پیامک' : 'متن اعلان'} name="body" value={body} onValueChange={(next) => { setBody(coerceToString(next)) }} /> {errors.body && {errors.body}} {includesInApp ? ( <> { setActionUrl(coerceToString(next)) }} /> {errors.actionUrl && {errors.actionUrl}} ) : null}
{ setMobilesText(coerceToString(next)) setOtpCode('') setOtpRequested(false) setOtpExpiresIn(null) }} /> هر موبایل را در یک خط جدا یا با کاما وارد کنید.
{ setUserIdsText(coerceToString(next)) setOtpCode('') setOtpRequested(false) setOtpExpiresIn(null) }} /> برای هدف‌گیری دقیق می‌توانید UUID کاربر را هم وارد کنید.
{errors.recipients && {errors.recipients}} {requiresBulkOtp ? (

ارسال گروهی پیامک نیاز به تأیید دارد

برای جلوگیری از ارسال اشتباهی، ابتدا کد تأیید را به موبایل ادمین بفرستید و سپس همان کد را وارد کنید.

{ setOtpCode(coerceToString(next)) }} /> {errors.otpCode && {errors.otpCode}} {otpRequested && otpExpiresIn ? ( کد تا {Math.floor(otpExpiresIn / 60).toLocaleString('fa-IR')} دقیقه معتبر است. ) : null}
) : null}

پیش‌نمایش گیرنده‌ها:

موبایل: {recipientsPreview.mobiles.length}

شناسه کاربر: {recipientsPreview.userIds.length}

) } export default SendManualNotificationModal