admin/app/(dashboard)/users/[id]/_components/UserMobileChangeModal.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

248 lines
8.5 KiB
TypeScript
Raw 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 { useCallback, useEffect, useMemo, useState } from 'react'
import Button from '@/components/formElements/Button'
import Input from '@/components/formElements/Input'
import Modal from '@/components/modals/Modal'
import { formatIranianMobile } from '@/lib/formatters'
import { addToast } from '@/lib/toast'
import type { AdminUserDetail } from '@/services/adminUserDetail'
import { ADMIN_CHANGE_USER_MOBILE } from '@/services/adminUsers'
import {
confirmsCurrentMobile,
confirmsFinalMobileChange,
MOBILE_CHANGE_FINAL_CONFIRMATION,
normalizeAdminMobile,
} from '@/validation/adminUserMobile'
type MobileChangeUser = Pick<AdminUserDetail, 'id' | 'mobile' | 'firstName' | 'lastName'>
interface UserMobileChangeModalProps {
isOpen: boolean
onOpenChange: (isOpen: boolean) => void
user: MobileChangeUser
onSuccess: () => void
}
const TOTAL_STEPS = 3
const UserMobileChangeModal = ({ isOpen, onOpenChange, user, onSuccess }: UserMobileChangeModalProps) => {
const [step, setStep] = useState(1)
const [newMobileInput, setNewMobileInput] = useState('')
const [confirmedNewMobile, setConfirmedNewMobile] = useState('')
const [currentMobileConfirmation, setCurrentMobileConfirmation] = useState('')
const [finalConfirmation, setFinalConfirmation] = useState('')
const [submitting, setSubmitting] = useState(false)
const normalizedNewMobile = useMemo(() => normalizeAdminMobile(newMobileInput), [newMobileInput])
const newMobileError = useMemo(() => {
if (!newMobileInput.trim()) return null
if (!normalizedNewMobile) return 'شماره موبایل معتبر نیست.'
if (normalizedNewMobile === user.mobile) return 'شماره جدید باید با شماره فعلی متفاوت باشد.'
return null
}, [newMobileInput, normalizedNewMobile, user.mobile])
const reset = useCallback(() => {
setStep(1)
setNewMobileInput('')
setConfirmedNewMobile('')
setCurrentMobileConfirmation('')
setFinalConfirmation('')
setSubmitting(false)
}, [])
useEffect(() => {
if (isOpen) reset()
}, [isOpen, reset, user.id])
const handleOpenChange = (nextOpen: boolean) => {
if (!nextOpen) reset()
onOpenChange(nextOpen)
}
const canContinue =
step === 1
? Boolean(normalizedNewMobile) && normalizedNewMobile !== user.mobile
: step === 2
? confirmsCurrentMobile(currentMobileConfirmation, user.mobile)
: confirmsFinalMobileChange(finalConfirmation)
const submit = async () => {
setSubmitting(true)
const result = await ADMIN_CHANGE_USER_MOBILE(user.id, confirmedNewMobile)
setSubmitting(false)
if (!result.ok) return
addToast({
color: 'success',
title: 'شماره موبایل کاربر تغییر کرد',
description: 'تمام نشست‌های فعال کاربر باطل شدند.',
})
onSuccess()
handleOpenChange(false)
}
const handleAccept = () => {
if (!canContinue) return
if (step === 1 && normalizedNewMobile) {
setConfirmedNewMobile(normalizedNewMobile)
setStep(2)
return
}
if (step === 2) {
setStep(3)
return
}
void submit()
}
const handleReject = () => {
if (step > 1) {
setStep((current) => current - 1)
return
}
handleOpenChange(false)
}
const displayName = [user.firstName, user.lastName].filter(Boolean).join(' ') || formatIranianMobile(user.mobile)
return (
<Modal
acceptBtnDisabled={!canContinue}
acceptBtnText={step === 1 ? 'بررسی شماره' : step === 2 ? 'تأیید و ادامه' : 'تغییر قطعی شماره'}
acceptDanger={step === 3}
isDismissable={!submitting}
isLoading={submitting}
isOpen={isOpen}
rejectBtnText={step === 1 ? 'انصراف' : 'مرحله قبل'}
title={`تغییر شماره «${displayName}»`}
onAccept={handleAccept}
onOpenChange={handleOpenChange}
onReject={handleReject}
>
<div className="flex flex-col gap-5">
<div
aria-label={`مرحله ${step} از ${TOTAL_STEPS}`}
aria-valuemax={TOTAL_STEPS}
aria-valuemin={1}
aria-valuenow={step}
className="flex items-center gap-2"
role="progressbar"
>
{Array.from({ length: TOTAL_STEPS }, (_, index) => (
<span
key={index}
className={`h-2 flex-1 rounded-full ${index < step ? 'bg-primary' : 'bg-secondary-40'}`}
/>
))}
</div>
{step === 1 && (
<div className="flex flex-col gap-4">
<div className="rounded-xl border border-secondary-40 bg-secondary-50 p-3 text-sm leading-6 text-secondary-30">
شماره فعلی:{' '}
<span
className="font-bold text-secondary-10"
dir="ltr"
>
{formatIranianMobile(user.mobile)}
</span>
</div>
<Input
autoFocus
englishDigitsOnly
required
direction="ltr"
generalType="input"
label="شماره موبایل جدید"
name="newMobile"
placeholder="09121231231"
value={newMobileInput}
variant="flat"
onValueChange={(value) => {
setNewMobileInput(typeof value === 'string' ? value : '')
}}
/>
{newMobileError && <p className="text-xs font-medium text-fourth">{newMobileError}</p>}
</div>
)}
{step === 2 && (
<div className="flex flex-col gap-4">
<div className="rounded-xl border border-fourth-100 bg-fourth-100 p-4 text-sm leading-7 text-fourth-900">
<p className="font-bold">پیامدهای این تغییر را دوباره بررسی کنید:</p>
<ul className="mt-2 list-inside list-disc">
<li>شماره قبلی برای ثبتنام یک حساب جدید آزاد میشود.</li>
<li>شماره جدید تا ورود موفق با OTP تأییدنشده خواهد بود.</li>
<li>تمام نشستها و توکنهای فعال کاربر فوراً باطل میشوند.</li>
</ul>
<div className="mt-3 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 border-t border-fourth-100 pt-3">
<span>از</span>
<b dir="ltr">{formatIranianMobile(user.mobile)}</b>
<span>به</span>
<b dir="ltr">{formatIranianMobile(confirmedNewMobile)}</b>
</div>
</div>
<Input
autoFocus
englishDigitsOnly
direction="ltr"
generalType="input"
label="برای تأیید، شماره فعلی کاربر را وارد کنید"
name="currentMobileConfirmation"
placeholder={formatIranianMobile(user.mobile)}
value={currentMobileConfirmation}
variant="flat"
onValueChange={(value) => {
setCurrentMobileConfirmation(typeof value === 'string' ? value : '')
}}
/>
</div>
)}
{step === 3 && (
<div className="flex flex-col gap-4">
<div className="rounded-xl border border-fourth/20 bg-fourth/5 p-4 text-sm leading-7 text-fourth">
این آخرین تأیید است. پس از انجام عملیات، کاربر از همه دستگاهها خارج میشود و باید با شماره جدید OTP دریافت کند.
</div>
<Input
autoFocus
generalType="input"
label={`عبارت «${MOBILE_CHANGE_FINAL_CONFIRMATION}» را وارد کنید`}
name="finalConfirmation"
placeholder={MOBILE_CHANGE_FINAL_CONFIRMATION}
value={finalConfirmation}
variant="flat"
onValueChange={(value) => {
setFinalConfirmation(typeof value === 'string' ? value : '')
}}
/>
</div>
)}
{step > 1 && (
<Button
className="self-start px-0"
color="default"
size="sm"
variant="light"
onClick={handleReject}
>
بازگشت به مرحله قبل
</Button>
)}
</div>
</Modal>
)
}
export default UserMobileChangeModal