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
@@ -275,6 +275,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 {
@@ -460,6 +471,13 @@ export const api = {
*/
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

+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(
@@ -28,7 +28,6 @@
:key="slide.key"
class="showcase__slide"
:class="{ 'is-active': i === index }"
@click="onSlideClick(slide)"
>
<img v-if="slide.image" :src="slide.image" alt="" class="showcase__art" draggable="false" />
</article>
@@ -42,7 +41,7 @@
type="button"
class="showcase__dot"
:class="{ 'is-active': i === index }"
:aria-label="slide.title"
:aria-label="`اسلاید ${i + 1}`"
@click="go(i)"
/>
</div>
@@ -53,21 +52,13 @@
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 ShowcaseSlide {
key: string
eyebrow: string
title: string
subtitle: string
cta: string
image?: string
categoryId?: string
/** Admin-uploaded slide's own destination (manage/Const.vue), when it has
* one. Takes priority over `categoryId` in the click handler. */
link?: string
}
const props = defineProps<{ slides: ShowcaseSlide[] }>()
const emit = defineEmits<{ (e: 'select', slide: ShowcaseSlide): void }>()
const index = ref(0)
let timer: ReturnType<typeof setInterval> | undefined
@@ -159,13 +150,6 @@ function onPointerCancel(e: PointerEvent) {
isSwipe = false
}
function onSlideClick(slide: ShowcaseSlide) {
// A drag also fires a trailing click on release; only treat it as a tap
// (i.e. open the slide) when the gesture never turned into a swipe.
if (isSwipe) return
emit('select', slide)
}
const trackStyle = computed(() => {
const base = -index.value * 100
const dragPercent = trackWidth ? (dragX.value / trackWidth) * 100 : 0
@@ -232,7 +216,6 @@ onBeforeUnmount(() => {
flex: 0 0 100%;
width: 100%;
height: 100%;
cursor: pointer;
// Clips its own image's scale-in (.showcase__art below) to this slide's own
// box, so the 1.06x zoom never bleeds into the neighbouring slide.
overflow: hidden;
@@ -279,7 +279,7 @@
</template>
<template v-slot:title>
<div class="p-8 text-justify">
برای دسترسی به تمامی امکانات نرم افزار منتزر، ابتدا گزینه ورود/ثبت نام را لمس کنید و در صورت نداشتن حساب کاربری، ثبت نام نمایید.
برای دسترسی به تمامی امکانات نرم افزار {{ brand.name }}، ابتدا گزینه ورود/ثبت نام را لمس کنید و در صورت نداشتن حساب کاربری، ثبت نام نمایید.
</div>
</template>
<template v-slot:button>
@@ -291,6 +291,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'
+2 -2
View File
@@ -10,9 +10,9 @@ import { useSessionStore } from './stores/session';
import { useAppearanceStore } from './stores/appearanceStore';
import Container from './components/ui/Container.vue';
// TODO: temporary placeholder — swap Vector.svg back in (or a proper loading
// mark) once one exists; montzar-logo.png is the brand logo, borrowed here
// mark) once one exists; brand-logo.png is the brand logo, borrowed here
// just so lazy-loaded images have something other than a broken icon.
import Vector from './assets/img/montzar-logo.png';
import Vector from './assets/img/brand-logo.png';
// Global styles, ported from legacy henza_pwa/src/main.ts — same import order
// (ant-design-vue's own theme first, then our Less overrides on top). Legacy
+4 -3
View File
@@ -29,7 +29,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
@@ -54,13 +55,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,
},
]
+12 -65
View File
@@ -27,7 +27,7 @@
<span v-if="cart.items.length" class="home__badge">{{ cart.items.length }}</span>
</router-link>
<img src="/img/montzar-logo.png" alt="منتزر" class="home__logo" />
<img src="/img/brand-logo.png" :alt="brand.name" class="home__logo" />
<router-link :to="{ name: 'search' }" class="home__iconBtn" aria-label="جستجو">
<Icon icon="vuesax-linear:search-normal-1" size="21" />
@@ -48,7 +48,6 @@
:slides="slides"
class="home__hero reveal"
:style="revealDelay(1)"
@select="onSlide"
/>
<CategoryRail
@@ -62,7 +61,7 @@
<section v-if="featured" class="home__section reveal" :style="revealDelay(3)">
<SectionHead
title="برگزیده منتزر"
:title="`برگزیده ${brand.name}`"
subtitle="انتخاب‌شده از میان مجموعه‌های موجود"
action-label="همه محصولات"
@action="goSearch"
@@ -117,6 +116,7 @@
</template>
<script setup lang="ts">
import { brand } from '../brand'
import { computed, onActivated, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Loading, PullRefresh } from 'vant'
@@ -164,50 +164,15 @@ const featured = computed<ProductSummary | null>(() => products.value[0] ?? null
const gridProducts = computed(() => products.value.slice(1, 5))
const newestProducts = computed(() => products.value.slice(5, 13))
// The hero prefers the admin-uploaded banner (manage/Const.vue "اسلایدر
// موبایل") when one has been configured; otherwise it falls back to a hero
// built from REAL categories, since no curated banner payload existed before
// that admin screen was wired up. Each fallback slide deep-links into search
// filtered by that category, so the artwork is decorative but the
// destination is genuine.
const SLIDE_COPY = [
{ eyebrow: 'مجموعه منتزر', cta: 'دیدن مجموعه' },
{ eyebrow: 'انتخاب سردبیر', cta: 'مشاهده' },
{ eyebrow: 'پیشنهاد این هفته', cta: 'بررسی کنید' },
]
const slides = computed<ShowcaseSlide[]>(() => {
if (adminSlides.value?.length) {
return adminSlides.value
.filter((s) => s.image)
.map((s, i) => ({
key: `${i}-${s.image}`,
eyebrow: 'مجموعه منتزر',
title: '',
subtitle: '',
cta: '',
image: s.image,
link: s.link || undefined,
}))
}
const cats = memory.cats.slice(0, 3)
if (!cats.length) return []
return cats.map((cat, i) => ({
key: cat.id,
eyebrow: SLIDE_COPY[i % SLIDE_COPY.length].eyebrow,
title: cat.title,
subtitle: 'ساخته‌شده از طلای ۱۸ عیار، با اجرت شفاف و اصالت تضمین‌شده.',
cta: SLIDE_COPY[i % SLIDE_COPY.length].cta,
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<ShowcaseSlide[]>(() =>
(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.
@@ -220,24 +185,6 @@ function onCategory(cat: HomeCategory) {
router.push({ name: 'search' })
}
function onSlide(slide: ShowcaseSlide) {
// 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.
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 goSearch() {
router.push({ name: 'search' })
}
@@ -141,7 +141,7 @@
</template>
<template v-slot:title>
<div class="p-8 text-justify">
برای دسترسی به تمامی امکانات نرم افزار منتزر، ابتدا گزینه ورود/ثبت نام را لمس کنید و در
برای دسترسی به تمامی امکانات نرم افزار {{ brand.name }}، ابتدا گزینه ورود/ثبت نام را لمس کنید و در
صورت نداشتن حساب کاربری، ثبت نام نمایید.
</div>
</template>
@@ -154,6 +154,7 @@
</template>
<script setup lang="ts">
import { brand } from '../../brand'
import { computed, onActivated, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Loading, PullRefresh } from 'vant'
+3 -2
View File
@@ -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'
@@ -148,7 +149,7 @@ const sendPhone = async () => {
// very confusion the 404 was added to remove: they would still not
// know whether the SMS was late or the number was never on file.
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
+28 -6
View File
@@ -12,11 +12,9 @@
plain text input. That whole complex, and the parallel unused
`userFormModel`/`defaultUserFormModel` objects, are not ported.
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.
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="auth">
@@ -97,6 +95,8 @@ 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'
import { api } from '../../api/client'
import { toast } from '../../composables/useToast'
const emit = defineEmits<{ (e: 'register', value: boolean): void }>()
const router = useRouter()
@@ -117,12 +117,34 @@ const formModel = reactive({
const register = async () => {
try {
profileData.loading = true
// Stubbed — no create-customer endpoint exists in apps/api yet.
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 ?? ''),
})
registeredInfo.value = true
} catch (e: unknown) {
toast.error(serverMessage(e) ?? 'خطا در ثبت اطلاعات. لطفاً دوباره تلاش کنید.')
} finally {
profileData.loading = 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">