Files
tehran_taj/apps/user-app-v2/src/views/user/RegisterView.vue
T
s.esmaeili f441ca8289 feat(user-apps): appearance settings (dark mode + reduced motion), product morph, filter sheet
Applies to both user-app and user-app-v2. Logic and structure are shared;
each app keeps its own look.

Appearance settings
- New "تنظیمات ظاهری" page (/settings/appearance), opened from the
  dashboard and available without login. It offers a light/dark/system
  theme and a "fast & light" motion switch.
- appearanceStore saves the choice to localStorage. A small inline boot
  script in index.html applies it before first paint so there is no
  white flash. The "system" option follows OS changes live.
- New ToggleSwitch component.

Dark mode
- Theme LESS tokens now compile to CSS custom properties. Light/dark
  values come from a generated virtual:palette.css (themeConfig/
  appearance.ts, with colors.json + new colors.dark.json).
- New LESS alpha() plugin replaces fade() on theme tokens.
- New semantic tokens: page, onInk, heroSurface, hairlineStrong,
  photoMat, photoStage.
- The dark palette is layered (page < card < panel < border) so
  surfaces separate without shadows.
- Hard-coded colors were replaced by tokens according to their role.
- antd inputs/text get dark overrides; vant uses its own
  .van-theme-dark.
- Light mode looks the same as before.

Reduced motion
- With html.motion-reduced, transitions are capped at 120ms, keyframe
  loops stop, and backdrop blurs and permanent will-change are dropped.
- Spinners and the toast progress bar (which closes the toast) are
  exempt via .keep-motion.
- Carousel autoplay, the v1 Lottie and the product morph honor the
  setting.

Product detail
- Opening a product is now a container-transform morph from the tapped
  card (ProductMorph). It replaces the bottom Drawer and uses composited
  transform/opacity only.
- The detail view is seeded with the card's data, so it no longer
  opens blank and jumps.

Filters and sheets
- Filters are now a full-width bottom sheet. Selections stay a draft
  until applied, so the result list no longer re-renders while
  dragging the slider.
- DropDown collapse animates grid-template-rows instead of a capped
  max-height.
- v2 IslandSheet is restyled to the v2 design language (API unchanged).

Fixes
- filtersStore.reset() shallow-copied shared arrays, so clearing the
  filters never cleared the selected brands.
2026-10-04 16:18:56 +03:30

739 lines
19 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!--
Store sign-up, redesigned as a two-step form:
1. اطلاعات شما — first/last name, mobile
2. اطلاعات فروشگاه — store name, store phone, city, address
followed by an in-page "request received" state.
Why two steps: seven fields in one column read as a wall on a phone. Split
by who they describe, each step fits one screen, the progress bar sets the
expectation, and the CTA sits in a sticky footer so it is always in reach.
Ant's Form only validates the FormItems currently mounted, so a submit on
step 1 validates just step 1's fields; `onFinish` then advances instead of
registering. Enter on the keyboard therefore behaves like the CTA.
Numeric fields are `type="tel"` + `inputmode="numeric"` (a real number input
drops the leading 0 and shows spinners), and Persian/Arabic digits are
converted to Latin as the user types, so the validators accept them.
STUBBED, unchanged from before: legacy's POST /major/register has no
counterpart in apps/api yet. Submitting shows the "awaiting review" state
without persisting anything — the copy says the request is under review,
which is what legacy said too, so it does not claim an account exists.
-->
<template>
<div class="reg">
<PageBar title="ثبت نام" :back-action="goBack" />
<!-- Success -->
<Transition name="reg-fade" mode="out-in">
<container v-if="done" key="done" class="reg__body reg__done">
<div class="reg__doneMark" aria-hidden="true">
<span class="reg__doneRing" />
<Icon icon="vuesax-linear:tick-circle" size="40" />
</div>
<h2 class="reg__doneTitle">درخواست شما ثبت شد</h2>
<p class="reg__doneText">
اطلاعات فروشگاه {{ formModel.store_name }} برای کارشناسان ما ارسال شد. پس از بررسی و تایید، می‌توانید با
شماره <span class="reg__ltr">{{ formModel.mobile }}</span> وارد شوید.
</p>
<div class="reg__doneActions">
<button type="button" class="reg__cta" @click="router.push({ name: 'home' })">بازگشت به صفحه اصلی</button>
<button type="button" class="reg__ghost" @click="emit('register', false)">رفتن به صفحه ورود</button>
</div>
</container>
<!-- Form -->
<container v-else key="form" class="reg__body">
<header class="reg__head">
<div class="reg__steps" role="progressbar" :aria-valuenow="step" aria-valuemin="1" aria-valuemax="2">
<span class="reg__seg is-on" />
<span class="reg__seg" :class="{ 'is-on': step === 2 }" />
</div>
<p class="reg__stepLabel">مرحله {{ step === 1 ? '۱' : '۲' }} از ۲</p>
<h2 class="reg__title">{{ step === 1 ? 'اطلاعات شما' : 'اطلاعات فروشگاه' }}</h2>
<p class="reg__sub">
{{
step === 1
? 'برای ساخت حساب فروشگاه، ابتدا خودتان را معرفی کنید.'
: 'این اطلاعات برای ارسال سفارش و تایید حساب استفاده می‌شود.'
}}
</p>
</header>
<Form ref="formRef" :model="formModel" layout="vertical" class="reg__form" @finish="onFinish">
<Transition :name="step === 2 ? 'reg-next' : 'reg-prev'" mode="out-in">
<!-- Step 1 -->
<div v-if="step === 1" key="s1" class="reg__fields">
<div class="reg__row">
<FormItem name="fname" label="نام" :rules="[need('نام خود')]">
<Input
v-model:value="formModel.fname"
placeholder="مثلاً علی"
autocomplete="given-name"
enterkeyhint="next"
/>
</FormItem>
<FormItem name="lname" label="نام خانوادگی" :rules="[need('نام خانوادگی')]">
<Input
v-model:value="formModel.lname"
placeholder="مثلاً محمدی"
autocomplete="family-name"
enterkeyhint="next"
/>
</FormItem>
</div>
<FormItem
name="mobile"
label="شماره موبایل"
:rules="[need('شماره موبایل'), { pattern: /^09\d{9}$/, message: 'شماره موبایل باید ۱۱ رقم و با ۰۹ شروع شود' }]"
extra="کد ورود به این شماره پیامک می‌شود."
>
<Input
:value="formModel.mobile"
class="reg__num"
type="tel"
inputmode="numeric"
:maxlength="11"
placeholder="09123456789"
autocomplete="tel"
enterkeyhint="next"
@update:value="(v: string) => (formModel.mobile = digits(v))"
>
<template #prefix><Icon icon="vuesax-linear:mobile" size="18" /></template>
</Input>
</FormItem>
</div>
<!-- Step 2 -->
<div v-else key="s2" class="reg__fields">
<FormItem name="store_name" label="نام فروشگاه" :rules="[need('نام فروشگاه')]">
<Input
v-model:value="formModel.store_name"
placeholder="مثلاً گالری طلای آفتاب"
autocomplete="organization"
enterkeyhint="next"
>
<template #prefix><Icon icon="vuesax-linear:shop" size="18" /></template>
</Input>
</FormItem>
<FormItem
name="phone"
label="تلفن فروشگاه"
:rules="[need('تلفن فروشگاه'), { pattern: /^(0\d{10}|\d{8})$/, message: 'تلفن را با کد شهر وارد کنید، مثلاً 02112345678' }]"
>
<Input
:value="formModel.phone"
class="reg__num"
type="tel"
inputmode="numeric"
:maxlength="11"
placeholder="02112345678"
autocomplete="tel-national"
enterkeyhint="next"
@update:value="(v: string) => (formModel.phone = digits(v))"
>
<template #prefix><Icon icon="vuesax-linear:call" size="18" /></template>
</Input>
</FormItem>
<FormItem name="city" label="شهر" :rules="[need('شهر')]">
<Input
v-model:value="formModel.city"
placeholder="مثلاً تهران"
autocomplete="address-level2"
enterkeyhint="next"
>
<template #prefix><Icon icon="vuesax-linear:buildings" size="18" /></template>
</Input>
</FormItem>
<FormItem name="address" label="آدرس پستی" :rules="[need('آدرس فروشگاه')]">
<Textarea
v-model:value="formModel.address"
:rows="3"
placeholder="خیابان، کوچه، پلاک، واحد"
autocomplete="street-address"
/>
</FormItem>
<div class="reg__note">
<Icon icon="vuesax-linear:shield-tick" size="18" />
<span>حساب شما پس از بررسی اطلاعات توسط کارشناسان فعال می‌شود.</span>
</div>
</div>
</Transition>
<!-- Sticky action bar; inside the Form so it submits it. -->
<footer class="reg__footer">
<div class="reg__footerInner">
<button type="submit" class="reg__cta" :class="{ 'is-loading': loading }" :disabled="loading">
<span v-if="loading" class="reg__spinner keep-motion" aria-hidden="true" />
<span>{{ step === 1 ? 'ادامه' : loading ? 'در حال ثبت…' : 'ثبت درخواست' }}</span>
<Icon v-if="step === 1" icon="vuesax-linear:arrow-left" size="18" />
</button>
<p v-if="step === 1" class="reg__switch">
حساب دارید؟
<button type="button" @click="emit('register', false)">وارد شوید</button>
</p>
</div>
</footer>
</Form>
</container>
</Transition>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Form, FormItem, Input, Textarea } from 'ant-design-vue'
import Icon from '../../components/icon/Icon.vue'
import PageBar from '../../components/ui/PageBar.vue'
const emit = defineEmits<{ (e: 'register', value: boolean): void }>()
const router = useRouter()
const formRef = ref()
const step = ref<1 | 2>(1)
const loading = ref(false)
const done = ref(false)
const formModel = reactive({
fname: undefined as string | undefined,
lname: undefined as string | undefined,
mobile: undefined as string | undefined,
store_name: undefined as string | undefined,
phone: undefined as string | undefined,
city: undefined as string | undefined,
address: undefined as string | undefined,
})
const need = (what: string) => ({ required: true, whitespace: true, message: `لطفاً ${what} را وارد کنید` })
// Persian (۰-۹) and Arabic (٠-٩) digits → Latin, everything else dropped.
function digits(v: string): string {
return (v ?? '')
.replace(/[۰-۹]/g, (d) => String(d.charCodeAt(0) - 0x06f0))
.replace(/[٠-٩]/g, (d) => String(d.charCodeAt(0) - 0x0660))
.replace(/\D/g, '')
}
function goBack() {
if (done.value) router.push({ name: 'home' })
else if (step.value === 2) step.value = 1
else emit('register', false)
}
async function onFinish() {
if (step.value === 1) {
step.value = 2
window.scrollTo({ top: 0, behavior: 'smooth' })
return
}
try {
loading.value = true
// Stubbed — no create-customer endpoint exists in apps/api yet.
await new Promise((r) => setTimeout(r, 600))
done.value = true
window.scrollTo({ top: 0 })
} finally {
loading.value = false
}
}
</script>
<style scoped lang="less">
.reg {
min-height: 100vh;
background: @page;
}
.reg__body {
padding-top: 78px;
// Room for the sticky footer.
padding-bottom: calc(150px + env(safe-area-inset-bottom, 0px));
}
/* ---------- Header + progress ---------- */
.reg__head {
padding: 6px 2px 22px;
}
.reg__steps {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
}
.reg__seg {
position: relative;
height: 4px;
border-radius: 4px;
background: @hairline;
overflow: hidden;
&::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
background: @wine;
transform: scaleX(0);
transform-origin: right center;
transition: transform 0.5s cubic-bezier(0.65, 0, 0.35, 1);
}
&.is-on::after {
transform: scaleX(1);
}
}
.reg__stepLabel {
margin: 14px 0 0;
font-size: 11.5px;
font-weight: 700;
color: @wine;
}
.reg__title {
margin: 4px 0 0;
font-size: 20px;
font-weight: 800;
color: @ink;
letter-spacing: -0.2px;
}
.reg__sub {
margin: 6px 0 0;
font-size: 12.5px;
line-height: 1.8;
color: @inkMuted;
}
/* ---------- Fields ---------- */
// minmax(0, 1fr): a plain 1fr column won't shrink below its content's
// min-width, which pushed the second name field off the screen.
.reg__row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 10px;
}
.reg__form {
// Force label-above-input. Ant's FormItem is a wrapping flex row, so in a
// narrow cell its label and control ended up side by side.
:deep(.ant-form-item) {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
min-width: 0;
margin-bottom: 16px;
}
:deep(.ant-form-item-label),
:deep(.ant-form-item-control) {
flex: none;
width: 100%;
max-width: 100%;
min-width: 0;
text-align: right;
}
:deep(.ant-form-item-label) {
padding: 0 0 6px;
line-height: 1.5;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
:deep(.ant-form-item-label > label) {
height: auto;
font-size: 12px;
font-weight: 700;
color: @inkSoft;
// Ant's required asterisk — every field here is required, so it's noise.
&::before {
display: none !important;
}
}
:deep(.ant-input),
:deep(.ant-input-affix-wrapper) {
border-radius: 14px;
border: 1.5px solid @hairline;
background: @canvasSoft;
font-family: inherit;
font-size: 14px;
color: @ink;
box-shadow: none;
transition: border-color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease;
}
:deep(.ant-input) {
min-height: 50px;
padding: 12px 14px;
}
:deep(.ant-input-affix-wrapper) {
min-height: 50px;
padding: 0 14px;
.ant-input {
min-height: 46px;
padding: 0;
border: 0;
background: transparent;
box-shadow: none;
}
}
:deep(textarea.ant-input) {
min-height: 96px;
line-height: 1.8;
resize: none;
}
:deep(.ant-input-prefix) {
margin-inline-end: 10px;
color: @inkFaint;
transition: color 0.2s ease;
}
:deep(.ant-input::placeholder) {
color: @inkFaint;
}
:deep(.ant-input:hover),
:deep(.ant-input-affix-wrapper:hover) {
border-color: @hairlineStrong;
}
:deep(.ant-input:focus),
:deep(.ant-input-focused),
:deep(.ant-input-affix-wrapper-focused) {
border-color: @wine;
background: @canvas;
box-shadow: 0 0 0 4px alpha(@wine, 10%);
.ant-input-prefix {
color: @wine;
}
}
:deep(.ant-input-affix-wrapper-focused .ant-input-prefix) {
color: @wine;
}
// Errors
:deep(.ant-form-item-has-error .ant-input),
:deep(.ant-form-item-has-error .ant-input-affix-wrapper) {
border-color: @red !important;
background: alpha(@red, 3%);
}
:deep(.ant-form-item-has-error .ant-input:focus),
:deep(.ant-form-item-has-error .ant-input-affix-wrapper-focused) {
box-shadow: 0 0 0 4px alpha(@red, 10%) !important;
}
:deep(.ant-form-item-has-error .ant-input-affix-wrapper .ant-input) {
background: transparent;
border: 0 !important;
}
:deep(.ant-form-item-explain),
:deep(.ant-form-item-extra) {
min-height: 0;
margin-top: 6px;
font-size: 11.5px;
line-height: 1.6;
}
:deep(.ant-form-item-extra) {
color: @inkFaint;
}
// Ant's own feedback icons duplicate the red border.
:deep(.ant-form-item-children-icon) {
display: none;
}
}
// Numbers are typed left-to-right but stay aligned with the RTL labels.
.reg__num :deep(input) {
direction: ltr;
text-align: right;
letter-spacing: 0.5px;
}
.reg__note {
display: flex;
align-items: flex-start;
gap: 10px;
margin-top: 4px;
padding: 12px 14px;
border-radius: 14px;
background: @wineWash;
color: @wine;
font-size: 12px;
font-weight: 600;
line-height: 1.8;
:deep(svg) {
flex: 0 0 auto;
margin-top: 2px;
}
}
/* ---------- Sticky footer ---------- */
.reg__footer {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 15;
padding: 12px 16px calc(12px + env(safe-area-inset-bottom, 0px));
background: alpha(@canvas, 86%);
-webkit-backdrop-filter: blur(16px) saturate(180%);
backdrop-filter: blur(16px) saturate(180%);
border-top: 1px solid @hairline;
}
.reg__footerInner {
max-width: 736px;
margin: 0 auto;
}
.reg__cta {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
min-height: 52px;
padding: 0 18px;
border: 0;
cursor: pointer;
border-radius: 16px;
font-family: inherit;
font-size: 14px;
font-weight: 800;
color: @onInk;
background: @ink;
box-shadow: 0 10px 22px -12px alpha(@ink, 70%);
transition: transform 0.15s ease, opacity 0.2s ease;
&:active {
transform: scale(0.98);
}
&.is-loading,
&:disabled {
opacity: 0.75;
cursor: default;
}
:deep(svg) {
transition: transform 0.2s ease;
}
&:active :deep(svg) {
transform: translateX(-3px);
}
}
.reg__spinner {
width: 16px;
height: 16px;
border-radius: 50%;
border: 2px solid fade(#fff, 35%);
border-top-color: #fff;
animation: reg-spin 0.7s linear infinite;
}
.reg__ghost {
width: 100%;
min-height: 50px;
border: 1.5px solid @hairline;
border-radius: 16px;
background: @canvas;
cursor: pointer;
font-family: inherit;
font-size: 13.5px;
font-weight: 700;
color: @ink;
}
.reg__switch {
margin: 10px 0 0;
text-align: center;
font-size: 12px;
color: @inkMuted;
button {
border: 0;
background: none;
cursor: pointer;
padding: 0 3px;
font-family: inherit;
font-size: 12px;
font-weight: 800;
color: @wine;
}
}
/* ---------- Success ---------- */
.reg__done {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding-top: 120px;
padding-bottom: 40px;
}
.reg__doneMark {
position: relative;
display: grid;
place-items: center;
width: 88px;
height: 88px;
border-radius: 26px;
color: @green;
background: alpha(@green, 10%);
animation: reg-pop 0.6s cubic-bezier(0.2, 1.5, 0.4, 1) both;
:deep(svg) {
animation: reg-pop 0.6s 0.15s cubic-bezier(0.2, 1.6, 0.4, 1) both;
}
}
.reg__doneRing {
position: absolute;
inset: 0;
border-radius: inherit;
border: 2px solid @green;
opacity: 0;
animation: reg-ring 1.1s 0.3s ease-out both;
}
.reg__doneTitle {
margin: 22px 0 0;
font-size: 19px;
font-weight: 800;
color: @ink;
}
.reg__doneText {
max-width: 320px;
margin: 10px 0 0;
font-size: 13px;
line-height: 2;
color: @inkMuted;
}
.reg__ltr {
direction: ltr;
unicode-bidi: isolate;
font-weight: 700;
color: @ink;
}
.reg__doneActions {
display: grid;
gap: 10px;
width: 100%;
max-width: 360px;
margin-top: 30px;
}
/* ---------- Motion ---------- */
.reg-next-enter-active,
.reg-next-leave-active,
.reg-prev-enter-active,
.reg-prev-leave-active {
transition: transform 0.28s cubic-bezier(0.65, 0, 0.35, 1), opacity 0.22s ease;
}
// RTL: moving forward slides content toward the left.
.reg-next-enter-from,
.reg-prev-leave-to {
transform: translateX(-24px);
opacity: 0;
}
.reg-next-leave-to,
.reg-prev-enter-from {
transform: translateX(24px);
opacity: 0;
}
.reg-fade-enter-active,
.reg-fade-leave-active {
transition: opacity 0.25s ease;
}
.reg-fade-enter-from,
.reg-fade-leave-to {
opacity: 0;
}
@keyframes reg-spin {
to {
transform: rotate(360deg);
}
}
@keyframes reg-pop {
0% {
transform: scale(0.4);
opacity: 0;
}
100% {
transform: scale(1);
opacity: 1;
}
}
@keyframes reg-ring {
0% {
transform: scale(1);
opacity: 0.6;
}
100% {
transform: scale(1.55);
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.reg-next-enter-active,
.reg-next-leave-active,
.reg-prev-enter-active,
.reg-prev-leave-active {
transition: opacity 0.2s ease;
}
.reg-next-enter-from,
.reg-next-leave-to,
.reg-prev-enter-from,
.reg-prev-leave-to {
transform: none;
}
.reg__doneMark,
.reg__doneMark :deep(svg),
.reg__doneRing {
animation: none;
}
}
</style>