Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
312 lines
11 KiB
TypeScript
312 lines
11 KiB
TypeScript
'use client'
|
|
|
|
import { zodResolver } from '@hookform/resolvers/zod'
|
|
import { useRouter, useSearchParams } from 'next/navigation'
|
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
import { useForm } from 'react-hook-form'
|
|
|
|
import { addToast } from '@/lib/toast'
|
|
import type { ServiceError } from '@/services/errorHandler'
|
|
import { RESEND_COOLDOWN_SECONDS } from '@/context/AuthContext'
|
|
import { texts } from '@/texts'
|
|
import { convertPersianToEnglish } from '@/helpers'
|
|
import useAuth from '@/hooks/useAuth'
|
|
import { useSmsOtpAutofill } from '@/hooks/useSmsOtpAutofill'
|
|
import { getDefaultPostLoginPath, getSafeInternalRedirect } from '@/lib/authRouting'
|
|
import { ANALYTICS_EVENTS, trackAnalyticsEvent } from '@/lib/analytics'
|
|
import {
|
|
CheckOtpFormValidation,
|
|
CompleteProfileFormValidation,
|
|
type CompleteProfileFormValues,
|
|
OTP_LENGTH,
|
|
SendOtpFormValidation,
|
|
} from '@/validation/auth'
|
|
|
|
type AuthStep = 'mobile' | 'otp' | 'profile'
|
|
type OtpPurpose = 'login' | 'register'
|
|
|
|
const OTP_EXPIRES_IN_SECONDS = 120
|
|
|
|
const BLOCK_MESSAGES = {
|
|
ACCOUNT_SUSPENDED: texts.auth.accountSuspended,
|
|
ACCOUNT_DELETED: texts.auth.accountDeleted,
|
|
} as const
|
|
|
|
function apiErrorCode(error: unknown): string | undefined {
|
|
return error && typeof error === 'object' && 'apiCode' in error ? (error as ServiceError).apiCode : undefined
|
|
}
|
|
|
|
function postLoginRedirect(searchParams: URLSearchParams) {
|
|
const explicit = searchParams.get('redirect')
|
|
let fallback = getDefaultPostLoginPath('user')
|
|
|
|
try {
|
|
const raw = localStorage.getItem('user')
|
|
|
|
if (raw) fallback = getDefaultPostLoginPath((JSON.parse(raw) as { role?: 'user' | 'admin' }).role)
|
|
} catch {
|
|
// Ignore corrupted legacy storage and use the safe consumer home.
|
|
}
|
|
|
|
return getSafeInternalRedirect(explicit, fallback)
|
|
}
|
|
|
|
export function useAuthFlow(
|
|
options: {
|
|
redirectTo?: string
|
|
navigateOnSuccess?: boolean
|
|
onAuthenticated?: () => void
|
|
requireTermsAcceptance?: boolean
|
|
} = {}
|
|
) {
|
|
const requireTermsAcceptance = options.requireTermsAcceptance ?? true
|
|
const authSource = useRef(options.navigateOnSuccess === false ? 'auth_gate' : 'auth_page')
|
|
const { sendLoginOtp, checkLoginOtp, completeProfile, user } = useAuth()
|
|
const router = useRouter()
|
|
const searchParams = useSearchParams()
|
|
const [loading, setLoading] = useState(false)
|
|
const [step, setStep] = useState<AuthStep>('mobile')
|
|
const [mobile, setMobile] = useState('')
|
|
const [otpTtl, setOtpTtl] = useState(RESEND_COOLDOWN_SECONDS)
|
|
const [resendCodeTime, setResendCodeTime] = useState(0)
|
|
const [accountBlockMessage, setAccountBlockMessage] = useState<string | null>(null)
|
|
const [otpError, setOtpError] = useState<string | null>(null)
|
|
const [otpPurpose, setOtpPurpose] = useState<OtpPurpose | null>(null)
|
|
const [otpReused, setOtpReused] = useState(false)
|
|
const [termsAccepted, setTermsAccepted] = useState(false)
|
|
const [termsError, setTermsError] = useState<string | null>(null)
|
|
const [otpChallenge, setOtpChallenge] = useState(0)
|
|
|
|
const sendOtpForm = useForm({ resolver: zodResolver(SendOtpFormValidation), defaultValues: { mobile: '' } })
|
|
const checkOtpForm = useForm({ resolver: zodResolver(CheckOtpFormValidation), defaultValues: { code: '' } })
|
|
const completeProfileForm = useForm({
|
|
resolver: zodResolver(CompleteProfileFormValidation),
|
|
defaultValues: { firstName: '', lastName: '', gender: '', dateOfBirth: '', cityId: '' },
|
|
})
|
|
|
|
const applyAutofilledOtp = useCallback(
|
|
(code: string) => {
|
|
const digits = convertPersianToEnglish(code).replace(/\D/g, '').slice(0, OTP_LENGTH)
|
|
|
|
if (digits.length !== OTP_LENGTH) return
|
|
checkOtpForm.setValue('code', digits, { shouldDirty: true, shouldValidate: true })
|
|
},
|
|
[checkOtpForm]
|
|
)
|
|
|
|
useSmsOtpAutofill(step === 'otp', otpChallenge, applyAutofilledOtp)
|
|
|
|
useEffect(() => {
|
|
trackAnalyticsEvent(ANALYTICS_EVENTS.AUTH_OPENED, { source: authSource.current })
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (user?.status === 'pending') setStep('profile')
|
|
}, [user?.status])
|
|
|
|
useEffect(() => {
|
|
if (step !== 'otp' || resendCodeTime <= 0) return
|
|
const timer = window.setTimeout(() => {
|
|
setResendCodeTime((value) => Math.max(0, value - 1))
|
|
}, 1000)
|
|
|
|
return () => {
|
|
window.clearTimeout(timer)
|
|
}
|
|
}, [resendCodeTime, step])
|
|
|
|
const sendOtp = useCallback(
|
|
async (data: { mobile: string }) => {
|
|
try {
|
|
setLoading(true)
|
|
setAccountBlockMessage(null)
|
|
const value = convertPersianToEnglish(data.mobile).trim()
|
|
const result = await sendLoginOtp({ mobile: value })
|
|
const alreadySent = result.alreadySent
|
|
// ارسال تازه همان پنجرهٔ ۱۲۰ ثانیهای محصول است؛ اگر بکاند کد قبلی را
|
|
// نگه داشته، شمارش معکوس باید باقیماندهٔ واقعی TTL باشد.
|
|
const resendIn = alreadySent ? Math.min(OTP_EXPIRES_IN_SECONDS, Math.max(1, result.expiresIn)) : OTP_EXPIRES_IN_SECONDS
|
|
const isResend = step === 'otp' && mobile === value
|
|
|
|
trackAnalyticsEvent(ANALYTICS_EVENTS.OTP_REQUESTED, {
|
|
purpose: result.purpose,
|
|
is_resend: isResend,
|
|
source: authSource.current,
|
|
})
|
|
|
|
setMobile(value)
|
|
setOtpPurpose(result.purpose)
|
|
setOtpReused(alreadySent)
|
|
setOtpTtl(resendIn)
|
|
setResendCodeTime(resendIn)
|
|
setOtpError(null)
|
|
if (!isResend) {
|
|
setTermsAccepted(false)
|
|
setTermsError(null)
|
|
}
|
|
setStep('otp')
|
|
setOtpChallenge((value) => value + 1)
|
|
checkOtpForm.reset({ code: '' })
|
|
} catch (error) {
|
|
const code = apiErrorCode(error)
|
|
|
|
trackAnalyticsEvent(ANALYTICS_EVENTS.AUTH_FAILED, {
|
|
auth_stage: 'request_otp',
|
|
error_code: code ?? 'unknown',
|
|
source: authSource.current,
|
|
})
|
|
|
|
if (code === 'ACCOUNT_SUSPENDED' || code === 'ACCOUNT_DELETED') setAccountBlockMessage(BLOCK_MESSAGES[code])
|
|
else addToast({ title: texts.auth.sendOtpFailed, color: 'danger' })
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
},
|
|
[checkOtpForm, mobile, sendLoginOtp, step]
|
|
)
|
|
|
|
const checkOtp = useCallback(
|
|
async (data: { code: string }) => {
|
|
if (requireTermsAcceptance && otpPurpose === 'register' && !termsAccepted) {
|
|
const message = texts.auth.termsRequiredMessage
|
|
|
|
setTermsError(message)
|
|
addToast({ title: texts.auth.termsRequiredTitle, description: message, color: 'danger' })
|
|
trackAnalyticsEvent(ANALYTICS_EVENTS.AUTH_FAILED, {
|
|
auth_stage: 'terms_acceptance',
|
|
error_code: 'terms_not_accepted',
|
|
source: authSource.current,
|
|
})
|
|
|
|
return
|
|
}
|
|
|
|
try {
|
|
setLoading(true)
|
|
setOtpError(null)
|
|
setTermsError(null)
|
|
const status = await checkLoginOtp({
|
|
mobile,
|
|
code: convertPersianToEnglish(data.code).trim(),
|
|
// Server stamps terms version itself; client only asserts acceptance.
|
|
...(otpPurpose === 'register' ? { termsAccepted: true } : {}),
|
|
})
|
|
|
|
if (status === 'pending') setStep('profile')
|
|
if (status === 'active') {
|
|
trackAnalyticsEvent(ANALYTICS_EVENTS.LOGIN, { method: 'otp', source: authSource.current })
|
|
addToast({ title: texts.auth.welcome, color: 'success' })
|
|
options.onAuthenticated?.()
|
|
if (options.navigateOnSuccess !== false) router.replace(options.redirectTo ?? postLoginRedirect(searchParams))
|
|
}
|
|
} catch (error) {
|
|
const code = apiErrorCode(error)
|
|
|
|
trackAnalyticsEvent(ANALYTICS_EVENTS.AUTH_FAILED, {
|
|
auth_stage: 'verify_otp',
|
|
error_code: code ?? 'unknown',
|
|
source: authSource.current,
|
|
})
|
|
|
|
if (code === 'ACCOUNT_SUSPENDED' || code === 'ACCOUNT_DELETED') {
|
|
setAccountBlockMessage(BLOCK_MESSAGES[code])
|
|
setStep('mobile')
|
|
} else if (code === 'OTP_INVALID' || code === 'OTP_EXPIRED') setOtpError(texts.auth.otpInvalidOrExpired)
|
|
else setOtpError(texts.auth.otpVerifyFailed)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
},
|
|
[checkLoginOtp, mobile, options, otpPurpose, requireTermsAcceptance, router, searchParams, termsAccepted]
|
|
)
|
|
|
|
const otpCode = checkOtpForm.watch('code')
|
|
const lastAutoSubmittedOtpRef = useRef<string | null>(null)
|
|
|
|
// با پر شدن رقم آخر، فرم را خودکار submit کن (دستی و SMS autofill)
|
|
useEffect(() => {
|
|
if (step !== 'otp' || loading) return
|
|
|
|
// watch('code') without a typed form can infer as {}; OTP field is always a string at runtime
|
|
const digits = convertPersianToEnglish(typeof otpCode === 'string' ? otpCode : '')
|
|
.replace(/\D/g, '')
|
|
.slice(0, OTP_LENGTH)
|
|
|
|
if (digits.length !== OTP_LENGTH) {
|
|
lastAutoSubmittedOtpRef.current = null
|
|
|
|
return
|
|
}
|
|
|
|
if (lastAutoSubmittedOtpRef.current === digits) return
|
|
|
|
lastAutoSubmittedOtpRef.current = digits
|
|
void checkOtpForm.handleSubmit(checkOtp)()
|
|
}, [checkOtp, checkOtpForm, loading, otpCode, step])
|
|
|
|
const submitProfile = useCallback(
|
|
async (data: CompleteProfileFormValues) => {
|
|
try {
|
|
setLoading(true)
|
|
await completeProfile({
|
|
firstName: data.firstName.trim(),
|
|
lastName: data.lastName.trim(),
|
|
gender: data.gender,
|
|
dateOfBirth: data.dateOfBirth,
|
|
cityId: Number(data.cityId),
|
|
})
|
|
addToast({ title: texts.auth.welcome, color: 'success' })
|
|
options.onAuthenticated?.()
|
|
if (options.navigateOnSuccess !== false) router.replace(options.redirectTo ?? postLoginRedirect(searchParams))
|
|
} catch {
|
|
trackAnalyticsEvent(ANALYTICS_EVENTS.AUTH_FAILED, {
|
|
auth_stage: 'complete_profile',
|
|
error_code: 'unknown',
|
|
source: authSource.current,
|
|
})
|
|
addToast({ title: texts.auth.profileCompleteFailed, color: 'danger' })
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
},
|
|
[completeProfile, options, router, searchParams]
|
|
)
|
|
|
|
const resetToMobile = useCallback(() => {
|
|
setStep('mobile')
|
|
setResendCodeTime(0)
|
|
setOtpError(null)
|
|
setOtpPurpose(null)
|
|
setOtpReused(false)
|
|
setTermsAccepted(false)
|
|
setTermsError(null)
|
|
}, [])
|
|
|
|
const updateTermsAccepted = useCallback((accepted: boolean) => {
|
|
setTermsAccepted(accepted)
|
|
if (accepted) setTermsError(null)
|
|
}, [])
|
|
|
|
return {
|
|
accountBlockMessage,
|
|
checkOtp,
|
|
checkOtpForm,
|
|
completeProfileForm,
|
|
loading,
|
|
mobile,
|
|
isRegistration: otpPurpose === 'register',
|
|
otpError,
|
|
otpReused,
|
|
otpTtl,
|
|
resendCodeTime,
|
|
resetToMobile,
|
|
sendOtp,
|
|
sendOtpForm,
|
|
step,
|
|
submitProfile,
|
|
termsAccepted,
|
|
termsError,
|
|
updateTermsAccepted,
|
|
}
|
|
}
|