feat(auth): referrer code at signup and agent code in profile

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>
This commit is contained in:
2026-10-04 10:38:16 +03:30
co-authored by Cursor
parent 9c35450b31
commit f252749d18
7 changed files with 132 additions and 7 deletions
+10 -1
View File
@@ -10,6 +10,7 @@ type PendingOtp = {
isRegister: boolean isRegister: boolean
firstName?: string firstName?: string
lastName?: string lastName?: string
referrerAgentCode?: string
} }
let memoryPending: PendingOtp | null = null let memoryPending: PendingOtp | null = null
@@ -50,7 +51,12 @@ function setPendingOtp(value: PendingOtp | null) {
export async function sendOtp( export async function sendOtp(
phone: string, phone: string,
options: { firstName?: string; lastName?: string; isRegister: boolean }, options: {
firstName?: string
lastName?: string
isRegister: boolean
referrerAgentCode?: string
},
): Promise<{ success: boolean; message: string }> { ): Promise<{ success: boolean; message: string }> {
const normalized = normalizePhone(phone) const normalized = normalizePhone(phone)
if (!/^09\d{9}$/.test(normalized)) { if (!/^09\d{9}$/.test(normalized)) {
@@ -62,11 +68,13 @@ export async function sendOtp(
} }
try { try {
const referrerAgentCode = options.referrerAgentCode?.trim() || undefined
setPendingOtp({ setPendingOtp({
phone: normalized, phone: normalized,
isRegister: options.isRegister, isRegister: options.isRegister,
firstName: options.firstName, firstName: options.firstName,
lastName: options.lastName, lastName: options.lastName,
referrerAgentCode,
}) })
const res = await apiRequest<{ success: boolean; message: string; debugCode?: string }>( const res = await apiRequest<{ success: boolean; message: string; debugCode?: string }>(
'/auth/otp/request', '/auth/otp/request',
@@ -78,6 +86,7 @@ export async function sendOtp(
firstName: options.firstName, firstName: options.firstName,
lastName: options.lastName, lastName: options.lastName,
isRegister: options.isRegister, isRegister: options.isRegister,
referrerAgentCode,
}, },
}, },
) )
+15 -2
View File
@@ -50,7 +50,12 @@ export const useAuthStore = defineStore('auth', () => {
async function requestOtp( async function requestOtp(
phone: string, phone: string,
options: { firstName?: string; lastName?: string; isRegister: boolean }, options: {
firstName?: string
lastName?: string
isRegister: boolean
referrerAgentCode?: string
},
) { ) {
loading.value = true loading.value = true
error.value = null error.value = null
@@ -62,9 +67,16 @@ export const useAuthStore = defineStore('auth', () => {
const firstName = options.firstName?.trim() || stored?.firstName const firstName = options.firstName?.trim() || stored?.firstName
const lastName = options.lastName?.trim() || stored?.lastName const lastName = options.lastName?.trim() || stored?.lastName
const referrerAgentCode =
options.referrerAgentCode?.trim() || stored?.referrerAgentCode
if (options.isRegister && firstName && lastName) { if (options.isRegister && firstName && lastName) {
pendingRegister.value = { firstName, lastName } pendingRegister.value = {
firstName,
lastName,
jobCategory: stored?.jobCategory,
referrerAgentCode,
}
setPendingRegister(pendingRegister.value) setPendingRegister(pendingRegister.value)
} }
@@ -72,6 +84,7 @@ export const useAuthStore = defineStore('auth', () => {
isRegister: options.isRegister, isRegister: options.isRegister,
firstName, firstName,
lastName, lastName,
referrerAgentCode,
}) })
if (!result.success) error.value = result.message if (!result.success) error.value = result.message
return result return result
+3
View File
@@ -6,6 +6,7 @@ export type PendingRegister = {
firstName: string firstName: string
lastName: string lastName: string
jobCategory?: JobCategory jobCategory?: JobCategory
referrerAgentCode?: string
} }
export const JOB_CATEGORY_OPTIONS: Array<{ id: JobCategory; label: string }> = [ export const JOB_CATEGORY_OPTIONS: Array<{ id: JobCategory; label: string }> = [
@@ -24,6 +25,7 @@ export function getPendingRegister(): PendingRegister | null {
firstName: parsed.firstName.trim(), firstName: parsed.firstName.trim(),
lastName: parsed.lastName.trim(), lastName: parsed.lastName.trim(),
jobCategory: parsed.jobCategory, jobCategory: parsed.jobCategory,
referrerAgentCode: parsed.referrerAgentCode?.trim() || undefined,
} }
} catch { } catch {
return null return null
@@ -38,6 +40,7 @@ export function setPendingRegister(value: PendingRegister | null) {
firstName: value.firstName.trim(), firstName: value.firstName.trim(),
lastName: value.lastName.trim(), lastName: value.lastName.trim(),
jobCategory: value.jobCategory, jobCategory: value.jobCategory,
referrerAgentCode: value.referrerAgentCode?.trim() || undefined,
}), }),
) )
return return
+1
View File
@@ -198,6 +198,7 @@ async function resend() {
isRegister: isRegister.value, isRegister: isRegister.value,
firstName: pending?.firstName, firstName: pending?.firstName,
lastName: pending?.lastName, lastName: pending?.lastName,
referrerAgentCode: pending?.referrerAgentCode,
}) })
if (result.success) { if (result.success) {
digits.value = Array.from({ length: OTP_LENGTH }, () => '') digits.value = Array.from({ length: OTP_LENGTH }, () => '')
+76 -1
View File
@@ -36,6 +36,7 @@ const firstName = ref('')
const lastName = ref('') const lastName = ref('')
const phone = ref('') const phone = ref('')
const jobCategory = ref<JobCategory | ''>('garment_producer') const jobCategory = ref<JobCategory | ''>('garment_producer')
const referrerAgentCode = ref('')
const acceptedTerms = ref(true) const acceptedTerms = ref(true)
const fieldErrors = ref<FieldErrors>({}) const fieldErrors = ref<FieldErrors>({})
const formError = ref('') const formError = ref('')
@@ -80,6 +81,7 @@ async function submit() {
firstName: firstName.value.trim(), firstName: firstName.value.trim(),
lastName: lastName.value.trim(), lastName: lastName.value.trim(),
jobCategory: jobCategory.value as JobCategory, jobCategory: jobCategory.value as JobCategory,
referrerAgentCode: referrerAgentCode.value.trim() || undefined,
} }
setPendingRegister(names) setPendingRegister(names)
@@ -87,11 +89,18 @@ async function submit() {
isRegister: true, isRegister: true,
firstName: names.firstName, firstName: names.firstName,
lastName: names.lastName, lastName: names.lastName,
referrerAgentCode: names.referrerAgentCode,
}) })
if (result.success) { if (result.success) {
router.push({ name: 'otp', query: { phone: normalized, mode: 'register' } }) router.push({ name: 'otp', query: { phone: normalized, mode: 'register' } })
} else { } else {
formError.value = result.message ?? 'خطا در ارسال کد تأیید.' const msg = result.message ?? 'خطا در ارسال کد تأیید.'
if (msg.includes('کد واسطه معتبر نیست')) {
fieldErrors.value = { ...fieldErrors.value, referrerAgentCode: 'کد واسطه معتبر نیست' }
formError.value = ''
} else {
formError.value = msg
}
} }
} }
</script> </script>
@@ -143,6 +152,29 @@ async function submit() {
</label> </label>
<p v-if="fieldErrors.phone" class="auth-error">{{ fieldErrors.phone }}</p> <p v-if="fieldErrors.phone" class="auth-error">{{ fieldErrors.phone }}</p>
<label
class="auth-field auth-field--stacked"
:class="{ 'is-invalid': fieldErrors.referrerAgentCode }"
>
<span class="auth-field__caption">کد واسطه (اختیاری)</span>
<input
v-model="referrerAgentCode"
type="text"
autocomplete="off"
dir="ltr"
class="auth-field__input-ltr"
placeholder="مثلاً W-1247"
@input="clearField('referrerAgentCode')"
/>
</label>
<p class="auth-hint">
اگر از طریق واسطه معرفی شده‌اید، کد واسطه را وارد کنید؛ در غیر این صورت این قسمت را خالی
بگذارید.
</p>
<p v-if="fieldErrors.referrerAgentCode" class="auth-error">
{{ fieldErrors.referrerAgentCode }}
</p>
<p class="job-label">دسته شغلی شما</p> <p class="job-label">دسته شغلی شما</p>
<div class="job-grid" :class="{ 'is-invalid': fieldErrors.jobCategory }"> <div class="job-grid" :class="{ 'is-invalid': fieldErrors.jobCategory }">
<button <button
@@ -242,6 +274,49 @@ async function submit() {
color: #98a2b3; color: #98a2b3;
} }
.auth-field--stacked {
flex-direction: column;
align-items: stretch;
height: auto;
min-height: 52px;
padding: 10px 14px;
gap: 6px;
}
.auth-field__caption {
font-size: 12px;
font-weight: 700;
color: #475569;
text-align: right;
width: 100%;
}
.auth-field__input-ltr {
flex: 1 1 0;
min-width: 0;
width: 100%;
border: 0;
background: transparent;
font: inherit;
font-size: 14px;
text-align: left;
color: #1e293b;
outline: none;
}
.auth-field__input-ltr::placeholder {
color: #98a2b3;
text-align: right;
}
.auth-hint {
margin: -4px 0 0;
font-size: 12px;
line-height: 1.65;
color: #667085;
text-align: right;
}
.auth-error { .auth-error {
margin: -4px 0 0; margin: -4px 0 0;
color: #b42318; color: #b42318;
+11
View File
@@ -43,6 +43,8 @@ const initials = computed(() => {
return `${first}${last}` || '؟' return `${first}${last}` || '؟'
}) })
const agentReferralCode = computed(() => auth.user?.agentCode?.trim() || '—')
function clearField(key: string) { function clearField(key: string) {
fieldErrors.value = clearFieldError(fieldErrors.value, key) fieldErrors.value = clearFieldError(fieldErrors.value, key)
if (!hasFieldErrors(fieldErrors.value)) formError.value = '' if (!hasFieldErrors(fieldErrors.value)) formError.value = ''
@@ -229,6 +231,15 @@ async function save() {
:error="fieldErrors.nationalId" :error="fieldErrors.nationalId"
@update:model-value="clearField('nationalId')" @update:model-value="clearField('nationalId')"
/> />
<BaseInput
v-if="auth.isAgent"
class="profile-edit__full"
:model-value="agentReferralCode"
label="کد واسطه"
dir="ltr"
disabled
hint="این کد را به مشتریان معرفی‌شده بدهید تا هنگام ثبت‌نام در اپ وارد کنند."
/>
<div v-if="auth.isAgent" class="form-group profile-edit__full"> <div v-if="auth.isAgent" class="form-group profile-edit__full">
<label class="form-label" for="profile-address">آدرس (واسطه)</label> <label class="form-label" for="profile-address">آدرس (واسطه)</label>
<textarea <textarea
@@ -54,7 +54,9 @@ const checksCount = computed(() => {
const customerCode = computed(() => { const customerCode = computed(() => {
const u = auth.user const u = auth.user
if (!u) return toPersianDigits('—') if (!u) return toPersianDigits('—')
if (auth.isAgent && u.agentCode) return toPersianDigits(u.agentCode) if (auth.isAgent) {
return u.agentCode ? toPersianDigits(u.agentCode) : toPersianDigits('—')
}
if (u.nationalId) return toPersianDigits(u.nationalId.slice(-6).padStart(6, '0')) if (u.nationalId) return toPersianDigits(u.nationalId.slice(-6).padStart(6, '0'))
return toPersianDigits(u.id?.slice(-6) ?? '—') return toPersianDigits(u.id?.slice(-6) ?? '—')
}) })
@@ -73,7 +75,8 @@ const joinDate = computed(() =>
const creditBalance = computed(() => 0) const creditBalance = computed(() => 0)
const companyRows = computed(() => [ const companyRows = computed(() => {
const rows: Array<{ label: string; value: string; badge?: boolean }> = [
{ label: 'نام شرکت', value: auth.fullName || '—' }, { label: 'نام شرکت', value: auth.fullName || '—' },
{ label: 'شناسه ملی', value: auth.user?.nationalId ? toPersianDigits(auth.user.nationalId) : '—' }, { label: 'شناسه ملی', value: auth.user?.nationalId ? toPersianDigits(auth.user.nationalId) : '—' },
{ {
@@ -87,7 +90,17 @@ const companyRows = computed(() => [
badge: true, badge: true,
}, },
{ label: 'وضعیت حساب', value: 'فعال', badge: true }, { label: 'وضعیت حساب', value: 'فعال', badge: true },
]) ]
if (auth.isAgent) {
rows.splice(1, 0, {
label: 'کد واسطه',
value: auth.user?.agentCode
? toPersianDigits(auth.user.agentCode)
: '—',
})
}
return rows
})
const quickAccess = [ const quickAccess = [
{ label: 'حساب‌های بانکی', icon: IconCashSettlement }, { label: 'حساب‌های بانکی', icon: IconCashSettlement },