Initialize the user app and load home banners from the API.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-09 17:08:45 +03:30
co-authored by Cursor
commit a7f9c66514
190 changed files with 36160 additions and 0 deletions
+119
View File
@@ -0,0 +1,119 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import BaseInput from '@/components/ui/BaseInput.vue'
import FormErrorBanner from '@/components/ui/FormErrorBanner.vue'
import BaseButton from '@/components/ui/BaseButton.vue'
import AuthThemeToggle from '@/components/ui/AuthThemeToggle.vue'
import { normalizePhone, toPersianDigits } from '@/utils/format'
import { validatePhone } from '@/utils/validation'
import {
FORM_VALIDATION_SUMMARY,
clearFieldError,
hasFieldErrors,
type FieldErrors,
} from '@/utils/formErrors'
const router = useRouter()
const auth = useAuthStore()
const phone = ref('')
const fieldErrors = ref<FieldErrors>({})
const formError = ref('')
const displayPhone = computed(() => (phone.value ? toPersianDigits(phone.value) : ''))
function phoneErrorMessage(value: string): string {
if (!value) return 'شماره موبایل را وارد کنید.'
if (!validatePhone(value)) return 'شماره موبایل معتبر نیست. (مثال: ۰۹۱۲۱۲۳۴۵۶۷)'
return ''
}
function clearPhoneError() {
fieldErrors.value = clearFieldError(fieldErrors.value, 'phone')
if (!hasFieldErrors(fieldErrors.value)) formError.value = ''
}
function onPhoneInput(value: string) {
phone.value = normalizePhone(value)
clearPhoneError()
}
function onPhoneBlur() {
if (!phone.value) return
const message = phoneErrorMessage(phone.value)
if (message) {
fieldErrors.value = { ...fieldErrors.value, phone: message }
}
}
async function submit() {
fieldErrors.value = {}
formError.value = ''
const normalized = normalizePhone(phone.value)
phone.value = normalized
const message = phoneErrorMessage(normalized)
if (message) {
fieldErrors.value = { phone: message }
formError.value = FORM_VALIDATION_SUMMARY
return
}
const result = await auth.requestOtp(normalized, { isRegister: false })
if (result.success) {
router.push({ name: 'otp', query: { phone: normalized, mode: 'login' } })
} else {
formError.value = result.message ?? 'خطا در ارسال کد تأیید.'
}
}
</script>
<template>
<div class="page page-shell auth-page">
<AuthThemeToggle />
<div class="page__scroll flex flex-col justify-center py-6">
<div class="auth-header">
<div class="text-5xl mb-3">🧵</div>
<h1 class="page-title">ورود به سامانه</h1>
<p class="page-subtitle">سامانه سفارش عمده پارچه</p>
</div>
<form class="card" @submit.prevent="submit">
<FormErrorBanner :message="formError" />
<BaseInput
:model-value="displayPhone"
label="شماره موبایل"
type="tel"
inputmode="numeric"
autocomplete="tel"
dir="ltr"
align="center"
maxlength="11"
placeholder="۰۹۱۲۳۴۵۶۷۸۹"
:error="fieldErrors.phone"
hint="کد تأیید به این شماره ارسال می‌شود"
@update:model-value="onPhoneInput"
@blur="onPhoneBlur"
/>
<BaseButton block type="submit" :loading="auth.loading">دریافت کد تأیید</BaseButton>
</form>
<div class="mt-6 w-full max-w-[var(--form-max-width)] text-center">
<p class="m-0 text-sm text-muted">
حساب کاربری ندارید؟
<RouterLink
to="/register"
class="ms-1 font-bold text-primary no-underline hover:underline"
>
ثبت نام کنید
</RouterLink>
</p>
</div>
</div>
</div>
</template>
+363
View File
@@ -0,0 +1,363 @@
<script setup lang="ts">
import { ref, computed, onMounted, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useIntervalFn } from '@vueuse/core'
import { useAuthStore } from '@/stores/auth'
import FormErrorBanner from '@/components/ui/FormErrorBanner.vue'
import BaseButton from '@/components/ui/BaseButton.vue'
import AuthThemeToggle from '@/components/ui/AuthThemeToggle.vue'
import IconEditPen from '@/components/icons/IconEditPen.vue'
import IconChevronBack from '@/components/icons/IconChevronBack.vue'
import { toPersianDigits, extractNumericString, normalizePhone } from '@/utils/format'
import {
FORM_VALIDATION_SUMMARY,
clearFieldError,
hasFieldErrors,
type FieldErrors,
} from '@/utils/formErrors'
import { getPendingRegister } from '@/utils/pendingRegister'
const OTP_LENGTH = 4
const RESEND_SECONDS = 60
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const phone = computed(() => {
const raw = route.query.phone
const value = Array.isArray(raw) ? raw[0] : raw
return normalizePhone(typeof value === 'string' ? value : '')
})
const isRegister = computed(() => route.query.mode === 'register')
const digits = ref<string[]>(Array.from({ length: OTP_LENGTH }, () => ''))
const inputRefs = ref<(HTMLInputElement | null)[]>([])
const fieldErrors = ref<FieldErrors>({})
const formError = ref('')
const secondsLeft = ref(RESEND_SECONDS)
const code = computed(() => digits.value.join(''))
const canResend = computed(() => secondsLeft.value <= 0 && !auth.loading)
const timerLabel = computed(() => {
const minutes = Math.floor(secondsLeft.value / 60)
const seconds = secondsLeft.value % 60
const formatted = `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
return toPersianDigits(formatted)
})
const { pause, resume } = useIntervalFn(
() => {
if (secondsLeft.value <= 1) {
secondsLeft.value = 0
pause()
return
}
secondsLeft.value -= 1
},
1000,
{ immediate: false },
)
function clearCodeError() {
fieldErrors.value = clearFieldError(fieldErrors.value, 'code')
if (!hasFieldErrors(fieldErrors.value)) formError.value = ''
}
function setInputRef(el: unknown, index: number) {
inputRefs.value[index] = el as HTMLInputElement | null
}
function focusInput(index: number) {
const clamped = Math.max(0, Math.min(OTP_LENGTH - 1, index))
void nextTick(() => inputRefs.value[clamped]?.focus())
}
function startTimer() {
secondsLeft.value = RESEND_SECONDS
resume()
}
function fillDigits(value: string, startIndex = 0) {
const numeric = extractNumericString(value).slice(0, OTP_LENGTH - startIndex)
if (!numeric) return startIndex
const next = [...digits.value]
let cursor = startIndex
for (const digit of numeric) {
if (cursor >= OTP_LENGTH) break
next[cursor] = digit
cursor += 1
}
digits.value = next
clearCodeError()
return Math.min(cursor, OTP_LENGTH - 1)
}
function tryAutoSubmit() {
if (code.value.length === OTP_LENGTH && !auth.loading) {
void submit()
}
}
function onDigitInput(index: number, event: Event) {
const target = event.target as HTMLInputElement
const numeric = extractNumericString(target.value)
if (numeric.length > 1) {
const focusAt = fillDigits(numeric, index)
target.value = digits.value[index] ?? ''
focusInput(focusAt)
tryAutoSubmit()
return
}
const digit = numeric.slice(-1)
digits.value[index] = digit
target.value = digit
clearCodeError()
if (digit && index < OTP_LENGTH - 1) {
focusInput(index + 1)
} else if (digit) {
tryAutoSubmit()
}
}
function onDigitKeydown(index: number, event: KeyboardEvent) {
if (event.key === 'Backspace') {
if (digits.value[index]) {
digits.value[index] = ''
clearCodeError()
return
}
if (index > 0) {
digits.value[index - 1] = ''
clearCodeError()
focusInput(index - 1)
}
return
}
if (event.key === 'ArrowLeft') {
event.preventDefault()
focusInput(index - 1)
return
}
if (event.key === 'ArrowRight') {
event.preventDefault()
focusInput(index + 1)
}
}
function onDigitPaste(index: number, event: ClipboardEvent) {
event.preventDefault()
const pasted = event.clipboardData?.getData('text') ?? ''
const focusAt = fillDigits(pasted, index)
focusInput(focusAt)
tryAutoSubmit()
}
function displayDigit(value: string) {
return value ? toPersianDigits(value) : ''
}
async function submit() {
if (auth.loading) return
fieldErrors.value = {}
formError.value = ''
const errors: FieldErrors = {}
if (code.value.length < OTP_LENGTH) {
errors.code = 'کد تأیید را کامل وارد کنید.'
}
if (hasFieldErrors(errors)) {
fieldErrors.value = errors
formError.value = FORM_VALIDATION_SUMMARY
return
}
const result = await auth.confirmOtp(phone.value, extractNumericString(code.value))
if (result.success) {
router.replace('/')
} else {
formError.value = result.message ?? 'کد تأیید نامعتبر است.'
digits.value = Array.from({ length: OTP_LENGTH }, () => '')
focusInput(0)
}
}
async function resend() {
if (!canResend.value) return
formError.value = ''
fieldErrors.value = {}
const pending = isRegister.value ? getPendingRegister() : null
if (isRegister.value && !pending) {
formError.value = 'برای ارسال مجدد کد، نام و نام خانوادگی لازم است. لطفاً دوباره ثبت‌نام را شروع کنید.'
return
}
const result = await auth.requestOtp(phone.value, {
isRegister: isRegister.value,
firstName: pending?.firstName,
lastName: pending?.lastName,
})
if (result.success) {
digits.value = Array.from({ length: OTP_LENGTH }, () => '')
startTimer()
focusInput(0)
} else {
formError.value = result.message ?? 'خطا در ارسال مجدد کد.'
}
}
function changePhone() {
router.replace({ name: isRegister.value ? 'register' : 'login' })
}
onMounted(() => {
if (!phone.value) {
router.replace({ name: 'login' })
return
}
startTimer()
focusInput(0)
})
</script>
<template>
<div class="page page-shell auth-page">
<button
type="button"
class="auth-back"
aria-label="بازگشت"
@click="router.push(isRegister ? '/register' : '/login')"
>
<IconChevronBack :size="20" />
</button>
<AuthThemeToggle />
<div class="page__scroll flex flex-col justify-center py-6">
<div class="auth-header">
<h1 class="page-title">تأیید شماره موبایل</h1>
<p class="page-subtitle">کد ارسال‌شده به {{ toPersianDigits(phone) }} را وارد کنید</p>
</div>
<form class="card" @submit.prevent="submit">
<FormErrorBanner :message="formError" />
<div class="form-group">
<div
class="otp-row flex w-full max-w-full justify-center ltr"
dir="ltr"
role="group"
aria-label="کد تأیید"
>
<input
v-for="(digit, index) in digits"
:id="`otp-${index}`"
:key="index"
:ref="(el) => setInputRef(el, index)"
class="otp-box"
:class="{ 'otp-box--error': fieldErrors.code }"
type="text"
inputmode="numeric"
autocomplete="one-time-code"
maxlength="1"
:value="displayDigit(digit)"
:aria-label="`رقم ${toPersianDigits(String(index + 1))}`"
@input="onDigitInput(index, $event)"
@keydown="onDigitKeydown(index, $event)"
@paste="onDigitPaste(index, $event)"
/>
</div>
<p v-if="fieldErrors.code" class="input-error-text">{{ fieldErrors.code }}</p>
</div>
<BaseButton block type="submit" :loading="auth.loading">تأیید و ورود</BaseButton>
<BaseButton
block
variant="outline"
type="button"
class="mt-2"
:disabled="!canResend"
@click="resend"
>
<template v-if="canResend">ارسال مجدد کد</template>
<template v-else>ارسال مجدد کد ({{ timerLabel }})</template>
</BaseButton>
<button
type="button"
class="mt-3 mx-auto flex items-center justify-center gap-1.5 border-0 bg-transparent p-0 text-sm text-muted cursor-pointer transition-colors hover:text-text"
@click="changePhone"
>
<span class="ltr tabular-nums" dir="ltr">{{ toPersianDigits(phone) }}</span>
<span>ویرایش شماره</span>
<IconEditPen :size="18" :stroke-width="1.5" />
</button>
</form>
</div>
</div>
</template>
<style scoped>
.otp-row {
gap: clamp(0.25rem, 1.8vw, 0.625rem);
}
.otp-box {
flex: 1 1 0;
width: 0;
min-width: 0;
max-width: 48px;
aspect-ratio: 48 / 52;
height: auto;
padding: 0;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface);
color: var(--color-text);
font-size: clamp(0.95rem, 4.2vw, 1.25rem);
font-weight: 600;
text-align: center;
caret-color: var(--color-primary);
transition: border-color 0.15s;
}
.otp-box:focus {
outline: none;
border-color: var(--color-primary);
}
.otp-box--error {
border-color: var(--color-danger);
}
.input-error-text {
color: var(--color-danger);
font-size: 0.8125rem;
margin: 4px 0 0;
}
.auth-back {
position: fixed;
top: calc(12px + env(safe-area-inset-top, 0px));
inset-inline-start: 16px;
z-index: 100;
width: 40px;
height: 40px;
border: none;
background: transparent;
display: grid;
place-items: center;
cursor: pointer;
color: var(--color-text);
padding: 0;
}
</style>
+203
View File
@@ -0,0 +1,203 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import BaseInput from '@/components/ui/BaseInput.vue'
import FormErrorBanner from '@/components/ui/FormErrorBanner.vue'
import BaseButton from '@/components/ui/BaseButton.vue'
import AuthThemeToggle from '@/components/ui/AuthThemeToggle.vue'
import IconChevronBack from '@/components/icons/IconChevronBack.vue'
import { normalizePhone } from '@/utils/format'
import { validatePhone } from '@/utils/validation'
import {
FORM_VALIDATION_SUMMARY,
clearFieldError,
hasFieldErrors,
type FieldErrors,
} from '@/utils/formErrors'
import {
JOB_CATEGORY_OPTIONS,
setPendingRegister,
type JobCategory,
} from '@/utils/pendingRegister'
const router = useRouter()
const auth = useAuthStore()
const firstName = ref('')
const lastName = ref('')
const phone = ref('')
const jobCategory = ref<JobCategory | ''>('')
const fieldErrors = ref<FieldErrors>({})
const formError = ref('')
function clearField(key: string) {
fieldErrors.value = clearFieldError(fieldErrors.value, key)
if (!hasFieldErrors(fieldErrors.value)) formError.value = ''
}
async function submit() {
fieldErrors.value = {}
formError.value = ''
const normalized = normalizePhone(phone.value)
const errors: FieldErrors = {}
if (!firstName.value.trim()) {
errors.firstName = 'نام الزامی است.'
}
if (!lastName.value.trim()) {
errors.lastName = 'نام خانوادگی الزامی است.'
}
if (!validatePhone(normalized)) {
errors.phone = 'شماره موبایل معتبر نیست.'
}
if (!jobCategory.value) {
errors.jobCategory = 'دسته‌بندی شغلی الزامی است.'
}
if (hasFieldErrors(errors)) {
fieldErrors.value = errors
formError.value = FORM_VALIDATION_SUMMARY
return
}
const names = {
firstName: firstName.value.trim(),
lastName: lastName.value.trim(),
jobCategory: jobCategory.value as JobCategory,
}
setPendingRegister(names)
const result = await auth.requestOtp(normalized, {
isRegister: true,
firstName: names.firstName,
lastName: names.lastName,
})
if (result.success) {
router.push({ name: 'otp', query: { phone: normalized, mode: 'register' } })
} else {
formError.value = result.message ?? 'خطا در ارسال کد تأیید.'
}
}
</script>
<template>
<div class="page page-shell auth-page">
<button type="button" class="auth-back" aria-label="بازگشت" @click="router.push('/login')">
<IconChevronBack :size="20" />
</button>
<AuthThemeToggle />
<div class="page__scroll flex flex-col justify-center py-6">
<div class="auth-header">
<div class="text-5xl mb-3">🧵</div>
<h1 class="page-title">ثبت‌نام</h1>
<p class="page-subtitle">ایجاد حساب کاربری جدید</p>
</div>
<div class="card">
<FormErrorBanner :message="formError" />
<BaseInput
v-model="firstName"
label="نام"
placeholder="نام خود را وارد کنید"
:error="fieldErrors.firstName"
@update:model-value="clearField('firstName')"
/>
<BaseInput
v-model="lastName"
label="نام خانوادگی"
placeholder="نام خانوادگی"
:error="fieldErrors.lastName"
@update:model-value="clearField('lastName')"
/>
<BaseInput
v-model="phone"
label="شماره موبایل"
type="tel"
inputmode="tel"
dir="ltr"
placeholder="۰۹۱۲۳۴۵۶۷۸۹"
:error="fieldErrors.phone"
@update:model-value="clearField('phone')"
/>
<label class="job-label">
دسته‌بندی شغلی
<select
v-model="jobCategory"
class="job-select"
:aria-invalid="!!fieldErrors.jobCategory"
@change="clearField('jobCategory')"
>
<option disabled value="">انتخاب کنید</option>
<option v-for="opt in JOB_CATEGORY_OPTIONS" :key="opt.id" :value="opt.id">
{{ opt.label }}
</option>
</select>
<span v-if="fieldErrors.jobCategory" class="job-error">{{ fieldErrors.jobCategory }}</span>
</label>
<BaseButton block :loading="auth.loading" @click="submit">دریافت کد تأیید</BaseButton>
</div>
<div class="mt-6 w-full max-w-[var(--form-max-width)] text-center">
<p class="m-0 text-sm text-muted">
قبلاً ثبت‌نام کرده‌اید؟
<RouterLink
to="/login"
class="ms-1 font-bold text-primary no-underline hover:underline"
>
وارد شوید
</RouterLink>
</p>
</div>
</div>
</div>
</template>
<style scoped>
.auth-back {
position: fixed;
top: calc(12px + env(safe-area-inset-top, 0px));
inset-inline-start: 16px;
z-index: 100;
width: 40px;
height: 40px;
border: none;
background: transparent;
display: grid;
place-items: center;
cursor: pointer;
color: var(--color-text);
padding: 0;
}
.job-label {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 14px;
font-size: 0.875rem;
font-weight: 600;
color: var(--color-text);
}
.job-select {
height: 48px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 0 12px;
font: inherit;
background: var(--color-surface);
color: var(--color-text);
}
.job-error {
color: var(--color-danger);
font-size: 0.75rem;
font-weight: 500;
}
</style>