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