Optional referrer agent code in registration with field validation UX. Show read-only agent referral code in profile edit and desktop overview. Co-authored-by: Cursor <cursoragent@cursor.com>
174 lines
4.5 KiB
TypeScript
174 lines
4.5 KiB
TypeScript
import { extractNumericString, normalizePhone } from '@/utils/format'
|
|
import { apiRequest, ApiError, getAccessToken, setAccessToken } from './http'
|
|
import type { User } from '@/types'
|
|
|
|
const USER_KEY = 'ft_user'
|
|
const PENDING_OTP_KEY = 'ft_pending_otp'
|
|
|
|
type PendingOtp = {
|
|
phone: string
|
|
isRegister: boolean
|
|
firstName?: string
|
|
lastName?: string
|
|
referrerAgentCode?: string
|
|
}
|
|
|
|
let memoryPending: PendingOtp | null = null
|
|
|
|
function cacheUser(user: User | null): void {
|
|
if (user) localStorage.setItem(USER_KEY, JSON.stringify(user))
|
|
else localStorage.removeItem(USER_KEY)
|
|
}
|
|
|
|
export function getCachedUser(): User | null {
|
|
try {
|
|
const raw = localStorage.getItem(USER_KEY)
|
|
if (!raw) return null
|
|
return JSON.parse(raw) as User
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
export function getCurrentUser(): User | null {
|
|
if (!getAccessToken()) return null
|
|
return getCachedUser()
|
|
}
|
|
|
|
export function isAuthenticated(): boolean {
|
|
return !!getAccessToken()
|
|
}
|
|
|
|
function setPendingOtp(value: PendingOtp | null) {
|
|
memoryPending = value
|
|
try {
|
|
if (value) sessionStorage.setItem(PENDING_OTP_KEY, JSON.stringify(value))
|
|
else sessionStorage.removeItem(PENDING_OTP_KEY)
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
export async function sendOtp(
|
|
phone: string,
|
|
options: {
|
|
firstName?: string
|
|
lastName?: string
|
|
isRegister: boolean
|
|
referrerAgentCode?: string
|
|
},
|
|
): Promise<{ success: boolean; message: string }> {
|
|
const normalized = normalizePhone(phone)
|
|
if (!/^09\d{9}$/.test(normalized)) {
|
|
return { success: false, message: 'شماره موبایل معتبر نیست.' }
|
|
}
|
|
|
|
if (options.isRegister && (!options.firstName?.trim() || !options.lastName?.trim())) {
|
|
return { success: false, message: 'نام و نام خانوادگی الزامی است.' }
|
|
}
|
|
|
|
try {
|
|
const referrerAgentCode = options.referrerAgentCode?.trim() || undefined
|
|
setPendingOtp({
|
|
phone: normalized,
|
|
isRegister: options.isRegister,
|
|
firstName: options.firstName,
|
|
lastName: options.lastName,
|
|
referrerAgentCode,
|
|
})
|
|
const res = await apiRequest<{ success: boolean; message: string; debugCode?: string }>(
|
|
'/auth/otp/request',
|
|
{
|
|
method: 'POST',
|
|
auth: false,
|
|
body: {
|
|
phone: normalized,
|
|
firstName: options.firstName,
|
|
lastName: options.lastName,
|
|
isRegister: options.isRegister,
|
|
referrerAgentCode,
|
|
},
|
|
},
|
|
)
|
|
const hint = res.debugCode ? ` (کد: ${res.debugCode})` : ''
|
|
return { success: true, message: `${res.message}${hint}` }
|
|
} catch (e) {
|
|
return {
|
|
success: false,
|
|
message: e instanceof ApiError ? e.message : 'ارسال کد ناموفق بود.',
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function verifyOtp(
|
|
phone: string,
|
|
code: string,
|
|
): Promise<{ success: boolean; message: string; user?: User }> {
|
|
const normalized = normalizePhone(phone)
|
|
try {
|
|
const res = await apiRequest<{ accessToken: string; user: User }>('/auth/otp/verify', {
|
|
method: 'POST',
|
|
auth: false,
|
|
body: { phone: normalized, code: extractNumericString(code) },
|
|
})
|
|
setAccessToken(res.accessToken)
|
|
cacheUser(res.user)
|
|
setPendingOtp(null)
|
|
return { success: true, message: 'ورود موفق.', user: res.user }
|
|
} catch (e) {
|
|
return {
|
|
success: false,
|
|
message: e instanceof ApiError ? e.message : 'تأیید کد ناموفق بود.',
|
|
}
|
|
}
|
|
}
|
|
|
|
export function logout(): void {
|
|
setAccessToken(null)
|
|
cacheUser(null)
|
|
setPendingOtp(null)
|
|
}
|
|
|
|
export async function fetchMe(): Promise<User | null> {
|
|
const token = getAccessToken()
|
|
if (!token) {
|
|
cacheUser(null)
|
|
return null
|
|
}
|
|
try {
|
|
const me = await apiRequest<User>('/auth/me')
|
|
cacheUser(me)
|
|
return me
|
|
} catch {
|
|
cacheUser(null)
|
|
return null
|
|
}
|
|
}
|
|
|
|
export async function updateProfile(
|
|
data: Partial<Pick<User, 'firstName' | 'lastName' | 'nationalId' | 'avatarUrl' | 'address'>>,
|
|
): Promise<User | null> {
|
|
try {
|
|
const updated = await apiRequest<User>('/auth/profile', {
|
|
method: 'PATCH',
|
|
body: data,
|
|
})
|
|
cacheUser(updated)
|
|
return updated
|
|
} catch (e) {
|
|
const avatarOnly =
|
|
data.avatarUrl !== undefined &&
|
|
data.firstName === undefined &&
|
|
data.lastName === undefined &&
|
|
data.nationalId === undefined &&
|
|
data.address === undefined
|
|
const current = getCachedUser()
|
|
if (!avatarOnly || !current) throw e
|
|
const merged: User = { ...current }
|
|
if (data.avatarUrl) merged.avatarUrl = data.avatarUrl
|
|
else delete merged.avatarUrl
|
|
cacheUser(merged)
|
|
return merged
|
|
}
|
|
}
|