feat: move brand name to one config and redesign the accounting card

اپ: نام برند در همه‌ی صفحه‌ها و پیام‌ها یکسان شده و از یک تنظیم مرکزی خوانده می‌شود.
اپ: کارت حسابداری در صفحه‌ی مشتری بازطراحی شد؛ مانده‌ها و وضعیت لینک واضح‌تر نمایش داده می‌شوند.
پنل: کارت حسابداری در پرونده‌ی مشتری بازطراحی شد؛ مانده‌ها، آخرین ارسال و فرم لینک دستی واضح‌تر شده‌اند.
پنل: ستون «کد» در جدول‌ها به‌طور پیش‌فرض پنهان است و از انتخاب ستون‌ها قابل نمایش است.
پنل: تغییر نمایش ستون‌ها در جدول‌ها بلافاصله اعمال می‌شود.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
s.esmaeili
2026-10-14 13:31:20 +03:30
co-authored by Claude Haiku 5.5
parent 5d2a1efb0c
commit 532ceee189
100 changed files with 1870 additions and 579 deletions
+18
View File
@@ -260,6 +260,17 @@ export interface GoldPriceQuote {
quotedAt: string;
}
/** Body of POST customer/auth/register — the app register form, field for field. */
export interface RegisterBody {
fname: string;
lname: string;
mobile: string;
store_name: string;
phone: string;
address: string;
city: string;
}
/** One admin-uploaded home banner slide — raw shape as saved by the admin
* panel's manage/Const.vue "اسلایدر موبایل" tab (image + optional link). */
export interface AdminSlide {
@@ -444,6 +455,13 @@ export const api = {
* which used to be derived by scanning the whole catalogue in the browser.
*/
getCatalogFacets: () => request<CatalogFacets>('/catalog/products/facets'),
/** App self-registration. The new customer waits for admin approval before signing in. */
register: (body: RegisterBody) =>
request<{ ok: boolean }>('/customer/auth/register', {
method: 'POST',
body: JSON.stringify(body),
}),
/**
* Step 1 of login — ports henza_server ApiMobileController::userAuth.
* Always resolves `{ sent: true }`, even for an unregistered number, so the

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

@@ -704,6 +704,24 @@ html.motion-reduced .toast {
background: alpha(@canvas, 97%) !important;
}
/* Browser autofill paints its own pale-blue fill (and dark text) over the
field, which can't be overridden with `background`. Cover it with an inset
shadow in the field's own colour instead. Inside an ant affix wrapper only
the inner <input> gets autofilled, so without this it shows as a blue box
floating inside the bordered wrapper. */
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus,
textarea:-webkit-autofill,
textarea:-webkit-autofill:hover,
textarea:-webkit-autofill:focus {
-webkit-text-fill-color: @ink;
caret-color: @ink;
-webkit-box-shadow: 0 0 0 1000px @canvas inset !important;
box-shadow: 0 0 0 1000px @canvas inset !important;
transition: background-color 9999s ease-out 0s;
}
/* ── Dark theme: third-party surfaces ──────────────────────────
ant-design-vue compiles its theme at build time with black text
(fade(@black, 85%)) and white fills; only its surface backgrounds were
+2
View File
@@ -0,0 +1,2 @@
// The brand lives in the repo-root brand.ts, shared by all three frontends.
export { brand } from '../../../brand';
+2 -1
View File
@@ -2,7 +2,7 @@
<template>
<Spin :spinning="spinning" :tip="tip" :size="(size as 'small' | 'default' | 'large')" :delay="delay" :class="fixed ? '!max-h-screen !fixed' : ''">
<template #indicator>
<img v-if="!indicator" src="/img/montzar-logo.png" alt="Montzar" class="spinner-brand" width="48" height="48" />
<img v-if="!indicator" src="/img/brand-logo.png" :alt="brand.nameEn" class="spinner-brand" width="48" height="48" />
<!-- keep-motion: a working spinner, exempt from reduced motion. -->
<div class="loading keep-motion" v-else>
<div class="effect-1 effects"></div>
@@ -17,6 +17,7 @@
</template>
<script setup lang="ts">
import { brand } from '../brand'
import { Spin } from 'ant-design-vue'
withDefaults(
@@ -6,10 +6,8 @@
module drags in its own CSS that fights the app's RTL/less theming, and the
effect here is three transforms — cheaper to own than to configure.
Slides are built from REAL categories (see HomeView), so each one deep-links
into a genuine filtered search. The artwork is the first catalogue image of
that category; a slide with no image renders its tile blank rather than
substituting stock art.
Slides come only from the admin slider section (see HomeView) and are
display-only: they link nowhere.
-->
<template>
<section class="hero" v-if="slides.length">
@@ -27,9 +25,9 @@
class="hero__card"
:class="{ 'is-active': i === index }"
:style="cardStyle(i)"
@click="onCardClick(i, slide)"
@click="onCardClick(i)"
>
<img v-if="slide.image" :src="slide.image" :alt="slide.title" class="hero__art" draggable="false" />
<img v-if="slide.image" :src="slide.image" alt="" class="hero__art" draggable="false" />
<div v-else class="hero__artFallback" aria-hidden="true"></div>
</article>
</div>
@@ -41,7 +39,7 @@
type="button"
class="hero__dot"
:class="{ 'is-active': i === index }"
:aria-label="slide.title"
:aria-label="`اسلاید ${i + 1}`"
@click="go(i)"
/>
</div>
@@ -52,19 +50,15 @@
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useAppearanceStore } from '../../stores/appearanceStore'
/** A slide from the admin slider section. Display-only — it has no destination. */
export interface HeroSlide {
key: string
title: string
image?: string
categoryId?: string
/** Admin-uploaded slides carry their own destination instead of a category. */
link?: string
}
const props = withDefaults(defineProps<{ slides: HeroSlide[]; autoplay?: boolean }>(), {
autoplay: true,
})
const emit = defineEmits<{ (e: 'select', slide: HeroSlide): void }>()
const index = ref(0)
const stage = ref<HTMLElement | null>(null)
@@ -169,14 +163,11 @@ function onUp(e: PointerEvent) {
else restart()
}
function onCardClick(i: number, slide: HeroSlide) {
// A click that ended a drag must not also open the category.
// Slides are display-only. Tapping a peeking side card only brings it to the
// front; tapping the front card does nothing.
function onCardClick(i: number) {
if (Math.abs(drag.value) > 1) return
if (i !== index.value) {
go(i)
return
}
emit('select', slide)
if (i !== index.value) go(i)
}
function stop() {
@@ -232,6 +223,7 @@ onBeforeUnmount(stop)
// The seam: a shadow cast by the front card onto the two tucked behind it, so
// the centre card reads as lifted off the stack rather than pasted onto it.
.hero__card.is-active {
cursor: default;
box-shadow: 0 18px 44px -12px rgba(20, 22, 26, 0.45), 0 0 0 1px rgba(255, 255, 255, 0.9);
}
@@ -182,8 +182,8 @@ html.theme-dark .pc__art img {
border-radius: 999px;
font-size: 10px;
font-weight: 700;
color: @onInk;
background: @ink;
color: #fff;
background: @wine;
}
.pc__flag--soft {
@@ -299,7 +299,7 @@
v-model:show="loginNotify"
art="login"
title="ابتدا وارد حساب کاربری شوید"
description="برای دسترسی به تمامی امکانات نرم افزار منتزر، گزینه ورود/ثبت نام را لمس کنید و در صورت نداشتن حساب کاربری، ثبت نام نمایید."
:description="`برای دسترسی به تمامی امکانات نرم افزار ${brand.name}، گزینه ورود/ثبت نام را لمس کنید و در صورت نداشتن حساب کاربری، ثبت نام نمایید.`"
confirm-text="ورود/ثبت نام"
cancel-text="بیخیال"
@confirm="router.push({ name: 'login' })"
@@ -308,6 +308,7 @@
</template>
<script setup lang="ts">
import { brand } from '../../brand'
import { computed, onBeforeMount, onBeforeUnmount, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import Icon from '../icon/Icon.vue'
@@ -1132,9 +1133,9 @@ onBeforeUnmount(() => {
transform: scale(0.95);
}
.pd__chip.is-on {
color: @onInk;
background: @ink;
border-color: @ink;
color: #fff;
background: @wine;
border-color: @wine;
}
/* Same treatment as v1's .pd__variant.is-out: an un-orderable weight stays
visible (so the customer can see it exists) but reads as unavailable. */
@@ -1314,9 +1315,9 @@ onBeforeUnmount(() => {
font-weight: 800;
white-space: nowrap;
text-decoration: none;
color: @onInk;
background: @ink;
box-shadow: 0 14px 30px -18px rgba(20, 22, 26, 0.9);
color: #fff;
background: @wine;
box-shadow: 0 14px 30px -18px rgba(124, 0, 53, 0.9);
transition: transform 0.18s ease, opacity 0.18s ease;
}
.pd__cta:active {
@@ -504,9 +504,9 @@ function onTouchEnd() {
border: 1px solid @hairline;
}
.fSheet__chip.is-on {
color: @onInk;
background: @ink;
border-color: @ink;
color: #fff;
background: @wine;
border-color: @wine;
}
.fSheet__gram {
@@ -578,8 +578,8 @@ function onTouchEnd() {
.fSheet__apply {
flex: 1;
border: 0;
color: @onInk;
background: @ink;
box-shadow: 0 14px 30px -18px rgba(20, 22, 26, 0.9);
color: #fff;
background: @wine;
box-shadow: 0 14px 30px -18px rgba(124, 0, 53, 0.9);
}
</style>
@@ -152,8 +152,8 @@ const show = ref(props.defaultOpen)
background: @canvas;
}
.dd.is-open .dd__chevron {
color: @onInk;
background: @ink;
color: #fff;
background: @wine;
}
.dd__body {
@@ -16,7 +16,7 @@
ProductPanel / catalogue-card treatment of a piece of jewellery;
- a centred title and description under it, as on those cards;
- the detail page's action bar: an outlined pill beside a filled pill.
The filled pill is @ink, or @wine for the destructive tone (logout).
The filled pill is @wine (brand), or @ink for the destructive tone (logout; the class is still .iSheet--wine).
The component name, props, events and illustrations are unchanged, so no
call site had to change.
@@ -234,18 +234,18 @@ function onTouchEnd() {
animation-play-state: paused !important;
}
// Two looks, one per job: the filled button is @wine for the destructive
// action (logout) and @ink — the app's primary surface — for everything else.
// Two looks, one per job: the filled button is @ink for the destructive
// action (logout) and @wine — the brand colour — for everything else.
.iSheet__card {
--t: @wine;
--t-on: #fff;
--t-rgb: 124, 0, 53;
}
.iSheet--wine {
--t: @ink;
--t-on: @onInk;
--t-rgb: 20, 22, 26;
}
.iSheet--wine {
--t: @wine;
--t-on: #fff;
--t-rgb: 122, 27, 51;
}
// The detail page's / filter sheet's shell: white, attached to the bottom
// edge, 32px top radius, a centred column on wide screens.
@@ -26,23 +26,23 @@
<template v-if="name === 'logout'">
<defs>
<linearGradient :id="ids.a" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#A33C55" />
<stop offset="1" stop-color="#5E1427" />
<stop offset="0" stop-color="#A0386A" />
<stop offset="1" stop-color="#5A0027" />
</linearGradient>
<radialGradient :id="ids.b" cx=".35" cy=".5" r=".75">
<stop offset="0" stop-color="#FFF7E6" />
<stop offset="1" stop-color="#F4C57A" />
</radialGradient>
</defs>
<ellipse class="sa-shadow" cx="62" cy="106" rx="36" ry="4" fill="#7A1B33" opacity=".12" />
<rect x="48" y="18" width="40" height="84" rx="5" :fill="`url(#${ids.b})`" stroke="#7A1B33" stroke-width="3" />
<ellipse class="sa-shadow" cx="62" cy="106" rx="36" ry="4" fill="#7C0035" opacity=".12" />
<rect x="48" y="18" width="40" height="84" rx="5" :fill="`url(#${ids.b})`" stroke="#7C0035" stroke-width="3" />
<g class="sa-door">
<path d="M88 18 L88 102 L69 108 L69 12 Z" :fill="`url(#${ids.a})`" stroke="#5E1427" stroke-width="2" stroke-linejoin="round" />
<path d="M88 18 L88 102 L69 108 L69 12 Z" :fill="`url(#${ids.a})`" stroke="#5A0027" stroke-width="2" stroke-linejoin="round" />
<circle cx="73.5" cy="61" r="2.6" fill="#F4C57A" />
</g>
<g class="sa-arrow" stroke-linecap="round" stroke-linejoin="round">
<path d="M60 60 H22 M33 48 L21 60 L33 72" stroke="#fff" stroke-width="12" />
<path d="M60 60 H22 M33 48 L21 60 L33 72" stroke="#7A1B33" stroke-width="6" />
<path d="M60 60 H22 M33 48 L21 60 L33 72" stroke="#7C0035" stroke-width="6" />
</g>
</template>
@@ -152,7 +152,7 @@
<g class="sa-confetti">
<rect x="18" y="30" width="6" height="6" rx="1.5" fill="#F4C57A" transform="rotate(20 21 33)" />
<circle cx="98" cy="26" r="3.2" fill="#16a34a" />
<rect x="100" y="52" width="7" height="3.5" rx="1.75" fill="#A33C55" transform="rotate(-30 103.5 53.75)" />
<rect x="100" y="52" width="7" height="3.5" rx="1.75" fill="#A0386A" transform="rotate(-30 103.5 53.75)" />
<circle cx="20" cy="72" r="2.6" fill="#335CB1" />
<rect x="88" y="8" width="3.5" height="7" rx="1.75" fill="#D9A441" transform="rotate(35 89.75 11.5)" />
</g>
@@ -46,8 +46,8 @@ defineProps<{ on: boolean }>()
}
/* ── Look: user-app-v2 ──────────────────────────────────────── */
// The app's chosen state is a filled @ink (chips, CTAs): the track fills @ink
// and the knob takes @onInk, so it stays a contrasting pair in both themes.
// The app's chosen state is a filled @wine (chips, CTAs): the track fills @wine
// and the knob stays white, which reads on @wine in both themes.
.tsw {
background: @hairline;
}
@@ -55,9 +55,9 @@ defineProps<{ on: boolean }>()
background: #fff;
}
.tsw.is-on {
background: @ink;
background: @wine;
}
.tsw.is-on .tsw__knob {
background: @onInk;
background: #fff;
}
</style>
+4 -3
View File
@@ -25,7 +25,8 @@ import { defineStore } from 'pinia'
import { api } from '../api/client'
import shippingArt from '../assets/img/shipping-and-delivery.png'
import competitionArt from '../assets/img/sports-and-competition.png'
import brandArt from '../assets/img/montzar-logo.png'
import brandArt from '../assets/img/brand-logo.png'
import { brand } from '../brand'
export interface Slide {
text: string | undefined
@@ -50,13 +51,13 @@ const PLACEHOLDER_SLIDES: Slide[] = [
{
type: 'popup',
src: competitionArt,
text: 'جدیدترین محصولات منتزر را در بخش محصولات جدید ببینید.',
text: `جدیدترین محصولات ${brand.name} را در بخش محصولات جدید ببینید.`,
link: undefined,
},
{
type: 'popup',
src: brandArt,
text: 'منتزر، همراه شما در خرید و فروش طلا.',
text: `${brand.name}، همراه شما در خرید و فروش طلا.`,
link: undefined,
},
]
+8 -8
View File
@@ -344,8 +344,8 @@ onMounted(async () => {
font-family: inherit;
font-size: 12.5px;
font-weight: 700;
color: @onInk;
background: @ink;
color: #fff;
background: @wine;
}
.cart__done {
@@ -400,8 +400,8 @@ onMounted(async () => {
font-family: inherit;
font-size: 12.5px;
font-weight: 700;
color: @onInk;
background: @ink;
color: #fff;
background: @wine;
}
.cart__bar {
@@ -460,8 +460,8 @@ onMounted(async () => {
font-family: inherit;
font-size: 13px;
font-weight: 700;
color: @onInk;
background: @ink;
color: #fff;
background: @wine;
transition: transform 0.15s ease;
}
@@ -489,7 +489,7 @@ onMounted(async () => {
font-family: inherit;
font-size: 13px;
font-weight: 700;
color: @onInk;
background: @ink;
color: #fff;
background: @wine;
}
</style>
+11 -49
View File
@@ -22,7 +22,7 @@
<div class="home">
<header class="home__bar">
<div class="home__barInner">
<img src="/img/montzar-logo.png" alt="منتزر" class="home__logo" />
<img src="/img/brand-logo.png" :alt="brand.name" class="home__logo" />
<RateStrip :quote="goldPrice" compact class="home__rate" />
</div>
</header>
@@ -39,7 +39,6 @@
:slides="slides"
class="home__hero home__reveal"
:style="revealDelay(0)"
@select="onSlide"
/>
<section v-if="memory.cats.length" class="home__section home__reveal" :style="revealDelay(1)">
@@ -80,6 +79,7 @@
</template>
<script setup lang="ts">
import { brand } from '../brand'
import { computed, nextTick, onActivated, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Loading, PullRefresh } from 'vant'
@@ -149,35 +149,15 @@ const newestProducts = computed(() => products.value)
const reelProducts = computed(() => products.value.slice(0, REEL_LIMIT))
const listProducts = computed(() => products.value.slice(0, LIST_LIMIT))
// The hero is built from REAL categories, not a curated banner payload (none
// exists). Each slide deep-links into search filtered by that category, so the
// artwork is decorative but the destination is genuine.
const slides = computed<HeroSlide[]>(() => {
// Prefer what the admin actually configured; the category-derived hero below
// stays as the fallback for when no slide has been uploaded.
if (adminSlides.value?.length) {
return adminSlides.value
.filter((s) => s.image)
.map((s, i) => ({
key: `${i}-${s.image}`,
title: '',
image: s.image,
link: s.link || undefined,
}))
}
return memory.cats.slice(0, 5).map((cat) => ({
key: cat.id,
title: cat.title,
image: productImageForCategory(cat.id),
categoryId: cat.id,
}))
})
function productImageForCategory(categoryId: string): string | undefined {
const match = products.value.find((p) => p.category?.id === categoryId)
return match?.images?.[0]?.url
}
// The hero shows ONLY what the admin uploaded in the slider section
// (manage/Const.vue "اسلایدر موبایل"). It used to fall back to slides built
// from categories and filled with product photos, so a newly added product
// could land in the hero. Slides are display-only: tapping one does nothing.
const slides = computed<HeroSlide[]>(() =>
(adminSlides.value ?? [])
.filter((s) => s.image)
.map((s, i) => ({ key: `${i}-${s.image}`, image: s.image })),
)
// Opens as a morph out of the tapped card (ProductMorph.vue, mounted in
// App.vue) — the card is found from the tap, so the emit chain stays as is.
@@ -190,24 +170,6 @@ function onCategory(cat: HomeCategory) {
router.push({ name: 'search' })
}
function onSlide(slide: HeroSlide) {
// Admin-uploaded slides (manage/Const.vue) carry their own destination
// instead of a categoryId — an absolute URL opens in a new tab, anything
// else is treated as an in-app path. Same rule as user-app.
if (slide.link) {
if (/^https?:\/\//i.test(slide.link)) {
window.open(slide.link, '_blank', 'noopener')
} else {
router.push(slide.link)
}
return
}
if (slide.categoryId) {
filters.query.cats = [slide.categoryId]
}
router.push({ name: 'search' })
}
function goNewest() {
router.push({ name: 'newestProduct' })
}
@@ -422,8 +422,8 @@ onBeforeUnmount(() => observer?.disconnect())
padding: 13px;
border: none;
border-radius: 14px;
background: @ink;
color: @onInk;
background: @wine;
color: #fff;
font-family: inherit;
font-size: 14px;
font-weight: 700;
@@ -392,8 +392,8 @@ onMounted(load)
padding: 13px;
border: none;
border-radius: 14px;
background: @ink;
color: @onInk;
background: @wine;
color: #fff;
font-family: inherit;
font-size: 14px;
font-weight: 700;
@@ -271,14 +271,14 @@ onMounted(() => loadProducts())
}
.search__chip.is-on {
color: @onInk;
background: @ink;
border-color: @ink;
color: #fff;
background: @wine;
border-color: @wine;
}
.search__chip--filter.is-on {
background: @wine;
border-color: @wine;
background: @wineDeep;
border-color: @wineDeep;
}
.search__chip:active {
@@ -289,7 +289,7 @@ const THEMES: { value: ThemeChoice; label: string; icon: string }[] = [
}
.ap__theme.is-on {
color: @ink;
box-shadow: inset 0 0 0 1.5px @ink;
box-shadow: inset 0 0 0 1.5px @wine;
}
.ap__preview {
@@ -306,8 +306,8 @@ const THEMES: { value: ThemeChoice; label: string; icon: string }[] = [
width: 22px;
height: 22px;
border-radius: 50%;
color: @onInk;
background: @ink;
color: #fff;
background: @wine;
}
.ap__row {
@@ -222,7 +222,7 @@
v-model:show="data.notify"
art="logout"
title="آیا می خواهید از برنامه خارج شوید؟"
description="برای استفاده دوباره از امکانات منتزر، باید دوباره وارد حساب کاربری خود شوید."
:description="`برای استفاده دوباره از امکانات ${brand.name}، باید دوباره وارد حساب کاربری خود شوید.`"
confirm-text="بله"
cancel-text="بی خیال"
@confirm="logOut"
@@ -232,7 +232,7 @@
v-model:show="data.loginNotify"
art="login"
title="ابتدا وارد حساب کاربری شوید"
description="برای دسترسی به تمامی امکانات نرم افزار منتزر، گزینه ورود/ثبت نام را لمس کنید و در صورت نداشتن حساب کاربری، ثبت نام نمایید."
:description="`برای دسترسی به تمامی امکانات نرم افزار ${brand.name}، گزینه ورود/ثبت نام را لمس کنید و در صورت نداشتن حساب کاربری، ثبت نام نمایید.`"
confirm-text="ورود/ثبت نام"
cancel-text="بیخیال"
@confirm="router.push({ name: 'login' })"
@@ -241,6 +241,7 @@
</template>
<script setup lang="ts">
import { brand } from '../../brand'
import { computed, nextTick, onActivated, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Loading, PullRefresh } from 'vant'
@@ -453,9 +454,9 @@ onMounted(async () => {
margin-bottom: 28px;
padding: 20px 18px 16px;
border-radius: 26px;
background: linear-gradient(155deg, @heroSurface 0%, @heroSurface 55%, #5E1427 130%);
background: linear-gradient(155deg, @heroSurface 0%, @heroSurface 55%, #5A0027 130%);
color: #fff;
box-shadow: 0 22px 40px -26px rgba(20, 22, 26, 0.32);
box-shadow: 0 22px 40px -26px rgba(124, 0, 53, 0.32);
}
.dash__heroTop {
@@ -125,7 +125,7 @@ watch(() => favorites.ids.join(','), load)
font-family: inherit;
font-size: 12.5px;
font-weight: 700;
color: @onInk;
background: @ink;
color: #fff;
background: @wine;
}
</style>
@@ -26,7 +26,7 @@
<div class="auth__mark" aria-hidden="true">
<Icon icon="vuesax-linear:security-user" size="28" />
</div>
<h2 class="auth__title">ورود به منتزر</h2>
<h2 class="auth__title">ورود به {{ brand.name }}</h2>
<p class="auth__sub">
{{ body.step === 1 ? 'شماره موبایل خود را وارد کنید' : 'رمز یکبارمصرف را وارد کنید' }}
</p>
@@ -81,6 +81,7 @@
</template>
<script setup lang="ts">
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'
@@ -147,7 +148,7 @@ const sendPhone = async () => {
// this branch the person is told "خطا در ارسال کد"، which is the
// very confusion the 404 was added to remove.
const text =
e instanceof Error && /\((?:429|404)\)/.test(e.message) ? extractServerMessage(e) : null
e instanceof Error && /\((?:429|404|403)\)/.test(e.message) ? extractServerMessage(e) : null
toast.error(text ?? 'خطا در ارسال کد. لطفاً دوباره تلاش کنید.')
} finally {
body.loading = false
@@ -294,8 +295,8 @@ const btnText = computed(() => (body.step === 1 ? 'دریافت رمز عبور'
font-family: inherit;
font-size: 13px;
font-weight: 700;
color: @onInk;
background: @ink;
color: #fff;
background: @wine;
transition: transform 0.15s ease, opacity 0.15s ease;
}
@@ -347,7 +348,7 @@ const btnText = computed(() => (body.step === 1 ? 'دریافت رمز عبور'
.otp-input:focus {
outline: none;
border-color: @ink;
border-color: @wine;
}
.otp-input.error {
@@ -298,9 +298,9 @@ onActivated(() => {
}
.ord__chip.is-on {
color: @onInk;
background: @ink;
border-color: @ink;
color: #fff;
background: @wine;
border-color: @wine;
}
.ord__chip:active {
@@ -430,15 +430,15 @@ onActivated(() => {
}
.detail__step.is-done .detail__stepDot {
color: @onInk;
background: @ink;
border-color: @ink;
color: #fff;
background: @wine;
border-color: @wine;
}
.detail__step.is-active .detail__stepDot {
color: #fff;
background: @wine;
border-color: @wine;
background: @wineDeep;
border-color: @wineDeep;
}
.detail__stepTitle {
@@ -16,10 +16,9 @@
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.
Submits to POST customer/auth/register (legacy POST /major/register). The new
customer is created inactive and lands in the admin's «مشتریان در انتظار»
list; they can sign in once an admin approves them.
-->
<template>
<div class="reg">
@@ -192,6 +191,8 @@ 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'
import { api } from '../../api/client'
import { toast } from '../../composables/useToast'
const emit = defineEmits<{ (e: 'register', value: boolean): void }>()
const router = useRouter()
@@ -235,14 +236,35 @@ async function onFinish() {
}
try {
loading.value = true
// Stubbed — no create-customer endpoint exists in apps/api yet.
await new Promise((r) => setTimeout(r, 600))
await api.register({
fname: String(formModel.fname ?? ''),
lname: String(formModel.lname ?? ''),
mobile: String(formModel.mobile ?? ''),
store_name: String(formModel.store_name ?? ''),
phone: String(formModel.phone ?? ''),
address: String(formModel.address ?? ''),
city: String(formModel.city ?? ''),
})
done.value = true
window.scrollTo({ top: 0 })
} catch (e: unknown) {
toast.error(serverMessage(e) ?? 'خطا در ثبت اطلاعات. لطفاً دوباره تلاش کنید.')
} finally {
loading.value = false
}
}
/** The API's Persian `message` (duplicate number, bad mobile, throttle), if any. */
function serverMessage(e: unknown): string | null {
const match = e instanceof Error ? e.message.match(/\{.*\}/) : null
if (!match) return null
try {
const parsed = JSON.parse(match[0]) as { message?: string | string[] }
return Array.isArray(parsed.message) ? parsed.message[0] ?? null : parsed.message ?? null
} catch {
return null
}
}
</script>
<style scoped lang="less">
@@ -364,16 +386,19 @@ async function onFinish() {
}
}
// Same field language as the rest of the app (SearchInput, login): @canvas
// fill, 1px @hairline edge, and on focus only the edge turns @wineSoft —
// no tinted fill, no glow ring.
:deep(.ant-input),
:deep(.ant-input-affix-wrapper) {
border-radius: 14px;
border: 1.5px solid @hairline;
background: @canvasSoft;
border: 1px solid @hairline;
background: @canvas;
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;
transition: border-color 0.18s ease;
}
:deep(.ant-input) {
@@ -386,9 +411,10 @@ async function onFinish() {
padding: 0 14px;
.ant-input {
min-height: 46px;
min-height: 48px;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
}
@@ -403,7 +429,7 @@ async function onFinish() {
:deep(.ant-input-prefix) {
margin-inline-end: 10px;
color: @inkFaint;
transition: color 0.2s ease;
transition: color 0.18s ease;
}
:deep(.ant-input::placeholder) {
@@ -418,29 +444,21 @@ async function onFinish() {
:deep(.ant-input:focus),
:deep(.ant-input-focused),
:deep(.ant-input-affix-wrapper-focused) {
border-color: @wine;
border-color: @wineSoft;
background: @canvas;
box-shadow: 0 0 0 4px alpha(@wine, 10%);
.ant-input-prefix {
color: @wine;
}
box-shadow: none;
}
:deep(.ant-input-affix-wrapper-focused .ant-input-prefix) {
color: @wine;
color: @wineSoft;
}
// Errors
// Errors: red edge only — like focus, no ring.
: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;
background: @canvas;
box-shadow: none !important;
}
:deep(.ant-form-item-has-error .ant-input-affix-wrapper .ant-input) {
@@ -481,14 +499,15 @@ async function onFinish() {
padding: 12px 14px;
border-radius: 14px;
background: @wineWash;
color: @wine;
color: @inkSoft;
font-size: 12px;
font-weight: 600;
font-weight: 500;
line-height: 1.8;
:deep(svg) {
flex: 0 0 auto;
margin-top: 2px;
color: @wine;
}
}
@@ -526,9 +545,9 @@ async function onFinish() {
font-family: inherit;
font-size: 14px;
font-weight: 800;
color: @onInk;
background: @ink;
box-shadow: 0 10px 22px -12px alpha(@ink, 70%);
color: #fff;
background: @wine;
box-shadow: 0 10px 22px -12px alpha(@wine, 70%);
transition: transform 0.15s ease, opacity 0.2s ease;
&:active {