Polish user-app auth, nav, and product UI for Hedena Gold.

Gate profile/orders on login, autofocus OTP, stretch product sheet white, and refresh dialog illustrations to the brand palette.
This commit is contained in:
s.esmaeili
2026-10-15 18:14:14 +03:30
parent ea86636027
commit be2c2ddf21
16 changed files with 1026 additions and 292 deletions
+2 -2
View File
@@ -128,7 +128,7 @@
<DialogVant :timeOut="-1" v-model:show="loginNotify" :closeOnClickOverlay="true">
<template v-slot:img>
<img :src="loginDialogImage" alt="" class="opacity-80" />
<img :src="loginDialogImage" alt="" />
</template>
<template v-slot:title>
<div class="p-8 text-justify">برای ثبت سفارش ابتدا باید وارد حساب کاربری خود شوید.</div>
@@ -147,7 +147,7 @@ import { useRouter } from 'vue-router'
import { Drawer, Form, FormItem, Textarea } from 'ant-design-vue'
import { Loading, PullRefresh } from 'vant'
import dialogEmpty4 from '../assets/img/empty/dialogEmpty4.svg'
import loginDialogImage from '../assets/img/empty/dialogEmpty1.svg'
import loginDialogImage from '../assets/img/empty/dialogLogin.svg'
import Icon from '../components/icon/Icon.vue'
import PageBar from '../components/ui/PageBar.vue'
import Spinner from '../components/Spinner.vue'
+65 -13
View File
@@ -6,7 +6,7 @@
2. the live 18k rate strip (real: GET /pricing/latest)
3. an editorial showcase hero built from real categories
4. the category rail (real: derived from GET /catalog/products)
5. "برگزیده" — a wide feature card + a two-column grid
5. "برگزیده" — masonry of product cards
6. "تازه‌ها" — a horizontal rail
7. the account block (real: GET /customers/:id when signed in)
@@ -59,17 +59,33 @@
@select="onCategory"
/>
<section v-if="featured" class="home__section reveal" :style="revealDelay(3)">
<section v-if="featuredProducts.length" class="home__section reveal" :style="revealDelay(3)">
<SectionHead
:title="`برگزیده ${brand.name}`"
subtitle="انتخاب‌شده از میان مجموعه‌های موجود"
action-label="همه محصولات"
@action="goSearch"
/>
<ShowcaseCard :value="featured" wide badge="برگزیده" @open="openProduct" />
<div v-if="gridProducts.length" class="home__grid">
<ShowcaseCard v-for="item in gridProducts" :key="item.id" :value="item" @open="openProduct" />
<div class="home__masonry">
<div class="home__masonryCol">
<ShowcaseCard
v-for="(cell, i) in masonryCols.start"
:key="cell.item.id"
:value="cell.item"
:badge="i === 0 ? 'برگزیده' : undefined"
:size="cell.size"
@open="openProduct"
/>
</div>
<div class="home__masonryCol">
<ShowcaseCard
v-for="cell in masonryCols.end"
:key="cell.item.id"
:value="cell.item"
:size="cell.size"
@open="openProduct"
/>
</div>
</div>
</section>
@@ -160,9 +176,37 @@ const adminSlides = ref<AdminSlide[] | null>(reads.peek<AdminSlide[]>(KEYS.homeS
// and later returns to this tab do not replay it.
const { revealed, revealDelay, reveal } = useReveal('home')
const featured = computed<ProductSummary | null>(() => products.value[0] ?? null)
const gridProducts = computed(() => products.value.slice(1, 5))
const newestProducts = computed(() => products.value.slice(5, 13))
/**
* Featured masonry — two real columns with deliberately *different* height
* rhythms (not a mirrored tall/short alternation), so the block reads
* asymmetric. RTL puts `start` on the right.
*/
const featuredProducts = computed(() => products.value.slice(0, 9))
const newestProducts = computed(() => products.value.slice(9, 17))
type MasonrySize = 'hero' | 'tall' | 'mid' | 'short' | 'squat'
/** Start column (visual right in RTL): opens tall, then irregular. */
const START_SIZES: MasonrySize[] = ['hero', 'short', 'tall', 'mid', 'squat']
/** End column: different sequence + often one fewer/taller cards. */
const END_SIZES: MasonrySize[] = ['mid', 'hero', 'short', 'tall']
const masonryCols = computed(() => {
const items = featuredProducts.value
const start: { item: ProductSummary; size: MasonrySize }[] = []
const end: { item: ProductSummary; size: MasonrySize }[] = []
items.forEach((item, i) => {
// Uneven split: 5 on start / 4 on end when we have 9 — keeps columns unequal.
if (i % 2 === 0 && start.length < START_SIZES.length) {
start.push({ item, size: START_SIZES[start.length] })
} else if (end.length < END_SIZES.length) {
end.push({ item, size: END_SIZES[end.length] })
} else {
start.push({ item, size: START_SIZES[start.length % START_SIZES.length] })
}
})
return { start, end }
})
// The hero shows ONLY what the admin uploaded in the slider section
// (manage/Const.vue "اسلایدر موبایل"). It used to fall back to slides built
@@ -337,11 +381,19 @@ onActivated(async () => {
margin-top: 28px;
}
.home__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
/* Asymmetric masonry: two independent columns, unequal size rhythms. */
.home__masonry {
display: flex;
align-items: flex-start;
gap: 11px;
}
.home__masonryCol {
flex: 1 1 0;
min-width: 0;
display: flex;
flex-direction: column;
gap: 11px;
margin-top: 11px;
}
.home__reel {
+80 -34
View File
@@ -98,20 +98,22 @@
<!-- Nav grid -->
<section class="dash__section reveal" :style="revealDelay(1)">
<SectionHead title="دسترسی‌ها" subtitle="بخش‌های حساب شما" />
<div class="dash__grid">
<div class="dash__access">
<button
v-for="item in list"
:key="item.id"
type="button"
class="dash__tile"
:class="{ 'is-disabled': item.disabled }"
:class="[`is-${item.tone}`, { 'is-disabled': item.disabled }]"
@click="goToDetails(item.id)"
>
<span class="dash__tileIcon" aria-hidden="true">
<Icon :icon="item.icon" size="20" />
<Icon :icon="item.icon" size="21" />
</span>
<span class="dash__tileTitle">{{ item.title }}</span>
<span class="dash__tileArrow" aria-hidden="true">
<Icon icon="vuesax-linear:arrow-left-2" size="14" />
</span>
</button>
</div>
</section>
@@ -126,7 +128,7 @@
<DialogVant :timeOut="-1" v-model:show="data.notify" :closeOnClickOverlay="true">
<template v-slot:img>
<img :src="exitImage" alt="" class="opacity-70 mb-8" />
<img :src="exitImage" alt="" />
</template>
<template v-slot:title>آیا می خواهید از برنامه خارج شوید؟</template>
<template v-slot:button>
@@ -137,7 +139,7 @@
<DialogVant :timeOut="-1" v-model:show="data.loginNotify" :closeOnClickOverlay="true">
<template v-slot:img>
<img :src="loginDialogImage" alt="" class="opacity-80" />
<img :src="loginDialogImage" alt="" />
</template>
<template v-slot:title>
<div class="p-8 text-justify">
@@ -162,8 +164,8 @@ import Icon from '../../components/icon/Icon.vue'
import SectionHead from '../../components/home/SectionHead.vue'
import RateStrip from '../../components/home/RateStrip.vue'
import DialogVant from '../../components/DialogVant.vue'
import exitImage from '../../assets/img/empty/dialogEmpty5.svg'
import loginDialogImage from '../../assets/img/empty/dialogEmpty1.svg'
import exitImage from '../../assets/img/empty/dialogLogout.svg'
import loginDialogImage from '../../assets/img/empty/dialogLogin.svg'
import { price } from '../../utils/filters'
import { useSessionStore } from '../../stores/session'
import { useReveal } from '../../composables/useReveal'
@@ -191,14 +193,14 @@ const grams = computed(() =>
const data = reactive({ notify: false, loginNotify: false })
const BASE_TILES = [
{ id: 0, icon: 'vuesax-linear:box', title: 'سفارشات', disabled: false },
{ id: 1, icon: 'vuesax-linear:receipt-text', title: 'اسناد', disabled: false },
{ id: 2, icon: 'vuesax-linear:user', title: 'پروفایل', disabled: false },
{ id: 3, icon: 'vuesax-linear:heart', title: 'علاقمندی', disabled: false },
{ id: 0, icon: 'vuesax-linear:box', title: 'سفارشات', tone: 'forest', disabled: false },
{ id: 1, icon: 'vuesax-linear:receipt-text', title: 'اسناد', tone: 'sand', disabled: false },
{ id: 2, icon: 'vuesax-linear:user', title: 'پروفایل', tone: 'wine', disabled: false },
{ id: 3, icon: 'vuesax-linear:heart', title: 'علاقمندی', tone: 'rose', disabled: false },
// D24 — the account statement. The reference put it on the dashboard too,
// gated on hasMCode() (Dashboard.vue:179, :449). The target has no mcode, so
// every signed-in customer sees it and the server scopes it to their token.
{ id: 6, icon: 'vuesax-linear:chart-2', title: 'گزارش', disabled: false },
{ id: 6, icon: 'vuesax-linear:chart-2', title: 'گزارش', tone: 'slate', disabled: false },
]
/**
@@ -208,13 +210,13 @@ const BASE_TILES = [
* server refuses both routes for a non-expert token regardless.
*/
const EXPERT_TILES = [
{ id: 4, icon: 'vuesax-linear:box-tick', title: 'سفارش مشتریان', disabled: false },
{ id: 5, icon: 'vuesax-linear:profile-2user', title: 'مشتریان من', disabled: false },
{ id: 4, icon: 'vuesax-linear:box-tick', title: 'سفارش مشتریان', tone: 'forest', disabled: false },
{ id: 5, icon: 'vuesax-linear:profile-2user', title: 'مشتریان من', tone: 'sand', disabled: false },
]
// Appearance (theme + reduced motion) — always last, and open to everyone:
// device preferences, not account data. See goToDetails.
const APPEARANCE_TILE = { id: 7, icon: 'vuesax-linear:brush', title: 'ظاهر', disabled: false }
const APPEARANCE_TILE = { id: 7, icon: 'vuesax-linear:brush', title: 'ظاهر', tone: 'ink', disabled: false }
const list = computed(() => [
...BASE_TILES,
@@ -522,47 +524,91 @@ onMounted(async () => {
margin-top: 26px;
}
.dash__grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 9px;
/* One island holding account shortcuts — denser, clearer than a flat 4-col grid. */
.dash__access {
display: flex;
flex-direction: column;
gap: 1px;
padding: 6px;
border-radius: 22px;
background: @canvas;
box-shadow:
0 1px 0 rgba(15, 23, 42, 0.04),
0 10px 28px rgba(15, 23, 42, 0.06);
border: 1px solid @hairline;
}
.dash__tile {
display: flex;
flex-direction: column;
display: grid;
grid-template-columns: 44px 1fr 18px;
align-items: center;
gap: 8px;
padding: 14px 6px;
border: 1px solid @hairline;
gap: 12px;
width: 100%;
margin: 0;
padding: 11px 12px;
border: 0;
border-radius: 16px;
cursor: pointer;
background: @canvas;
background: transparent;
font-family: inherit;
transition: transform 0.18s ease;
text-align: start;
transition: background 0.15s ease, transform 0.15s ease;
}
.dash__tile:active {
transform: scale(0.97);
transform: scale(0.99);
background: @canvasSoft;
}
.dash__tile.is-disabled {
opacity: 0.4;
pointer-events: none;
}
.dash__tileIcon {
width: 38px;
height: 38px;
border-radius: 12px;
width: 44px;
height: 44px;
border-radius: 14px;
display: grid;
place-items: center;
color: @inkSoft;
}
.dash__tile.is-forest .dash__tileIcon {
color: @primary;
background: alpha(@primary, 10%);
}
.dash__tile.is-sand .dash__tileIcon {
color: @secondary;
background: alpha(@secondary, 12%);
}
.dash__tile.is-wine .dash__tileIcon {
color: @wine;
background: @wineWash;
}
.dash__tile.is-rose .dash__tileIcon {
color: @wineSoft;
background: alpha(@wineSoft, 12%);
}
.dash__tile.is-slate .dash__tileIcon {
color: @inkMuted;
background: @hairlineSoft;
}
.dash__tile.is-ink .dash__tileIcon {
color: @inkSoft;
background: alpha(@ink, 6%);
}
.dash__tileTitle {
font-size: 11px;
font-size: 13.5px;
font-weight: 700;
color: @inkSoft;
color: @ink;
letter-spacing: -0.01em;
}
.dash__tileArrow {
display: grid;
place-items: center;
color: @inkFaint;
opacity: 0.7;
}
</style>
+227 -52
View File
@@ -22,19 +22,34 @@
<PageBar title="ورود" back-to="home" />
<container class="auth__body">
<section class="auth__intro">
<div class="auth__mark" aria-hidden="true">
<Icon icon="vuesax-linear:security-user" size="28" />
</div>
<section class="auth__hero">
<img src="/img/brand-logo.png" :alt="brand.name" class="auth__logo" />
<h2 class="auth__title">ورود به {{ brand.name }}</h2>
<p class="auth__sub">
{{ body.step === 1 ? 'شماره موبایل خود را وارد کنید' : 'رمز یکبارمصرف را وارد کنید' }}
{{ body.step === 1 ? 'با شماره موبایل وارد شوید؛ کد یکبارمصرف پیامک می‌شود.' : 'کد ۴ رقمی پیامک‌شده را وارد کنید.' }}
</p>
<ol class="auth__steps" aria-label="مراحل ورود">
<li class="auth__step" :class="{ 'is-on': body.step === 1, 'is-done': body.step > 1 }">
<span class="auth__stepNum">۱</span>
<span>شماره</span>
</li>
<li class="auth__stepLine" aria-hidden="true" />
<li class="auth__step" :class="{ 'is-on': body.step === 2 }">
<span class="auth__stepNum">۲</span>
<span>کد تأیید</span>
</li>
</ol>
</section>
<section class="auth__card">
<section class="auth__island">
<!-- Step 2: OTP -->
<template v-if="body.step === 2">
<div class="auth__phoneChip">
<span>{{ formModel.username }}</span>
<button type="button" class="auth__editPhone" @click="backToPhone">ویرایش</button>
</div>
<OtpInput
class="auth__otp"
input-classes="otp-input"
@@ -46,19 +61,27 @@
/>
<p v-if="timer > 0" class="auth__timer">
امکان دریافت مجدد رمز در {{ counterZeroShow(timer) }} ثانیه دیگر
ارسال مجدد تا <strong>{{ counterZeroShow(timer) }}</strong> ثانیه
</p>
<p v-else class="auth__timer">برای دریافت رمز عبور مجدد تلاش کنید</p>
<p v-else class="auth__timer is-ready">می‌توانید کد را دوباره دریافت کنید</p>
</template>
<Form :model="formModel" layout="vertical" @finish="sendPhone" class="auth__form">
<FormItem
v-show="body.step === 1"
name="username"
label="تلفن همراه"
:rules="[required('تلفن همراه'), mobileRule('تلفن همراه')]"
label="شماره موبایل"
:rules="[required('شماره موبایل'), mobileRule('شماره موبایل')]"
>
<Input type="number" v-model:value="formModel.username" />
<Input
type="tel"
inputmode="numeric"
dir="ltr"
placeholder="09123456789"
autocomplete="tel"
v-model:value="formModel.username"
class="auth__phoneInput"
/>
</FormItem>
<button
@@ -67,14 +90,15 @@
:class="{ 'is-loading': body.loading }"
:disabled="body.loading || (timer > 0 && body.step === 2)"
>
<span v-if="body.loading" class="auth__ctaSpin" aria-hidden="true" />
{{ btnText }}
</button>
</Form>
</section>
<p class="auth__switch">
اگر ثبت نام نکرده‌اید
<button type="button" @click="emit('register', true)">ثبت نام کنید</button>
حساب ندارید؟
<button type="button" @click="emit('register', true)">ثبت‌نام فروشگاه</button>
</p>
</container>
</div>
@@ -85,7 +109,6 @@ import { brand } from '../../brand'
import { computed, nextTick, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Form, FormItem, Input } from 'ant-design-vue'
import Icon from '../../components/icon/Icon.vue'
import PageBar from '../../components/ui/PageBar.vue'
import { toast } from '../../composables/useToast'
import OtpInput from '../../components/input/OtpInput.vue'
@@ -117,6 +140,12 @@ const countDownTimer = () => {
}
}
function backToPhone() {
body.step = 1
body.defaultFocus = false
timer.value = 0
}
/**
* Step 1 — ask the server to send an SMS code.
*
@@ -215,56 +244,145 @@ function extractServerMessage(e: Error): string | null {
const counterZeroShow = (value: number) => (value < 10 ? '0' + value : value)
const btnText = computed(() => (body.step === 1 ? 'دریافت رمز عبور' : 'دریافت مجدد رمز عبور'))
const btnText = computed(() => (body.step === 1 ? 'دریافت کد ورود' : 'ارسال مجدد کد'))
</script>
<style scoped lang="less">
.auth {
background: @page;
min-height: 100vh;
background:
radial-gradient(120% 60% at 50% -10%, alpha(@primary, 8%), transparent 55%),
@page;
}
.auth__body {
padding-top: 86px;
padding-bottom: 90px;
padding-bottom: 48px;
}
.auth__intro {
.auth__hero {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding-bottom: 24px;
padding: 4px 8px 22px;
}
.auth__mark {
width: 64px;
height: 64px;
border-radius: 50%;
display: grid;
place-items: center;
color: @wine;
background: @wineWash;
.auth__logo {
width: 72px;
height: 72px;
object-fit: contain;
border-radius: 20px;
background: @canvas;
box-shadow:
0 1px 0 rgba(15, 23, 42, 0.04),
0 12px 28px rgba(15, 23, 42, 0.08);
padding: 10px;
}
.auth__title {
margin: 14px 0 0;
font-size: 16px;
margin: 16px 0 0;
font-size: 18px;
font-weight: 800;
color: @ink;
letter-spacing: -0.02em;
}
.auth__sub {
margin: 6px 0 0;
margin: 8px 0 0;
max-width: 28rem;
font-size: 12.5px;
line-height: 1.7;
color: @inkFaint;
}
.auth__card {
padding: 18px 16px;
.auth__steps {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
margin: 18px 0 0;
padding: 0;
list-style: none;
}
.auth__step {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11.5px;
font-weight: 700;
color: @inkFaint;
}
.auth__step.is-on,
.auth__step.is-done {
color: @ink;
}
.auth__stepNum {
width: 22px;
height: 22px;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 11px;
background: @hairlineSoft;
color: inherit;
}
.auth__step.is-on .auth__stepNum {
background: @primary;
color: #fff;
}
.auth__step.is-done .auth__stepNum {
background: alpha(@primary, 14%);
color: @primary;
}
.auth__stepLine {
width: 28px;
height: 1px;
background: @hairline;
list-style: none;
}
.auth__island {
padding: 20px 16px 18px;
border-radius: 24px;
background: @canvas;
border: 1px solid @hairline;
border-radius: 20px;
box-shadow:
0 1px 0 rgba(15, 23, 42, 0.04),
0 14px 36px rgba(15, 23, 42, 0.07);
}
.auth__phoneChip {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 16px;
padding: 10px 12px;
border-radius: 14px;
background: @canvasSoft;
font-size: 13px;
font-weight: 700;
color: @ink;
direction: ltr;
}
.auth__editPhone {
border: 0;
background: none;
cursor: pointer;
padding: 0;
font-family: inherit;
font-size: 12px;
font-weight: 700;
color: @wine;
direction: rtl;
}
.auth__otp {
@@ -272,29 +390,69 @@ const btnText = computed(() => (body.step === 1 ? 'دریافت رمز عبور'
justify-content: center;
gap: 10px;
direction: ltr;
padding-bottom: 6px;
padding-bottom: 4px;
}
.auth__timer {
margin: 12px 0 16px;
margin: 14px 0 16px;
text-align: center;
font-size: 11.5px;
font-size: 12px;
color: @inkMuted;
strong {
font-variant-numeric: tabular-nums;
color: @ink;
}
&.is-ready {
color: @primary;
font-weight: 700;
}
}
.auth__form :deep(.ant-form-item) {
margin-bottom: 16px;
}
.auth__form :deep(.ant-form-item-label > label) {
font-size: 12px;
color: @inkMuted;
font-size: 12.5px;
font-weight: 700;
color: @inkSoft;
}
.auth__form :deep(.ant-input) {
height: 48px;
border-radius: 14px;
border-color: @hairline;
font-size: 15px;
font-weight: 600;
background: @canvasSoft;
}
.auth__form :deep(.ant-input:focus),
.auth__form :deep(.ant-input-focused) {
border-color: @primary;
box-shadow: 0 0 0 3px alpha(@primary, 12%);
}
.auth__phoneInput {
letter-spacing: 0.04em;
}
.auth__cta {
position: relative;
width: 100%;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 50px;
padding: 13px 18px;
border: 0;
cursor: pointer;
border-radius: 14px;
border-radius: 16px;
font-family: inherit;
font-size: 13px;
font-size: 14px;
font-weight: 700;
color: #fff;
background: @primary;
@@ -310,10 +468,25 @@ const btnText = computed(() => (body.step === 1 ? 'دریافت رمز عبور'
cursor: default;
}
.auth__ctaSpin {
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.35);
border-top-color: #fff;
border-radius: 50%;
animation: auth-spin 0.7s linear infinite;
}
@keyframes auth-spin {
to {
transform: rotate(360deg);
}
}
.auth__switch {
margin: 20px 0 0;
margin: 22px 0 0;
text-align: center;
font-size: 12px;
font-size: 12.5px;
color: @inkMuted;
}
@@ -321,10 +494,10 @@ const btnText = computed(() => (body.step === 1 ? 'دریافت رمز عبور'
border: 0;
background: none;
cursor: pointer;
padding: 0 3px;
padding: 0 4px;
font-family: inherit;
font-size: 12px;
font-weight: 700;
font-size: 12.5px;
font-weight: 800;
color: @wine;
}
</style>
@@ -333,23 +506,25 @@ const btnText = computed(() => (body.step === 1 ? 'دریافت رمز عبور'
/* OtpInput renders its inputs with this class from a prop, so the rule cannot
be scoped to this component. */
.otp-input {
width: 48px;
height: 52px;
width: 52px;
height: 56px;
padding: 5px;
margin: 0;
font-size: 19px;
font-weight: 700;
font-size: 20px;
font-weight: 800;
color: @ink;
background: @canvas;
background: @canvasSoft;
border: 1px solid @hairline;
border-radius: 13px;
border-radius: 16px;
text-align: center;
transition: border-color 0.18s ease;
transition: border-color 0.18s ease, box-shadow 0.18s ease;
}
.otp-input:focus {
outline: none;
border-color: @primary;
box-shadow: 0 0 0 3px alpha(@primary, 12%);
background: @canvas;
}
.otp-input.error {
+184 -59
View File
@@ -18,53 +18,91 @@
-->
<template>
<div class="auth">
<PageBar title="ثبت نام" back-to="home" />
<PageBar title="ثبت‌نام" back-to="home" />
<Spinner :spinning="profileData.loading" tip="در حال ثبت اطلاعات">
<container class="auth__body">
<section class="auth__intro">
<div class="auth__mark" aria-hidden="true">
<Icon icon="vuesax-linear:user-add" size="28" />
</div>
<h2 class="auth__title">ساخت حساب کاربری</h2>
<p class="auth__sub">اطلاعات فروشگاه خود را وارد کنید</p>
<section class="auth__hero">
<img src="/img/brand-logo.png" :alt="brand.name" class="auth__logo" />
<h2 class="auth__title">ساخت حساب فروشگاه</h2>
<p class="auth__sub">
پس از ثبت، کارشناسان {{ brand.name }} اطلاعات را بررسی می‌کنند و سپس می‌توانید وارد شوید.
</p>
</section>
<section class="auth__card">
<Form :model="formModel" layout="vertical" @finish="register" class="auth__form">
<Form :model="formModel" layout="vertical" @finish="register" class="auth__form">
<section class="auth__island">
<header class="auth__groupHead">
<h3>اطلاعات شخصی</h3>
<p>نام و شمارهٔ تماس مالک فروشگاه</p>
</header>
<FormItem name="fname" label="نام" :rules="required('نام')">
<Input v-model:value="formModel.fname" />
<Input v-model:value="formModel.fname" autocomplete="given-name" placeholder="مثلاً علی" />
</FormItem>
<FormItem name="lname" label="نام خانوادگی" :rules="required('نام خانوادگی')">
<Input v-model:value="formModel.lname" />
<Input v-model:value="formModel.lname" autocomplete="family-name" placeholder="مثلاً محمدی" />
</FormItem>
<FormItem
name="mobile"
label="شماره موبایل"
:rules="[mobileRule('شماره موبایل'), required('شماره موبایل')]"
class="auth__itemLast"
>
<Input v-model:value="formModel.mobile" type="number" />
<Input
v-model:value="formModel.mobile"
type="tel"
inputmode="numeric"
dir="ltr"
placeholder="09123456789"
autocomplete="tel"
class="auth__ltr"
/>
</FormItem>
</section>
<section class="auth__island">
<header class="auth__groupHead">
<h3>اطلاعات فروشگاه</h3>
<p>برای تأیید و ارسال سفارش‌ها لازم است</p>
</header>
<FormItem name="store_name" label="نام فروشگاه" :rules="required('نام فروشگاه')">
<Input v-model:value="formModel.store_name" />
</FormItem>
<FormItem name="phone" label="تلفن فروشگاه" :rules="[required('تلفن فروشگاه')]">
<Input v-model:value="formModel.phone" type="number" />
</FormItem>
<FormItem name="address" label="آدرس پستی" :rules="required('آدرس')">
<Textarea v-model:value="formModel.address" :rows="3" />
</FormItem>
<FormItem name="city" label="شهر" :rules="required('شهر')">
<Input v-model:value="formModel.city" />
<Input v-model:value="formModel.store_name" placeholder="نام تابلو یا واحد صنفی" />
</FormItem>
<button type="submit" class="auth__cta">ثبت اطلاعات</button>
</Form>
</section>
<FormItem name="phone" label="تلفن فروشگاه" :rules="[required('تلفن فروشگاه')]">
<Input
v-model:value="formModel.phone"
type="tel"
inputmode="numeric"
dir="ltr"
placeholder="021xxxxxxx"
class="auth__ltr"
/>
</FormItem>
<FormItem name="city" label="شهر" :rules="required('شهر')">
<Input v-model:value="formModel.city" placeholder="مثلاً تهران" />
</FormItem>
<FormItem name="address" label="آدرس پستی" :rules="required('آدرس')" class="auth__itemLast">
<Textarea
v-model:value="formModel.address"
:rows="3"
placeholder="آدرس کامل برای هماهنگی ارسال"
/>
</FormItem>
</section>
<button type="submit" class="auth__cta" :disabled="profileData.loading">
ثبت درخواست عضویت
</button>
</Form>
<p class="auth__switch">
اگر حساب کاربری دارید
<button type="button" @click="emit('register', false)">وارد شوید</button>
قبلاً ثبت‌نام کرده‌اید؟
<button type="button" @click="emit('register', false)">ورود به حساب</button>
</p>
</container>
</Spinner>
@@ -74,8 +112,8 @@
<img :src="registerDelayImage" alt="" class="opacity-80" />
</template>
<template v-slot:title>
<div class="p-5">
ثبت نام شما با موفقیت انجام شد. منتظر تایید اطلاعات ثبت نامی شما توسط کارشناسان بمانید.
<div>
ثبت‌نام شما ثبت شد. پس از تأیید کارشناسان می‌توانید با همین شماره وارد شوید.
</div>
</template>
<template v-slot:button>
@@ -86,11 +124,11 @@
</template>
<script setup lang="ts">
import { brand } from '../../brand'
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Form, FormItem, Input, Textarea } from 'ant-design-vue'
import registerDelayImage from '../../assets/img/registerDelay.svg'
import Icon from '../../components/icon/Icon.vue'
import PageBar from '../../components/ui/PageBar.vue'
import Spinner from '../../components/Spinner.vue'
import DialogVant from '../../components/DialogVant.vue'
@@ -149,81 +187,168 @@ function serverMessage(e: unknown): string | null {
<style scoped lang="less">
.auth {
background: @page;
min-height: 100vh;
background:
radial-gradient(120% 60% at 50% -10%, alpha(@primary, 8%), transparent 55%),
@page;
}
.auth__body {
padding-top: 86px;
padding-bottom: 90px;
padding-bottom: 48px;
}
.auth__intro {
.auth__hero {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding-bottom: 24px;
padding: 4px 8px 20px;
}
.auth__mark {
width: 64px;
height: 64px;
border-radius: 50%;
display: grid;
place-items: center;
color: @wine;
background: @wineWash;
.auth__logo {
width: 72px;
height: 72px;
object-fit: contain;
border-radius: 20px;
background: @canvas;
box-shadow:
0 1px 0 rgba(15, 23, 42, 0.04),
0 12px 28px rgba(15, 23, 42, 0.08);
padding: 10px;
}
.auth__title {
margin: 14px 0 0;
font-size: 16px;
margin: 16px 0 0;
font-size: 18px;
font-weight: 800;
color: @ink;
letter-spacing: -0.02em;
}
.auth__sub {
margin: 6px 0 0;
margin: 8px 0 0;
max-width: 28rem;
font-size: 12.5px;
line-height: 1.7;
color: @inkFaint;
}
.auth__card {
padding: 18px 16px 20px;
.auth__island {
margin-bottom: 12px;
padding: 16px 16px 14px;
border-radius: 24px;
background: @canvas;
border: 1px solid @hairline;
border-radius: 20px;
background: @canvasSoft;
box-shadow:
0 1px 0 rgba(15, 23, 42, 0.04),
0 14px 36px rgba(15, 23, 42, 0.07);
overflow: hidden;
}
.auth__groupHead {
margin-bottom: 12px;
h3 {
margin: 0;
font-size: 14px;
font-weight: 800;
color: @ink;
}
p {
margin: 4px 0 0;
font-size: 11.5px;
color: @inkFaint;
}
}
.auth__form :deep(.ant-form-item) {
margin-bottom: 12px;
width: 100%;
max-width: 100%;
}
.auth__form :deep(.auth__itemLast.ant-form-item),
.auth__form :deep(.ant-form-item.auth__itemLast) {
margin-bottom: 0;
}
.auth__form :deep(.ant-form-item-control),
.auth__form :deep(.ant-form-item-control-input),
.auth__form :deep(.ant-form-item-control-input-content) {
min-width: 0;
max-width: 100%;
}
.auth__form :deep(.ant-form-item-label > label) {
font-size: 12px;
color: @inkMuted;
font-size: 12.5px;
font-weight: 700;
color: @inkSoft;
}
.auth__form :deep(.ant-input),
.auth__form :deep(textarea.ant-input) {
width: 100%;
max-width: 100%;
box-sizing: border-box;
border-radius: 14px;
border-color: @hairline;
font-size: 14px;
font-weight: 600;
background: @canvasSoft;
}
.auth__form :deep(.ant-input) {
height: 46px;
}
.auth__form :deep(textarea.ant-input) {
min-height: 92px;
padding-top: 12px;
line-height: 1.65;
}
.auth__form :deep(.ant-input:focus),
.auth__form :deep(.ant-input-focused),
.auth__form :deep(textarea.ant-input:focus) {
border-color: @primary;
box-shadow: 0 0 0 3px alpha(@primary, 12%);
}
.auth__ltr {
letter-spacing: 0.03em;
}
.auth__cta {
width: 100%;
margin-top: 4px;
margin-top: 6px;
min-height: 50px;
padding: 13px 18px;
border: 0;
cursor: pointer;
border-radius: 14px;
border-radius: 16px;
font-family: inherit;
font-size: 13px;
font-size: 14px;
font-weight: 700;
color: #fff;
background: @primary;
transition: transform 0.15s ease;
transition: transform 0.15s ease, opacity 0.15s ease;
}
.auth__cta:active {
transform: scale(0.99);
}
.auth__cta:disabled {
opacity: 0.5;
cursor: default;
}
.auth__switch {
margin: 20px 0 0;
text-align: center;
font-size: 12px;
font-size: 12.5px;
color: @inkMuted;
}
@@ -231,10 +356,10 @@ function serverMessage(e: unknown): string | null {
border: 0;
background: none;
cursor: pointer;
padding: 0 3px;
padding: 0 4px;
font-family: inherit;
font-size: 12px;
font-weight: 700;
font-size: 12.5px;
font-weight: 800;
color: @wine;
}
</style>