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:
co-authored by
Claude Haiku 5.5
parent
5d2a1efb0c
commit
532ceee189
@@ -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
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
// The brand lives in the repo-root brand.ts, shared by all three frontends.
|
||||
export { brand } from '../../../brand';
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user