refactor(ui): replace modal dialogs with toast/sheet components

Remove DialogVant/NotifyVant and add Toast and Sheet components with
useToast, useReveal, and usePageTransition composables. Rework
Navigation, RegisterView, and styles across both apps; rebuild dist assets.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
s.esmaeili
2026-10-04 13:57:25 +03:30
co-authored by Claude Sonnet 5
parent 148b99c4a6
commit c05352d6fd
205 changed files with 3362 additions and 1701 deletions
+20 -26
View File
@@ -101,7 +101,7 @@
v-if="cartData.tempRow?.product"
:closable="false"
height="300px"
class="rtl"
class="rtl island-sheet"
:bodyStyle="{ padding: 0 }"
>
<div class="sheet">
@@ -115,29 +115,25 @@
</div>
</Drawer>
<DialogVant :timeOut="-1" v-model:show="cartData.notify" :closeOnClickOverlay="true">
<template v-slot:img>
<img :src="dialogEmpty4" alt="" class="opacity-70" />
</template>
<template v-slot:title>آیا مطمئن به ثبت سفارش هستید ؟</template>
<template v-slot:button>
<div class="text-center text-[#1C2D56]" @click="submitOrder">بله</div>
<div class="text-center text-[#6b7280]" @click="cartData.notify = false">بی خیال</div>
</template>
</DialogVant>
<IslandSheet
v-model:show="cartData.notify"
art="order"
title="آیا مطمئن به ثبت سفارش هستید؟"
description="پس از ثبت، وضعیت سفارش را می توانید از بخش سفارش ها پیگیری کنید."
confirm-text="بله"
cancel-text="بی خیال"
@confirm="submitOrder"
/>
<DialogVant :timeOut="-1" v-model:show="loginNotify" :closeOnClickOverlay="true">
<template v-slot:img>
<img :src="loginDialogImage" alt="" class="opacity-80" />
</template>
<template v-slot:title>
<div class="p-8 text-justify">برای ثبت سفارش ابتدا باید وارد حساب کاربری خود شوید.</div>
</template>
<template v-slot:button>
<div class="text-center text-[#1C2D56]" @click="router.push({ name: 'login' })">ورود/ثبت نام</div>
<div class="text-center text-danger" @click="loginNotify = false">بیخیال</div>
</template>
</DialogVant>
<IslandSheet
v-model:show="loginNotify"
art="login"
title="برای ثبت سفارش وارد شوید"
description="برای ثبت سفارش ابتدا باید وارد حساب کاربری خود شوید. سبد خرید شما حفظ می شود."
confirm-text="ورود/ثبت نام"
cancel-text="بیخیال"
@confirm="router.push({ name: 'login' })"
/>
</div>
</template>
@@ -146,12 +142,10 @@ import { nextTick, onMounted, reactive, ref } from 'vue'
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 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'
import IslandSheet from '../components/ui/IslandSheet.vue'
import CartItem from '../components/product/CartItem.vue'
import { useCartStore, type CartItem as CartItemType } from '../stores/cartStore'
import { useSessionStore } from '../stores/session'
@@ -61,30 +61,25 @@
</container>
</Spinner>
<DialogVantOneButton
<IslandSheet
v-model:show="done"
:time-out="-1"
art="success"
title="تکمیل اطلاعات شما با موفقیت انجام شد."
description="مدارک شما با موفقیت بارگذاری شد."
confirm-text="متوجه شدم"
:close-on-click-overlay="false"
>
<template #title>
<div class="cpf__dialogText">
تکمیل اطلاعات شما با موفقیت انجام شد.
</div>
</template>
<template #button>
<div class="cpf__dialogBtn" @click="finish">متوجه شدم</div>
</template>
</DialogVantOneButton>
@confirm="finish"
/>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { showNotify } from 'vant'
import { toast } from '../../composables/useToast'
import PageBar from '../../components/ui/PageBar.vue'
import Spinner from '../../components/Spinner.vue'
import DialogVantOneButton from '../../components/DialogVantOneButton.vue'
import IslandSheet from '../../components/ui/IslandSheet.vue'
import SingleImageUploader from '../../components/upload/SingleImageUploader.vue'
import { api } from '../../api/client'
@@ -106,7 +101,7 @@ async function submit() {
// The reference checked all three client-side before posting
// (completeProfile.vue:191-198); the server requires them too.
if (!form.business_license_image_id || !form.shop_image_id || !form.ncode_image_id) {
showNotify('لطفا تمامی عکس های خواسته شده را آپلود کنید')
toast.warning('لطفا تمامی عکس های خواسته شده را آپلود کنید')
return
}
@@ -184,16 +179,4 @@ function finish() {
opacity: 0.6;
cursor: progress;
}
.cpf__dialogText {
padding: 20px;
line-height: 1.9;
text-align: justify;
font-size: 14px;
}
.cpf__dialogBtn {
text-align: center;
cursor: pointer;
}
</style>
@@ -205,32 +205,25 @@
</container>
</PullRefresh>
<DialogVant :timeOut="-1" v-model:show="data.notify" :closeOnClickOverlay="true">
<template v-slot:img>
<img :src="exitImage" alt="" class="opacity-70 mb-8" />
</template>
<template v-slot:title>آیا می خواهید از برنامه خارج شوید؟</template>
<template v-slot:button>
<div class="text-center text-[#1C2D56]" @click="logOut">بله</div>
<div class="text-center text-[#6b7280]" @click="data.notify = false">بی خیال</div>
</template>
</DialogVant>
<IslandSheet
v-model:show="data.notify"
art="logout"
title="آیا می خواهید از برنامه خارج شوید؟"
description="برای استفاده دوباره از امکانات منتزر، باید دوباره وارد حساب کاربری خود شوید."
confirm-text="بله"
cancel-text="بی خیال"
@confirm="logOut"
/>
<DialogVant :timeOut="-1" v-model:show="data.loginNotify" :closeOnClickOverlay="true">
<template v-slot:img>
<img :src="loginDialogImage" alt="" class="opacity-80" />
</template>
<template v-slot:title>
<div class="p-8 text-justify">
برای دسترسی به تمامی امکانات نرم افزار منتزر، ابتدا گزینه ورود/ثبت نام را لمس کنید و در
صورت نداشتن حساب کاربری، ثبت نام نمایید.
</div>
</template>
<template v-slot:button>
<div class="text-center text-[#1C2D56]" @click="router.push({ name: 'login' })">ورود/ثبت نام</div>
<div class="text-center text-danger" @click="data.loginNotify = false">بیخیال</div>
</template>
</DialogVant>
<IslandSheet
v-model:show="data.loginNotify"
art="login"
title="ابتدا وارد حساب کاربری شوید"
description="برای دسترسی به تمامی امکانات نرم افزار منتزر، گزینه ورود/ثبت نام را لمس کنید و در صورت نداشتن حساب کاربری، ثبت نام نمایید."
confirm-text="ورود/ثبت نام"
cancel-text="بیخیال"
@confirm="router.push({ name: 'login' })"
/>
</div>
</template>
@@ -240,9 +233,7 @@ import { useRouter } from 'vue-router'
import { Loading, PullRefresh } from 'vant'
import Icon from '../../components/icon/Icon.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 IslandSheet from '../../components/ui/IslandSheet.vue'
import { price } from '../../utils/filters'
import { useSessionStore } from '../../stores/session'
import { api, type CustomerDetail, type GoldPriceQuote } from '../../api/client'
+6 -11
View File
@@ -19,8 +19,6 @@
-->
<template>
<div class="auth">
<NotifyVant :timeOut="2000" v-model="notify.show" :text="notify.text" :color="'background-color: #DC2626'" />
<PageBar title="ورود" back-to="home" />
<container class="auth__body">
@@ -88,7 +86,7 @@ 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 NotifyVant from '../../components/NotifyVant.vue'
import { toast } from '../../composables/useToast'
import OtpInput from '../../components/input/OtpInput.vue'
import { required, mobile as mobileRule } from '../../utils/validator'
import { useSessionStore } from '../../stores/session'
@@ -100,7 +98,6 @@ const route = useRoute()
const session = useSessionStore()
const formModel = reactive({ username: undefined as string | undefined })
const notify = reactive({ show: false, text: 'اطلاعات کاربری در سیستم وجود ندارد.' })
const body = reactive({
step: 1,
loading: false,
@@ -146,8 +143,7 @@ const sendPhone = async () => {
// 429 = the send throttle (too soon, or too many in the window). Its message
// is already Persian and user-facing, so show it rather than a generic one.
const text = e instanceof Error && /\(429\)/.test(e.message) ? extractServerMessage(e) : null
notify.text = text ?? 'خطا در ارسال کد. لطفاً دوباره تلاش کنید.'
notify.show = true
toast.error(text ?? 'خطا در ارسال کد. لطفاً دوباره تلاش کنید.')
} finally {
body.loading = false
}
@@ -177,14 +173,13 @@ const sendToken = async (otpCode: string) => {
}
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : undefined
await router.push(redirect ?? { name: 'home' })
notify.text = 'با موفقیت وارد شدید'
notify.show = true
toast.success('با موفقیت وارد شدید')
} catch (e: unknown) {
notify.text =
toast.error(
e instanceof Error && /\(429\)/.test(e.message)
? (extractServerMessage(e) ?? 'تعداد تلاش‌ها بیش از حد مجاز است.')
: 'کد وارد شده صحیح نیست'
notify.show = true
: 'کد وارد شده صحیح نیست',
)
} finally {
body.loading = false
}
+674 -154
View File
@@ -1,89 +1,188 @@
<!--
Ported from legacy henza_pwa/src/views/user/LoginRegister/components/Register.vue,
restyled into the redesigned design language (see components/home/* and
HomeView).
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.
DROPPED as confirmed-dead code (verified unreachable, not merely "possibly
dead"): legacy rendered a whole cascading country->province->city picker
(WorkActionSheet + selectCityArrays + getCities() against POST /cities/list)
alongside a plain "شهر" text <Input>. The only element that ever opened the
picker (`#citybtn`) was itself commented out in legacy's own template, so the
picker was unreachable UI even in the original app; the visible form uses the
plain text input. That whole complex, and the parallel unused
`userFormModel`/`defaultUserFormModel` objects, are not ported.
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.
STUBBED, not real, and unchanged from the previous version: legacy's
POST /major/register — apps/api has no create-customer endpoint. Submitting
shows legacy's exact success message without persisting anything. The copy
says the account awaits review, which is what legacy said too, so the screen
does not claim an account exists when none was created.
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="auth">
<PageBar title="ثبت نام" back-to="home" />
<div class="reg">
<PageBar title="ثبت نام" :back-action="goBack" />
<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>
<section class="auth__card">
<Form :model="formModel" layout="vertical" @finish="register" class="auth__form">
<FormItem name="fname" label="نام" :rules="required('نام')">
<Input v-model:value="formModel.fname" />
</FormItem>
<FormItem name="lname" label="نام خانوادگی" :rules="required('نام خانوادگی')">
<Input v-model:value="formModel.lname" />
</FormItem>
<FormItem
name="mobile"
label="شماره موبایل"
:rules="[mobileRule('شماره موبایل'), required('شماره موبایل')]"
>
<Input v-model:value="formModel.mobile" type="number" />
</FormItem>
<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" />
</FormItem>
<button type="submit" class="auth__cta">ثبت اطلاعات</button>
</Form>
</section>
<p class="auth__switch">
اگر حساب کاربری دارید
<button type="button" @click="emit('register', false)">وارد شوید</button>
</p>
</container>
</Spinner>
<DialogVant :timeOut="-1" v-model:show="registeredInfo" :closeOnClickOverlay="false">
<template v-slot:img>
<img :src="registerDelayImage" alt="" class="opacity-80" />
</template>
<template v-slot:title>
<div class="p-5">
ثبت نام شما با موفقیت انجام شد. منتظر تایید اطلاعات ثبت نامی شما توسط کارشناسان بمانید.
<!-- 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>
</template>
<template v-slot:button>
<div class="text-center" @click="router.push({ name: 'home' })">متوجه شدم</div>
</template>
</DialogVant>
<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" 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>
@@ -91,18 +190,16 @@
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'
import { required, mobile as mobileRule } from '../../utils/validator'
const emit = defineEmits<{ (e: 'register', value: boolean): void }>()
const router = useRouter()
const profileData = reactive({ loading: false })
const registeredInfo = ref(false)
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,
@@ -110,109 +207,532 @@ const formModel = reactive({
mobile: undefined as string | undefined,
store_name: undefined as string | undefined,
phone: undefined as string | undefined,
address: undefined as string | undefined,
city: undefined as string | undefined,
address: undefined as string | undefined,
})
const register = async () => {
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 {
profileData.loading = true
loading.value = true
// Stubbed — no create-customer endpoint exists in apps/api yet.
registeredInfo.value = true
await new Promise((r) => setTimeout(r, 600))
done.value = true
window.scrollTo({ top: 0 })
} finally {
profileData.loading = false
loading.value = false
}
}
</script>
<style scoped lang="less">
.auth {
background: @canvas;
.reg {
min-height: 100vh;
background: @canvas;
}
.auth__body {
padding-top: 86px;
padding-bottom: 90px;
.reg__body {
padding-top: 78px;
// Room for the sticky footer.
padding-bottom: calc(150px + env(safe-area-inset-bottom, 0px));
}
.auth__intro {
/* ---------- 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: #b4b8c0;
}
:deep(.ant-input:hover),
:deep(.ant-input-affix-wrapper:hover) {
border-color: darken(@hairline, 8%);
}
: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 fade(@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: fade(@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 fade(@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: fade(@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: #fff;
background: @ink;
box-shadow: 0 10px 22px -12px fade(@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-bottom: 24px;
padding-top: 120px;
padding-bottom: 40px;
}
.auth__mark {
width: 64px;
height: 64px;
border-radius: 50%;
.reg__doneMark {
position: relative;
display: grid;
place-items: center;
color: @wine;
background: @wineWash;
width: 88px;
height: 88px;
border-radius: 26px;
color: @green;
background: fade(@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;
}
}
.auth__title {
margin: 14px 0 0;
font-size: 16px;
.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;
}
.auth__sub {
margin: 6px 0 0;
font-size: 12.5px;
color: @inkFaint;
}
.auth__card {
padding: 18px 16px 20px;
border: 1px solid @hairline;
border-radius: 20px;
background: @canvasSoft;
}
.auth__form :deep(.ant-form-item-label > label) {
font-size: 12px;
color: @inkMuted;
}
.auth__cta {
width: 100%;
margin-top: 4px;
padding: 13px 18px;
border: 0;
cursor: pointer;
border-radius: 14px;
font-family: inherit;
.reg__doneText {
max-width: 320px;
margin: 10px 0 0;
font-size: 13px;
font-weight: 700;
color: #fff;
background: @ink;
transition: transform 0.15s ease;
}
.auth__cta:active {
transform: scale(0.99);
}
.auth__switch {
margin: 20px 0 0;
text-align: center;
font-size: 12px;
line-height: 2;
color: @inkMuted;
}
.auth__switch button {
border: 0;
background: none;
cursor: pointer;
padding: 0 3px;
font-family: inherit;
font-size: 12px;
.reg__ltr {
direction: ltr;
unicode-bidi: isolate;
font-weight: 700;
color: @wine;
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>