'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('mobile') const [mobile, setMobile] = useState('') const [otpTtl, setOtpTtl] = useState(RESEND_COOLDOWN_SECONDS) const [resendCodeTime, setResendCodeTime] = useState(0) const [accountBlockMessage, setAccountBlockMessage] = useState(null) const [otpError, setOtpError] = useState(null) const [otpPurpose, setOtpPurpose] = useState(null) const [otpReused, setOtpReused] = useState(false) const [termsAccepted, setTermsAccepted] = useState(false) const [termsError, setTermsError] = useState(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(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, } }