design login-register pages

This commit is contained in:
2026-09-13 10:46:57 +03:30
parent 1e355e2c01
commit d6e424137d
141 changed files with 1233 additions and 258 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

+72
View File
@@ -192,11 +192,83 @@
background: var(--color-bg);
}
.app-shell--immersive {
padding: 0;
align-items: stretch;
justify-content: stretch;
background: #efe8df;
}
.app-shell--immersive .app-layout,
.app-shell--immersive .app-frame {
max-width: none;
max-height: none;
width: 100%;
height: 100%;
border-radius: 0;
box-shadow: none;
background: transparent;
overflow: hidden;
}
.app-shell--immersive .app-main,
.app-shell--immersive .app-main__content,
.app-shell--immersive .app-content,
.app-shell--immersive .page-transition {
height: 100%;
min-height: 0;
flex: 1;
overflow: hidden;
}
.app-shell--immersive .page-transition {
display: flex;
flex-direction: column;
}
.app-shell--immersive .page-transition > * {
flex: 1;
min-height: 0;
height: 100%;
}
.app-shell--immersive .app-content {
padding-inline: 0;
max-width: none;
}
.app-shell--immersive .page {
height: 100%;
min-height: 0;
overflow: hidden;
background: transparent;
}
@media (min-width: 768px) {
.app-shell--immersive .page {
padding: 0;
max-width: none;
margin: 0;
height: 100%;
min-height: 0;
overflow: hidden;
flex: 1;
}
.app-shell--no-nav {
padding: 24px;
}
.app-shell--no-nav.app-shell--immersive {
padding: 0;
}
.app-shell--no-nav.app-shell--immersive .app-layout,
.app-shell--no-nav.app-shell--immersive .app-frame {
max-height: none;
height: 100%;
}
.app-shell--no-nav .app-layout,
.app-shell--no-nav .app-frame {
max-height: calc(100dvh - 48px);
+25
View File
@@ -0,0 +1,25 @@
<script setup lang="ts">
import logoUrl from '@/assets/images/nademitex-logo.png'
withDefaults(defineProps<{ size?: number }>(), { size: 72 })
</script>
<template>
<img
class="brand-logo"
:src="logoUrl"
alt=""
:style="{ height: `${size}px` }"
draggable="false"
/>
</template>
<style scoped>
.brand-logo {
display: block;
width: auto;
flex-shrink: 0;
object-fit: contain;
user-select: none;
}
</style>
+3 -12
View File
@@ -9,6 +9,7 @@ import {
resolveNavPath,
} from '@/utils/navigation'
import IconHeadset from '@/components/icons/IconHeadset.vue'
import IconQemashMark from '@/components/icons/IconQemashMark.vue'
const route = useRoute()
const router = useRouter()
@@ -29,8 +30,8 @@ function navigate(item: (typeof DESKTOP_NAV_ITEMS)[number]) {
<template>
<aside class="desktop-sidebar" aria-label="ناوبری اصلی">
<div class="desktop-sidebar__brand">
<span class="desktop-sidebar__logo-mark" aria-hidden="true">پ</span>
<strong class="desktop-sidebar__title">پارس بافت</strong>
<IconQemashMark :size="36" />
<strong class="desktop-sidebar__title">نادمی تکس</strong>
</div>
<nav class="desktop-sidebar__nav">
@@ -112,16 +113,6 @@ function navigate(item: (typeof DESKTOP_NAV_ITEMS)[number]) {
}
.desktop-sidebar__logo-mark {
width: 36px;
height: 36px;
border-radius: var(--radius-sm);
background: var(--color-primary);
color: var(--color-surface);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.125rem;
font-weight: 700;
flex-shrink: 0;
}
+3
View File
@@ -32,6 +32,8 @@ const isWideGuest = computed(() => {
]
return wideRoutes.includes(route.name as string)
})
const isImmersiveAuth = computed(() => route.name === 'login' || route.name === 'register')
</script>
<template>
@@ -41,6 +43,7 @@ const isWideGuest = computed(() => {
'app-shell--no-nav': !showNav,
'app-shell--wide': isWideGuest,
'app-shell--with-nav': showNav,
'app-shell--immersive': isImmersiveAuth,
}"
>
<div class="app-layout">
+16
View File
@@ -0,0 +1,16 @@
import { apiRequest } from '@/services/http'
export type PublicContact = {
supportPhone: string | null
supportEmail: string | null
}
export function telHref(phone: string | null | undefined): string | undefined {
if (!phone) return undefined
const digits = phone.replace(/[^\d+]/g, '')
return digits ? `tel:${digits}` : undefined
}
export function getPublicContact() {
return apiRequest<PublicContact>('/settings/public', { auth: false })
}
+482 -35
View File
@@ -1,11 +1,13 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, onMounted, 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 IconChevronLeft from '@/components/icons/IconChevronLeft.vue'
import IconHeadset from '@/components/icons/IconHeadset.vue'
import IconPhone from '@/components/icons/IconPhone.vue'
import IconQemashMark from '@/components/icons/IconQemashMark.vue'
import { getPublicContact, telHref } from '@/services/settings'
import { normalizePhone, toPersianDigits } from '@/utils/format'
import { validatePhone } from '@/utils/validation'
import {
@@ -14,10 +16,14 @@ import {
hasFieldErrors,
type FieldErrors,
} from '@/utils/formErrors'
import fabricUrl from '@/assets/images/login-fabric.jpg'
const router = useRouter()
const auth = useAuthStore()
const supportPhone = ref<string | null>(null)
const supportHref = computed(() => telHref(supportPhone.value))
const phone = ref('')
const fieldErrors = ref<FieldErrors>({})
const formError = ref('')
@@ -69,44 +75,485 @@ async function submit() {
formError.value = result.message ?? 'خطا در ارسال کد تأیید.'
}
}
onMounted(async () => {
try {
const contact = await getPublicContact()
supportPhone.value = contact.supportPhone
} catch {
supportPhone.value = null
}
})
</script>
<template>
<div class="page auth-page">
<header class="auth-top">
<span aria-hidden="true" />
<h1 class="auth-top__title">ورود</h1>
<AuthThemeToggle variant="inline" />
</header>
<div class="page login-page">
<img class="login-page__bg" :src="fabricUrl" alt="" />
<div class="page__scroll auth-body">
<p class="auth-lead">برای سفارش پارچه، شماره موبایل خود را وارد کنید.</p>
<div class="login-page__scroll">
<div class="login-page__stage">
<header class="login-top">
<p class="login-top__welcome">به دنیای پارچه خوش آمدید</p>
</header>
<form class="card" @submit.prevent="submit">
<FormErrorBanner :message="formError" />
<div class="login-brand">
<IconQemashMark :size="78" />
<h1 class="login-brand__fa">نادمی تکس</h1>
<p class="login-brand__en">NADEMITEX</p>
<p class="login-brand__tag">انتخاب حرفه‌ای‌ها</p>
</div>
<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>
<form class="login-card" @submit.prevent="submit">
<h2 class="login-card__title">ورود به حساب کاربری</h2>
<p class="login-card__lead">برای ادامه، شماره موبایل خود را وارد کنید.</p>
<p class="auth-switch">
حساب کاربری ندارید؟
<RouterLink to="/register">ثبت‌نام کنید</RouterLink>
</p>
<FormErrorBanner :message="formError" />
<div class="phone-field" dir="ltr" :class="{ 'is-invalid': Boolean(fieldErrors.phone) }">
<span class="phone-field__locale" aria-hidden="true">
<svg class="iran-flag" viewBox="0 0 21 15" xmlns="http://www.w3.org/2000/svg">
<rect width="21" height="5" fill="#239F40" />
<rect y="5" width="21" height="5" fill="#fff" />
<rect y="10" width="21" height="5" fill="#DA0001" />
<circle cx="10.5" cy="7.5" r="1.35" fill="#DA0001" />
</svg>
<svg class="phone-field__caret" viewBox="0 0 10 6" fill="none">
<path
d="M1 1.2 5 5l4-3.8"
stroke="currentColor"
stroke-width="1.3"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</span>
<input
class="phone-field__input"
:value="displayPhone"
type="tel"
inputmode="numeric"
autocomplete="tel"
dir="ltr"
maxlength="11"
placeholder="۰۹۱۲۳۴۵۶۷۸۹"
:aria-invalid="Boolean(fieldErrors.phone)"
aria-label="شماره موبایل"
@input="onPhoneInput(($event.target as HTMLInputElement).value)"
@blur="onPhoneBlur"
/>
</div>
<p v-if="fieldErrors.phone" class="phone-field__error">{{ fieldErrors.phone }}</p>
<button class="login-submit" type="submit" :disabled="auth.loading">
<span v-if="auth.loading" class="login-submit__spinner" aria-hidden="true" />
<span class="login-submit__chevron" aria-hidden="true">
<IconChevronLeft :size="18" color="#F4EDE4" />
</span>
<span>دریافت کد تأیید</span>
</button>
<p class="login-register">
حساب کاربری ندارید؟
<RouterLink to="/register">ثبت‌نام کنید</RouterLink>
</p>
<div class="login-or" role="separator">
<span>یا</span>
</div>
<div class="login-help">
<a
class="login-help__item"
:href="supportHref"
:class="{ 'is-disabled': !supportHref }"
:aria-disabled="!supportHref"
>
<span class="login-help__icon">
<IconHeadset :size="22" color="#3A3936" />
</span>
پشتیبانی
</a>
<a
class="login-help__item"
:href="supportHref"
:class="{ 'is-disabled': !supportHref }"
:aria-disabled="!supportHref"
>
<span class="login-help__icon">
<IconPhone :size="22" color="#3A3936" />
</span>
تماس با ما
</a>
</div>
<p class="login-card__foot">پارچه، آغاز یک اعتماد ماندگار…</p>
</form>
<div class="login-spacer" aria-hidden="true" />
<p class="login-watermark">MORE<br />THAN<br />FABRIC</p>
</div>
</div>
</div>
</template>
<style scoped>
.login-page {
position: relative;
isolation: isolate;
height: 100%;
min-height: 100%;
color: #f4ede4;
background: #1a1612;
overflow: hidden;
}
.login-page__bg {
position: absolute;
inset: 0;
z-index: 0;
width: 100%;
height: 100%;
object-fit: cover;
object-position: center center;
pointer-events: none;
user-select: none;
}
.login-page__scroll {
position: relative;
z-index: 1;
height: 100%;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
}
.login-page__stage {
display: flex;
flex-direction: column;
min-height: 100%;
width: 100%;
max-width: 360px;
margin-inline: auto;
padding: calc(10px + env(safe-area-inset-top, 0px)) 16px
calc(14px + env(safe-area-inset-bottom, 0px));
}
.login-top {
display: flex;
align-items: center;
justify-content: flex-start;
min-height: 36px;
}
.login-top__welcome {
color: #f3e6d6;
font-size: 0.75rem;
font-weight: 400;
line-height: 1.6;
}
.login-brand {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
margin-top: 28px;
margin-bottom: 20px;
color: #f3e6d6;
}
.login-brand__fa {
margin: 14px 0 0;
font-size: 1.75rem;
font-weight: 700;
line-height: 1.25;
letter-spacing: -0.02em;
}
.login-brand__en {
margin: 6px 0 0;
font-size: 0.6875rem;
font-weight: 500;
letter-spacing: 0.28em;
color: #ead7c0;
}
.login-brand__tag {
margin: 10px 0 0;
font-size: 0.875rem;
font-weight: 400;
color: #efe2d2;
}
.login-card {
width: 100%;
margin: 0;
padding: 22px 18px 16px;
border: none;
border-radius: 28px;
background: #f4f1ed;
box-shadow: 0 18px 40px rgba(12, 10, 8, 0.22);
color: #2a2622;
}
.login-card__title {
margin: 0;
text-align: center;
font-size: 1.0625rem;
font-weight: 700;
line-height: 1.5;
color: #1c1b19;
}
.login-card__lead {
margin: 6px 0 0;
text-align: center;
font-size: 0.75rem;
line-height: 1.7;
color: #8a847c;
}
.login-card :deep(.form-error-banner) {
margin-top: 14px;
margin-bottom: 0;
}
.phone-field {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
height: 48px;
margin-top: 18px;
padding: 0 8px 0 14px;
border: 1px solid #e4e0db;
border-radius: 999px;
background: #f7f5f1;
}
.phone-field.is-invalid {
border-color: color-mix(in srgb, var(--color-danger) 55%, #e4e0db);
}
.phone-field__locale {
display: inline-flex;
align-items: center;
gap: 4px;
color: #9a9590;
flex-shrink: 0;
}
.iran-flag {
width: 22px;
height: 16px;
border-radius: 2px;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.06);
overflow: hidden;
}
.phone-field__caret {
width: 10px;
height: 6px;
}
.phone-field__input {
flex: 1;
min-width: 0;
height: 100%;
border: none;
background: transparent;
color: #2a2622;
font-family: inherit;
font-size: 0.9375rem;
letter-spacing: 0.04em;
text-align: start;
outline: none;
}
.phone-field__input::placeholder {
color: #b0aaa3;
}
.phone-field__error {
margin: 8px 4px 0;
color: var(--color-danger);
font-size: 0.75rem;
line-height: 1.5;
}
.login-submit {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 48px;
margin-top: 12px;
padding: 0 44px;
border: none;
border-radius: 999px;
background: #1e4644;
color: #f7f4ef;
font-family: inherit;
font-size: 0.9375rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s ease;
}
.login-submit:not(:disabled):hover,
.login-submit:not(:disabled):focus-visible {
background: #173835;
}
.login-submit:focus-visible {
outline: 2px solid #c9a56a;
outline-offset: 2px;
}
.login-submit:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.login-submit__chevron {
position: absolute;
inset-inline-end: 16px;
display: grid;
place-items: center;
}
.login-submit__spinner {
position: absolute;
inset-inline-start: 16px;
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.28);
border-top-color: #fff;
border-radius: 50%;
animation: login-spin 0.6s linear infinite;
}
.login-register {
margin: 12px 0 0;
text-align: center;
font-size: 0.8125rem;
line-height: 1.6;
color: #8a847c;
}
.login-register a {
font-weight: 700;
color: #1e4644;
text-decoration: none;
}
.login-or {
display: flex;
align-items: center;
gap: 12px;
margin: 16px 4px 14px;
color: #9a9590;
font-size: 0.75rem;
}
.login-or::before,
.login-or::after {
content: '';
flex: 1;
height: 1px;
background: #e6e1db;
}
.login-help {
display: flex;
justify-content: center;
gap: 48px;
}
.login-help__item {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
color: #6f6b66;
font-size: 0.75rem;
text-decoration: none;
}
.login-help__item.is-disabled {
pointer-events: none;
}
.login-help__icon {
width: 52px;
height: 52px;
border: 1px solid #d8d3cd;
border-radius: 50%;
display: grid;
place-items: center;
background: transparent;
}
.login-card__foot {
margin: 18px 0 2px;
text-align: center;
font-size: 0.75rem;
line-height: 1.7;
color: #8a847c;
}
.login-spacer {
flex: 1 1 auto;
min-height: 16px;
}
.login-watermark {
margin: 0;
padding-bottom: 8px;
color: rgba(244, 237, 228, 0.58);
font-size: 0.625rem;
font-weight: 600;
letter-spacing: 0.2em;
line-height: 1.35;
text-align: left;
direction: ltr;
}
@keyframes login-spin {
to {
transform: rotate(360deg);
}
}
@media (max-width: 359px) {
.login-page__stage {
padding-inline: 12px;
}
.login-brand {
margin-top: 16px;
margin-bottom: 14px;
}
.login-brand__fa {
font-size: 1.5rem;
}
.login-help {
gap: 36px;
}
}
@media (min-width: 768px) {
.login-page {
padding: 0;
max-width: none;
margin: 0;
height: 100%;
min-height: 0;
overflow: hidden;
}
}
</style>
+551 -133
View File
@@ -3,12 +3,15 @@ import { computed, ref } from 'vue'
import { onClickOutside } from '@vueuse/core'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import BaseInput from '@/components/ui/BaseInput.vue'
import BasicIcon from '@/components/BasicPacks/BasicIcon.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 IconBuilding from '@/components/icons/IconBuilding.vue'
import IconChevronLeft from '@/components/icons/IconChevronLeft.vue'
import IconDiamond from '@/components/icons/IconDiamond.vue'
import IconPhone from '@/components/icons/IconPhone.vue'
import IconProfile from '@/components/icons/IconProfile.vue'
import IconVerified from '@/components/icons/IconVerified.vue'
import { normalizePhone, toPersianDigits } from '@/utils/format'
import { validatePhone } from '@/utils/validation'
import {
FORM_VALIDATION_SUMMARY,
@@ -21,6 +24,7 @@ import {
setPendingRegister,
type JobCategory,
} from '@/utils/pendingRegister'
import cardUrl from '@/assets/images/register-card.jpg'
const router = useRouter()
const auth = useAuthStore()
@@ -29,13 +33,15 @@ const firstName = ref('')
const lastName = ref('')
const phone = ref('')
const jobCategory = ref<JobCategory | ''>('')
const acceptedTerms = ref(true)
const jobOpen = ref(false)
const jobRoot = ref<HTMLElement | null>(null)
const fieldErrors = ref<FieldErrors>({})
const formError = ref('')
const displayPhone = computed(() => (phone.value ? toPersianDigits(phone.value) : ''))
const selectedJobLabel = computed(
() => JOB_CATEGORY_OPTIONS.find((opt) => opt.id === jobCategory.value)?.label ?? 'انتخاب کنید',
() => JOB_CATEGORY_OPTIONS.find((opt) => opt.id === jobCategory.value)?.label ?? 'دسته‌بندی شغلی',
)
onClickOutside(jobRoot, () => {
@@ -53,6 +59,11 @@ function clearField(key: string) {
if (!hasFieldErrors(fieldErrors.value)) formError.value = ''
}
function onPhoneInput(value: string) {
phone.value = normalizePhone(value)
clearField('phone')
}
async function submit() {
fieldErrors.value = {}
formError.value = ''
@@ -72,6 +83,9 @@ async function submit() {
if (!jobCategory.value) {
errors.jobCategory = 'دسته‌بندی شغلی الزامی است.'
}
if (!acceptedTerms.value) {
errors.terms = 'پذیرش قوانین برای ثبت‌نام لازم است.'
}
if (hasFieldErrors(errors)) {
fieldErrors.value = errors
@@ -100,140 +114,366 @@ async function submit() {
</script>
<template>
<div class="page auth-page">
<header class="auth-top">
<button type="button" class="icon-btn" aria-label="بازگشت" @click="router.push('/login')">
<IconChevronBack :size="20" />
</button>
<h1 class="auth-top__title">ثبت‌نام</h1>
<AuthThemeToggle variant="inline" />
</header>
<div class="page__scroll auth-body">
<p class="auth-lead">نام، موبایل و شغل خود را وارد کنید تا حساب ساخته شود.</p>
<form class="card" @submit.prevent="submit">
<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')"
/>
<div ref="jobRoot" class="job-field">
<span id="job-label" class="job-label">دسته‌بندی شغلی</span>
<div class="page register-page">
<div class="register-page__scroll">
<div class="register-page__stage">
<header class="register-top">
<p class="register-top__welcome">همراه ما باشید</p>
<button
type="button"
class="job-trigger"
:class="{ 'is-open': jobOpen, 'is-error': fieldErrors.jobCategory }"
aria-labelledby="job-label"
aria-haspopup="listbox"
:aria-expanded="jobOpen"
:aria-invalid="!!fieldErrors.jobCategory"
@click="jobOpen = !jobOpen"
class="register-top__back"
aria-label="بازگشت"
@click="router.push('/login')"
>
<span :class="{ 'is-placeholder': !jobCategory }">{{ selectedJobLabel }}</span>
<svg class="job-chevron" viewBox="0 0 12 12" aria-hidden="true">
<path
d="M2.5 4.5 6 8l3.5-3.5"
fill="none"
stroke="currentColor"
stroke-width="1.4"
stroke-linecap="round"
/>
</svg>
<IconChevronLeft :size="20" color="#2A2622" />
</button>
<ul v-if="jobOpen" class="job-menu" role="listbox">
<li v-for="opt in JOB_CATEGORY_OPTIONS" :key="opt.id">
<button
type="button"
role="option"
class="job-option"
:class="{ 'is-active': jobCategory === opt.id }"
:aria-selected="jobCategory === opt.id"
@click="selectJob(opt.id)"
>
{{ opt.label }}
</button>
</li>
</ul>
<span v-if="fieldErrors.jobCategory" class="job-error">{{ fieldErrors.jobCategory }}</span>
</header>
<h1 class="register-title">ثبت‌نام در نادمی تکس</h1>
<p class="register-lead">با چند مرحله ساده، عضوی از خانواده ما شوید.</p>
<form class="register-form" @submit.prevent="submit">
<FormErrorBanner :message="formError" />
<label class="reg-field" :class="{ 'is-invalid': Boolean(fieldErrors.firstName) }">
<span class="reg-field__meta">
<span>نام</span>
<IconProfile :size="18" color="#8A847C" />
</span>
<input
v-model="firstName"
class="reg-field__input"
type="text"
autocomplete="given-name"
@input="clearField('firstName')"
/>
</label>
<p v-if="fieldErrors.firstName" class="reg-field__error">{{ fieldErrors.firstName }}</p>
<label class="reg-field" :class="{ 'is-invalid': Boolean(fieldErrors.lastName) }">
<span class="reg-field__meta">
<span>نام خانوادگی</span>
<IconProfile :size="18" color="#8A847C" />
</span>
<input
v-model="lastName"
class="reg-field__input"
type="text"
autocomplete="family-name"
@input="clearField('lastName')"
/>
</label>
<p v-if="fieldErrors.lastName" class="reg-field__error">{{ fieldErrors.lastName }}</p>
<label class="reg-field" :class="{ 'is-invalid': Boolean(fieldErrors.phone) }">
<span class="reg-field__meta">
<span>شماره موبایل</span>
<IconPhone :size="18" color="#8A847C" />
</span>
<span class="reg-field__phone" dir="ltr">
<span class="reg-field__locale" aria-hidden="true">
<svg class="iran-flag" viewBox="0 0 21 15" xmlns="http://www.w3.org/2000/svg">
<rect width="21" height="5" fill="#239F40" />
<rect y="5" width="21" height="5" fill="#fff" />
<rect y="10" width="21" height="5" fill="#DA0001" />
<circle cx="10.5" cy="7.5" r="1.35" fill="#DA0001" />
</svg>
<svg class="reg-field__caret" viewBox="0 0 10 6" fill="none">
<path
d="M1 1.2 5 5l4-3.8"
stroke="currentColor"
stroke-width="1.3"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</span>
<input
class="reg-field__input reg-field__input--phone"
:value="displayPhone"
type="tel"
inputmode="numeric"
autocomplete="tel"
dir="ltr"
maxlength="11"
@input="onPhoneInput(($event.target as HTMLInputElement).value)"
/>
</span>
</label>
<p v-if="fieldErrors.phone" class="reg-field__error">{{ fieldErrors.phone }}</p>
<div ref="jobRoot" class="job-wrap">
<button
type="button"
class="reg-field job-trigger"
:class="{ 'is-open': jobOpen, 'is-invalid': Boolean(fieldErrors.jobCategory) }"
aria-haspopup="listbox"
:aria-expanded="jobOpen"
@click="jobOpen = !jobOpen"
>
<span class="reg-field__meta">
<span :class="{ 'is-placeholder': !jobCategory }">{{ selectedJobLabel }}</span>
<IconBuilding :size="18" color="#8A847C" />
</span>
<svg class="job-chevron" viewBox="0 0 12 12" aria-hidden="true">
<path
d="M2.5 4.5 6 8l3.5-3.5"
fill="none"
stroke="currentColor"
stroke-width="1.4"
stroke-linecap="round"
/>
</svg>
</button>
<ul v-if="jobOpen" class="job-menu" role="listbox">
<li v-for="opt in JOB_CATEGORY_OPTIONS" :key="opt.id">
<button
type="button"
role="option"
class="job-option"
:class="{ 'is-active': jobCategory === opt.id }"
:aria-selected="jobCategory === opt.id"
@click="selectJob(opt.id)"
>
{{ opt.label }}
</button>
</li>
</ul>
<p v-if="fieldErrors.jobCategory" class="reg-field__error">{{ fieldErrors.jobCategory }}</p>
</div>
<button class="register-submit" type="submit" :disabled="auth.loading">
<span v-if="auth.loading" class="register-submit__spinner" aria-hidden="true" />
<span class="register-submit__chevron" aria-hidden="true">
<IconChevronLeft :size="18" color="#F4EDE4" />
</span>
<span>دریافت کد تأیید</span>
</button>
<label class="register-terms">
<input v-model="acceptedTerms" class="register-terms__native" type="checkbox" />
<span class="register-terms__box" :class="{ 'is-on': acceptedTerms }" aria-hidden="true">
<svg v-if="acceptedTerms" viewBox="0 0 12 12" fill="none">
<path
d="M2.2 6.2 4.7 8.7 9.8 3.4"
stroke="#fff"
stroke-width="1.6"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</span>
<span>
با ثبت‌نام،
<strong>قوانین و شرایط</strong>
استفاده از سرویس را می‌پذیرم.
</span>
</label>
<p v-if="fieldErrors.terms" class="reg-field__error">{{ fieldErrors.terms }}</p>
</form>
<div class="register-foot">
<img class="register-foot__bg" :src="cardUrl" alt="" />
<section class="register-perks" aria-label="مزایا">
<article class="register-perk">
<IconVerified :size="22" color="#6F6B66" />
<strong>اطمینان از اصالت محصولات</strong>
</article>
<article class="register-perk">
<BasicIcon icon="vuesax-linear:truck-time" :size="22" color="#6F6B66" />
<strong>دسترسی سریع به کالیته‌های جدید</strong>
</article>
<article class="register-perk">
<IconDiamond :size="22" color="#6F6B66" />
<strong>تجربه خرید ویژه مشتریان</strong>
</article>
</section>
<p class="register-foot__copy">همراه حرفه‌ای<br />کسب‌وکار شما</p>
</div>
<BaseButton block type="submit" :loading="auth.loading">دریافت کد تأیید</BaseButton>
</form>
<p class="auth-switch">
قبلاً ثبت‌نام کرده‌اید؟
<RouterLink to="/login">وارد شوید</RouterLink>
</p>
</div>
</div>
</div>
</template>
<style scoped>
.job-field {
position: relative;
.register-page {
background: #f3eee8;
color: #2a2622;
overflow: hidden;
}
.register-page__scroll {
height: 100%;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
}
.register-page__stage {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 14px;
min-height: 100%;
width: 100%;
max-width: 390px;
margin-inline: auto;
padding: calc(10px + env(safe-area-inset-top, 0px)) 20px 0;
}
.job-label {
font-size: 0.875rem;
font-weight: 600;
color: var(--color-text);
}
.job-trigger {
.register-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
height: 48px;
padding: 0 14px;
border: 1px solid #eeeef1;
border-radius: 12px;
background: var(--color-surface);
color: var(--color-text);
font: inherit;
font-size: 0.9375rem;
text-align: start;
min-height: 36px;
}
.register-top__welcome {
margin: 0;
color: #6f6b66;
font-size: 0.75rem;
}
.register-top__back {
width: 36px;
height: 36px;
margin-inline-start: auto;
padding: 0;
border: none;
background: transparent;
display: grid;
place-items: center;
cursor: pointer;
}
.register-title {
margin: 18px 0 0;
text-align: center;
font-size: 1.375rem;
font-weight: 700;
line-height: 1.45;
color: #1c1b19;
}
.register-lead {
margin: 6px 0 0;
text-align: center;
font-size: 0.75rem;
line-height: 1.7;
color: #8a847c;
}
.register-form {
display: flex;
flex-direction: column;
margin-top: 22px;
}
.register-form :deep(.form-error-banner) {
margin-bottom: 12px;
}
.reg-field {
display: flex;
align-items: center;
gap: 10px;
height: 52px;
margin-top: 10px;
padding: 0 14px;
border: 1px solid #e6e1db;
border-radius: 999px;
background: #fbfaf7;
}
.reg-field.is-invalid,
.job-trigger.is-invalid {
border-color: color-mix(in srgb, var(--color-danger) 55%, #e6e1db);
}
.reg-field__meta {
display: inline-flex;
align-items: center;
gap: 6px;
color: #5f5b56;
font-size: 0.8125rem;
font-weight: 500;
white-space: nowrap;
flex-shrink: 0;
}
.reg-field__meta .is-placeholder {
color: #8a847c;
font-weight: 400;
}
.reg-field__input {
flex: 1;
min-width: 0;
height: 100%;
border: none;
background: transparent;
color: #2a2622;
font: inherit;
font-size: 0.875rem;
text-align: end;
outline: none;
}
.reg-field__input::placeholder {
color: #b0aaa3;
}
.reg-field__phone {
display: flex;
flex: 1;
align-items: center;
min-width: 0;
gap: 6px;
}
.reg-field__locale {
display: inline-flex;
align-items: center;
gap: 4px;
color: #9a9590;
}
.iran-flag {
width: 22px;
height: 16px;
border-radius: 2px;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.06);
}
.reg-field__caret {
width: 10px;
height: 6px;
}
.reg-field__input--phone {
text-align: start;
letter-spacing: 0.04em;
}
.reg-field__error {
margin: 6px 8px 0;
color: var(--color-danger);
font-size: 0.75rem;
}
.job-wrap {
position: relative;
}
.job-trigger {
width: 100%;
justify-content: space-between;
font: inherit;
cursor: pointer;
text-align: start;
}
.job-trigger.is-open {
border-color: var(--color-primary);
}
.job-trigger.is-error {
border-color: var(--color-danger);
}
.job-trigger .is-placeholder {
color: var(--color-text-muted);
border-color: #1e4644;
}
.job-chevron {
@@ -241,7 +481,6 @@ async function submit() {
height: 12px;
color: #9aa3af;
flex-shrink: 0;
transition: transform 0.15s;
}
.job-trigger.is-open .job-chevron {
@@ -256,9 +495,9 @@ async function submit() {
margin: 0;
padding: 6px;
list-style: none;
background: var(--color-surface);
border: 1px solid #eeeef1;
border-radius: 14px;
background: #fbfaf7;
border: 1px solid #e6e1db;
border-radius: 16px;
box-shadow: 0 8px 24px rgba(16, 24, 40, 0.08);
}
@@ -269,26 +508,205 @@ async function submit() {
border: none;
border-radius: 10px;
background: transparent;
color: var(--color-text);
color: #2a2622;
font: inherit;
font-size: 0.9rem;
font-size: 0.875rem;
text-align: start;
cursor: pointer;
}
.job-option:hover {
background: #f4f6f5;
}
.job-option.is-active {
background: var(--color-primary-soft);
color: var(--color-primary);
background: #e8f0ee;
color: #1e4644;
font-weight: 600;
}
.job-error {
color: var(--color-danger);
.register-submit {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 52px;
margin-top: 14px;
padding: 0 44px;
border: none;
border-radius: 999px;
background: #1e4644;
color: #f7f4ef;
font: inherit;
font-size: 0.9375rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s ease;
}
.register-submit:not(:disabled):hover,
.register-submit:not(:disabled):focus-visible {
background: #173835;
}
.register-submit:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.register-submit__chevron {
position: absolute;
inset-inline-end: 16px;
display: grid;
place-items: center;
}
.register-submit__spinner {
position: absolute;
inset-inline-start: 16px;
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.28);
border-top-color: #fff;
border-radius: 50%;
animation: register-spin 0.6s linear infinite;
}
.register-terms {
position: relative;
display: flex;
align-items: flex-start;
gap: 10px;
margin-top: 14px;
color: #6f6b66;
font-size: 0.75rem;
line-height: 1.7;
cursor: pointer;
}
.register-terms strong {
font-weight: 700;
color: #2a2622;
}
.register-terms__native {
position: absolute;
opacity: 0;
pointer-events: none;
}
.register-terms__box {
width: 22px;
height: 22px;
flex-shrink: 0;
margin-top: 1px;
border: 1.5px solid #cfc9c2;
border-radius: 50%;
display: grid;
place-items: center;
background: #fff;
}
.register-terms__box.is-on {
border-color: #1e4644;
background: #1e4644;
}
.register-terms__box svg {
width: 12px;
height: 12px;
}
.register-perks {
position: relative;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
margin-bottom: 20rem;
padding: 16px 10px 14px;
border-radius: 22px;
background: #f7f3ee;
}
.register-perk {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
text-align: center;
color: #6f6b66;
}
.register-perk strong {
font-size: 0.6875rem;
font-weight: 500;
line-height: 1.55;
}
.register-foot {
position: relative;
margin-top: auto;
margin-inline: -20px;
min-height: 270px;
overflow: hidden;
padding: 20px 20px calc(20px + env(safe-area-inset-bottom, 0px));
}
.register-foot__bg {
position: absolute;
inset: 0;
z-index: 0;
width: 100%;
height: 100%;
object-fit: cover;
object-position: 88% 32%;
pointer-events: none;
user-select: none;
mask-image: linear-gradient(to bottom, transparent 0%, #000 38%);
-webkit-mask-image: linear-gradient(to bottom, transparent 0%, #000 38%);
}
.register-foot::before {
display: none;
}
.register-foot__copy {
position: absolute;
z-index: 3;
left: 24px;
top: 8.75rem;
margin: 0;
max-width: 10rem;
font-size: 0.75rem;
font-weight: 600;
line-height: 1.55;
text-align: right;
color: #5a5550;
pointer-events: none;
}
.register-foot__copy::after {
content: "";
display: block;
width: 1.375rem;
height: 1.5px;
margin-top: 8px;
margin-left: auto;
background: #c9a56a;
border-radius: 1px;
}
@keyframes register-spin {
to {
transform: rotate(360deg);
}
}
@media (min-width: 768px) {
.register-page {
padding: 0;
max-width: none;
margin: 0;
height: 100%;
min-height: 0;
overflow: hidden;
}
}
</style>