Files
tehran_taj/apps/user-app-v2/src/views/user/RegisterView.vue
T

739 lines
19 KiB
Vue
Raw Normal View History

<!--
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>