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
+10
View File
@@ -162,3 +162,13 @@ PROFILE_ADMIN_MOBILES=
# Api/ApiMobileController::setOrder:740-743). Blank means nobody is notified —
# the order, the customer's SMS and the sales expert's SMS still go through.
ORDER_ADMIN_MOBILES=
# ---------------------------------------------------------------------------
# Brand
# ---------------------------------------------------------------------------
# The name at the top of every SMS («زرتاج\n…») and the site link in the
# welcome SMS. Blank = the defaults in apps/api/src/common/brand.ts. The
# frontends take their brand from the repo-root brand.ts instead; the full
# rebrand checklist is docs/BRANDING.md.
BRAND_NAME=
BRAND_SITE_URL=
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/png" href="/favicon.png" />
<title>منتزر — پنل مدیریت</title>
<title>%BRAND_TITLE%</title>
</head>
<body>
<div id="app">
+2 -1
View File
@@ -37,6 +37,7 @@ import { onMounted, onUnmounted, ref, watch } from 'vue';
import { notification } from 'ant-design-vue';
import faIR from 'ant-design-vue/es/locale/fa_IR';
import themeConfig from './themeConfig';
import { brand } from './brand';
import { useLayoutStore } from './stores/layout';
import AppUpdateGate from './components/common/AppUpdateGate.vue';
import { userName } from './utils';
@@ -96,7 +97,7 @@ onMounted(() => {
notification.open({
message: `${user && user.name ? user.name : ' کاربر '} عزیز `,
placement: 'topLeft',
description: 'به منتزر خوش آمدید',
description: `به ${brand.name} خوش آمدید`,
style: {
color: 'var(--primary-color)',
marginLeft: `20px`,

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

@@ -29,7 +29,7 @@
// ant-design-pro boilerplate in Vue's own palette, inherited verbatim from
// legacy and used as `@border-color-base`. That file is deleted (ADV4 has no
// LESS build to feed); nothing in the app should reference `--primary`/
// `--accent` any more — use the Montzar tokens below instead.
// `--accent` any more — use the brand tokens below instead.
:root {
--primary-color: #1c2d56;
--secondary-color: #836447;
+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';
@@ -4,50 +4,86 @@
linked to, and both balances side by side (the customer sees the
accounting one; the credit limit is checked against the app's own).
-->
<a-card v-if="visible" size="small" title="حسابداری" class="mt-4">
<section v-if="visible" class="acc-card" :style="themeVars">
<header class="acc-head">
<h2>حسابداری</h2>
<a-tag v-if="info?.link" color="success">لینک شده</a-tag>
<a-tag v-else color="warning">لینک نشده</a-tag>
</header>
<a-spin :spinning="loading">
<a-descriptions bordered size="small" :column="3">
<a-descriptions-item label="کد حساب">
<div class="acc-body">
<div class="acc-code">
<span class="label">کد حساب</span>
<template v-if="info?.link">
{{ info.link.externalCode }}
<a-tag class="ms-2">{{ ORIGIN[info.link.origin] ?? info.link.origin }}</a-tag>
</template>
<span v-else class="muted">لینک نشده</span>
</a-descriptions-item>
<a-descriptions-item label="مانده‌ی حسابداری (گرم / ریال)">
<template v-if="info?.accounting">
<span :class="sign(info.accounting.gold)">{{ num(info.accounting.gold, 3) }}</span> /
<span :class="sign(info.accounting.rial)">{{ num(info.accounting.rial, 0) }}</span>
<div class="muted small">{{ jdt(info.accounting.at) }}</div>
<span class="code" dir="ltr">{{ info.link.externalCode }}</span>
<span class="origin">{{ ORIGIN[info.link.origin] ?? info.link.origin }}</span>
</template>
<span v-else class="muted">—</span>
</a-descriptions-item>
<a-descriptions-item label="مانده‌ی دفتر اپ (گرم / ریال)">
<span :class="sign(info?.internal?.gold)">{{ num(info?.internal?.gold, 3) }}</span> /
<span :class="sign(info?.internal?.rial)">{{ num(info?.internal?.rial, 0) }}</span>
<div class="muted small">مبنای سقف اعتبار</div>
</a-descriptions-item>
</a-descriptions>
</div>
<div v-if="lastJob" class="muted small mt-2">
آخرین ارسال: {{ STATUS[lastJob.status] ?? lastJob.status }} — {{ jdt(lastJob.updatedAt) }}
<span v-if="lastJob.lastError" class="warn"> — {{ lastJob.lastError }}</span>
<div class="balances">
<div class="balance">
<div class="label">مانده‌ی حسابداری</div>
<template v-if="info?.accounting">
<div class="line">
<span :class="sign(info.accounting.gold)">{{ num(info.accounting.gold, 3) }}</span>
<small>گرم</small>
</div>
<div class="line">
<span :class="sign(info.accounting.rial)">{{ num(info.accounting.rial, 0) }}</span>
<small>ریال</small>
</div>
<div class="hint">{{ jdt(info.accounting.at) }}</div>
</template>
<template v-else>
<div class="line muted">—</div>
<div class="hint">هنوز دریافت نشده</div>
</template>
</div>
<div class="balance">
<div class="label">مانده‌ی دفتر اپ</div>
<div class="line">
<span :class="sign(info?.internal?.gold)">{{ num(info?.internal?.gold, 3) }}</span>
<small>گرم</small>
</div>
<div class="line">
<span :class="sign(info?.internal?.rial)">{{ num(info?.internal?.rial, 0) }}</span>
<small>ریال</small>
</div>
<div class="hint">مبنای سقف اعتبار</div>
</div>
</div>
<div v-if="lastJob" class="last-job">
<a-badge :status="JOB_BADGE[lastJob.status] ?? 'default'" />
آخرین ارسال: {{ STATUS[lastJob.status] ?? lastJob.status }} — {{ jdt(lastJob.updatedAt) }}
<div v-if="lastJob.lastError" class="warn">{{ lastJob.lastError }}</div>
</div>
<div class="link-form">
<div class="label">{{ info?.link ? 'تغییر لینک' : 'لینک دستی' }}</div>
<div class="link-row">
<a-input
v-model:value="code"
dir="ltr"
placeholder="کد حساب در حسابداری"
@press-enter="code.trim() && link()"
/>
<a-button type="primary" ghost :disabled="!code.trim()" @click="link">لینک</a-button>
</div>
<a-popconfirm v-if="info?.link" title="لینک حذف شود؟" @confirm="unlink">
<a-button type="link" danger size="small" class="unlink">حذف لینک فعلی</a-button>
</a-popconfirm>
</div>
</div>
<a-space class="mt-2">
<a-input v-model:value="code" dir="ltr" placeholder="کد حساب در حسابداری" style="width: 200px" />
<a-button size="small" :disabled="!code.trim()" @click="link">لینک دستی</a-button>
<a-popconfirm v-if="info?.link" title="لینک حذف شود؟" @confirm="unlink">
<a-button size="small" danger>حذف لینک</a-button>
</a-popconfirm>
</a-space>
</a-spin>
</a-card>
</section>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { message } from 'ant-design-vue';
import { message, theme } from 'ant-design-vue';
import moment from 'moment-jalaali';
import api from '../../api/client';
import { useUsersStore } from '../../stores/users';
@@ -68,12 +104,32 @@ const STATUS: Record<string, string> = {
NEEDS_REVIEW: 'نیاز به بررسی',
RESOLVED: 'حل‌شده',
};
const JOB_BADGE: Record<string, 'success' | 'processing' | 'error' | 'warning' | 'default'> = {
PENDING: 'default',
SENDING: 'processing',
SENT: 'success',
FAILED: 'error',
NEEDS_REVIEW: 'warning',
RESOLVED: 'success',
};
const users = useUsersStore();
const { token } = theme.useToken();
const info = ref<any>(null);
const loading = ref(false);
const code = ref('');
const themeVars = computed(() => ({
'--acc-bg': token.value.colorBgContainer,
'--acc-border': token.value.colorBorderSecondary,
'--acc-fill': token.value.colorFillQuaternary,
'--acc-text': token.value.colorText,
'--acc-text-2': token.value.colorTextSecondary,
'--acc-text-3': token.value.colorTextTertiary,
'--acc-error': token.value.colorError,
'--acc-success': token.value.colorSuccess,
}));
const visible = computed(() => Boolean(users.hasPermission('config.accounting')) && info.value && info.value.provider !== 'none');
const lastJob = computed(() => info.value?.jobs?.[0]);
@@ -115,28 +171,116 @@ onMounted(load);
</script>
<style scoped>
.mt-4 {
margin-top: 1rem;
.acc-card {
background: var(--acc-bg);
border: 1px solid var(--acc-border);
border-radius: 12px;
color: var(--acc-text);
}
.mt-2 {
margin-top: 0.5rem;
.acc-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
border-bottom: 1px solid var(--acc-border);
}
.ms-2 {
margin-inline-start: 0.5rem;
.acc-head h2 {
margin: 0;
font-size: 14px;
font-weight: 700;
}
.muted {
color: #888;
.acc-head :deep(.ant-tag) {
margin: 0;
}
.small {
.acc-body {
display: flex;
flex-direction: column;
gap: 14px;
padding: 14px 18px 16px;
}
.label {
font-size: 13px;
color: var(--acc-text-2);
}
.acc-code {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 4px 10px;
}
.acc-code .label {
margin-inline-end: auto;
}
.code {
font-weight: 700;
letter-spacing: 0.5px;
}
.origin {
font-size: 12px;
color: var(--acc-text-3);
}
.balances {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.balance {
padding: 10px 12px;
border-radius: 10px;
background: var(--acc-fill);
}
.balance .label {
margin-bottom: 4px;
font-size: 12px;
}
.line {
font-size: 15px;
font-weight: 700;
font-variant-numeric: tabular-nums;
line-height: 1.7;
}
.line small {
margin-inline-start: 4px;
font-size: 11px;
font-weight: 500;
color: var(--acc-text-3);
}
.hint {
margin-top: 2px;
font-size: 11px;
color: var(--acc-text-3);
}
.last-job {
font-size: 12px;
color: var(--acc-text-2);
}
.link-form {
padding-top: 12px;
border-top: 1px dashed var(--acc-border);
}
.link-form .label {
margin-bottom: 6px;
font-size: 12px;
}
.link-row {
display: flex;
gap: 8px;
}
.unlink {
margin-top: 4px;
padding-inline: 0;
}
.muted {
color: var(--acc-text-3);
}
.warn {
color: #d4380d;
margin-top: 2px;
color: var(--acc-error);
}
.debt {
color: #cf1322;
color: var(--acc-error);
}
.credit {
color: #389e0d;
color: var(--acc-success);
}
</style>
@@ -293,7 +293,10 @@ const opOptions = [
{ value: 'between', label: 'بین' },
];
const cols = computed(() => props.model?.cols ?? []);
// The model is a plain class instance (see model.ts), so its columns are not
// reactive on their own. Wrapping it here lets the column picker's `col.show`
// checkbox toggles re-run visibleCols / antColumns.
const cols = computed<Xtc[]>(() => (props.model ? (reactive(props.model) as XTbl).cols : []));
const visibleCols = computed(() => cols.value.filter((c) => c.show));
const filterableOptions = computed(() =>
cols.value.filter((c) => c.filtered).map((c) => ({ value: c.__fkey ?? c.key, label: c.title })),
@@ -42,6 +42,9 @@ export class Xtc {
this.key = key;
this.title = title;
this.dataIndex = key;
// The record id ('کد') is kept out of every table by default. It can still be
// turned on from the column picker.
if (key === 'id') this.show = false;
}
private __filter() {
@@ -19,7 +19,7 @@
>
<div class="nav-header" :class="{ 'is-collapsed': collapsed }">
<span class="nav-logo">
<img src="../../assets/images/logo/montzar-icon.png" alt="" />
<img src="../../assets/images/logo/brand-icon.png" alt="" />
</span>
<span v-if="!collapsed" class="nav-brand">
<span class="nav-title">{{ title }}</span>
@@ -66,9 +66,10 @@ import { getMenu, type NavMenuNode as NavMenuNodeData } from '../navMenuItems';
import { useUsersStore } from '../../stores/users';
import { resolveIcon } from '../../components/icons';
import NavMenuNode from './NavMenuNode.vue';
import { brand } from '../../brand';
withDefaults(defineProps<{ title?: string; collapsed?: boolean }>(), {
title: 'منتزر',
title: brand.name,
collapsed: false,
});
+6
View File
@@ -150,6 +150,12 @@ router.beforeEach(async (to) => {
const token = localStorage.getItem(tokenName);
if (token) {
const roles = await getPermission();
// The stored token could not be confirmed (API down, still booting, or any
// non-401 failure). Letting the route through here showed the whole panel to
// whoever held a stale token, so fail closed and send them to sign in.
if (!roles) {
return { path: '/auth/login' };
}
if (to.meta?.superAdminOnly && !useUsersStore().isSuperAdmin) {
return { name: 'page-not-authorized' };
}
+5 -4
View File
@@ -20,9 +20,9 @@
<section class="hero" aria-hidden="true">
<div class="brand-row">
<div class="logo-chip">
<img src="../../assets/images/logo/montzar-icon.png" alt="" />
<img src="../../assets/images/logo/brand-icon.png" alt="" />
</div>
<span class="brand-name">Montzar</span>
<span class="brand-name">{{ brand.nameEn }}</span>
</div>
<h2 class="hero-title">
@@ -83,7 +83,7 @@
<div class="glass-card">
<div class="card-head">
<div class="logo-chip logo-chip--sm">
<img src="../../assets/images/logo/montzar-icon.png" alt="Montzar" />
<img src="../../assets/images/logo/brand-icon.png" :alt="brand.nameEn" />
</div>
<span class="eyebrow"><span class="dot"></span>پنل مدیریت</span>
</div>
@@ -146,12 +146,13 @@
</section>
</main>
<footer class="page-footer">© Montzar</footer>
<footer class="page-footer">© {{ brand.nameEn }}</footer>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { brand } from '../../brand';
import { useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import {
+1 -1
View File
@@ -9,7 +9,7 @@
<div class="flex gap-6 w-full justify-center h-screen branch-login-page">
<div class="flex flex-col h-screen items-center justify-center">
<img
src="../../assets/images/logo/montzar-icon.png"
src="../../assets/images/logo/brand-icon.png"
width="150"
alt="login"
class="mx-auto mb-5"
@@ -121,7 +121,7 @@
<ul>
<li class="comment-item">
<img src="../../../assets/images/logo/montzar-icon.png" alt="comment author" class="img border" />
<img src="../../../assets/images/logo/brand-icon.png" alt="comment author" class="img border" />
<div class="comment-body">
<h5>ادمین</h5>
<span v-if="tempRow.reply && tempRow.reply[0]" class="time">
@@ -166,6 +166,7 @@ import TooltipBtn from '../../../components/tooltip-btn/TooltipBtn.vue';
import { buildCommentListModel } from './CommentListModel';
import { useUsersStore } from '../../../stores/users';
import api from '../../../api/client';
import { brand } from '../../../brand';
const users = useUsersStore();
const hasPermission = (rid: string) => Boolean(users.hasPermission(rid));
@@ -185,12 +186,12 @@ const showChangeReplyBtn = computed(
/**
* legacy built a storefront link from the product title:
* https://bnk.montzar.ir/product/<title-with-dashes>/<id>
* The host is kept because it points at the live public site, not the API.
* <site>/product/<title-with-dashes>/<id>
* The host is the live public site (brand.siteUrl), not the API.
*/
function productLink(record: Record<string, any>) {
const slug = String(record.product?.title ?? '').split(' ').join('-');
return `https://bnk.montzar.ir/product/${slug}/${record.product?.id}`;
return `${brand.siteUrl}/product/${slug}/${record.product?.id}`;
}
async function changeSts(id: string, sts: number) {
@@ -3,123 +3,279 @@
Shared implementation of legacy ViewOmdeCustomer.vue (376 lines) and
ViewSingleCustomer.vue (425 lines) — the customer detail screens.
Both show the same four panels (profile, orders, financial documents, wallet)
Both show the same panels (profile, orders, financial documents, wallet)
against the same customer record; legacy duplicated the whole page per
customer kind. The differing bits are parameterised:
- the wholesale screen adds an activity-log panel (legacy log/ago/{id})
- the retail screen adds a wallet panel (legacy single/wallet/{id})
Layout: profile header → financial KPI tiles → main column (record tabs)
beside a side column (profile details, accounting).
-->
<a-spin :spinning="loading">
<a-card :title="title" class="page-card">
<a-descriptions bordered size="small" :column="{ xxl: 3, xl: 3, lg: 3, md: 2, sm: 2, xs: 1 }">
<a-descriptions-item label="نام">{{ customer?.name ?? '-' }}</a-descriptions-item>
<a-descriptions-item label="موبایل">{{ customer?.mobile ?? '-' }}</a-descriptions-item>
<a-descriptions-item label="کد ملی">
{{ customer?.national_code ?? '-' }}
</a-descriptions-item>
<a-descriptions-item label="شهر">{{ customer?.city?.title ?? '-' }}</a-descriptions-item>
<a-descriptions-item label="کارشناس فروش">
{{ customer?.reagent?.name ?? '-' }}
</a-descriptions-item>
<a-descriptions-item label="وضعیت">
<a-tag :color="customer?.active === 1 ? 'green' : 'red'">
{{ customer?.active === 1 ? 'فعال' : 'غیر فعال' }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="سطح اعتبار">
{{ customer?.gram_level ?? '-' }} گرم
</a-descriptions-item>
<a-descriptions-item label="بدهی گرمی">{{ customer?.remain ?? '-' }}</a-descriptions-item>
<a-descriptions-item label="بدهی ریالی">
{{ customer?.rial_remain ?? '-' }}
</a-descriptions-item>
<!-- legacy ViewOmdeCustomer.vue:28-30 -->
<a-descriptions-item label="میزان فروش">{{ dashboard?.grams_sell ?? '-' }}</a-descriptions-item>
</a-descriptions>
<div v-if="dashboard" class="stats mt-4">
<a-tag color="blue">کل سفارش‌ها: {{ dashboard.total_order }}</a-tag>
<a-tag color="orange">سفارش باز: {{ dashboard.open_order }}</a-tag>
<a-tag color="green">اسناد: {{ dashboard.ledger_entries }}</a-tag>
</div>
</a-card>
<!-- D27 — link and balance in the accounting software. -->
<CustomerAccountingCard :customer-id="id" />
<a-tabs class="mt-4 detail-tabs" :default-active-key="defaultTab">
<a-tab-pane key="orders" tab="سفارش ها">
<a-table
size="small"
:data-source="orders"
:columns="orderColumns"
:pagination="{ pageSize: 10 }"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'caj'">{{ jdate(record.caj) }}</template>
</template>
</a-table>
</a-tab-pane>
<a-tab-pane key="documents" tab="سند های مالی">
<a-table
size="small"
:data-source="documents"
:columns="documentColumns"
:pagination="{ pageSize: 10 }"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'confirmed'">
<a-tag :color="record.confirmed === 1 ? 'green' : ''">
{{ record.confirmed === 1 ? 'تایید شده' : 'تایید نشده' }}
<div class="customer-detail" :style="themeVars">
<!-- Profile header -->
<section class="panel hero">
<div class="hero-main">
<a-avatar :size="64" :src="customer?.image?.image" class="hero-avatar">
{{ initials }}
</a-avatar>
<div class="hero-text">
<div class="hero-eyebrow">{{ title }}</div>
<h1 class="hero-name">{{ customer?.name || '—' }}</h1>
<div class="hero-tags">
<a-tag v-if="customer" :color="customer.active === 1 ? 'success' : 'error'">
{{ customer.active === 1 ? 'فعال' : 'غیرفعال' }}
</a-tag>
</template>
<template v-else-if="column.dataIndex === 'caj'">{{ jdate(record.caj) }}</template>
</template>
</a-table>
</a-tab-pane>
<a-tag v-if="customer" :color="customer.sts === 1 ? 'processing' : 'warning'">
{{ customer.sts === 1 ? 'مجاز به سفارش' : 'عدم سفارش' }}
</a-tag>
<a-tag v-if="customer?.type_str">{{ customer.type_str }}</a-tag>
<a-tag v-if="customer?.is_archive === 1" color="default">بایگانی‌شده</a-tag>
</div>
<div class="hero-meta">
<span v-if="customer?.mobile" class="meta-item">
<PhoneOutlined />
<span dir="ltr">{{ customer.mobile }}</span>
</span>
<span v-if="customer?.city?.title" class="meta-item">
<EnvironmentOutlined />
{{ customer.city.title }}
</span>
<span v-if="customer?.reagent?.name" class="meta-item">
<UserOutlined />
کارشناس فروش: {{ customer.reagent.name }}
</span>
<span v-if="customer?.caj" class="meta-item">
<CalendarOutlined />
عضویت از {{ jdate(customer.caj) }}
</span>
</div>
</div>
</div>
<div class="hero-actions">
<a-button @click="router.back()">
<template #icon><ArrowRightOutlined /></template>
بازگشت
</a-button>
<a-button v-if="canEdit" type="primary" @click="router.push({ name: editRoute, params: { id } })">
<template #icon><EditOutlined /></template>
ویرایش مشتری
</a-button>
</div>
</section>
<a-tab-pane v-if="showWallet" key="wallet" tab="کیف پول">
<a-table
size="small"
:data-source="wallet"
:columns="walletColumns"
:pagination="false"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'caj'">{{ jdate(record.caj) }}</template>
<template v-else-if="column.dataIndex === 'expires_at'">
{{ jdate(record.expires_at) }}
</template>
<!-- Financial KPIs -->
<section class="kpis">
<div class="panel kpi">
<div class="kpi-label">بدهی طلایی</div>
<div class="kpi-value" :class="sign(customer?.remain)">
{{ num(customer?.remain, 3) }}<small>گرم</small>
</div>
<div class="kpi-foot">{{ balanceWord(customer?.remain) }}</div>
</div>
<div class="panel kpi">
<div class="kpi-label">بدهی ریالی</div>
<div class="kpi-value" :class="sign(customer?.rial_remain)">
{{ num(customer?.rial_remain, 0) }}<small>ریال</small>
</div>
<div class="kpi-foot">{{ balanceWord(customer?.rial_remain) }}</div>
</div>
<div class="panel kpi">
<div class="kpi-label">سقف اعتبار</div>
<div class="kpi-value">{{ num(customer?.gram_level, 3) }}<small>گرم</small></div>
<template v-if="creditUsage !== null">
<a-progress
:percent="creditUsage"
:stroke-color="creditColor"
:show-info="false"
size="small"
class="kpi-progress"
/>
<div class="kpi-foot">{{ num(creditUsage, 0) }}٪ از سقف استفاده شده</div>
</template>
</a-table>
</a-tab-pane>
<div v-else class="kpi-foot">سقفی تعیین نشده</div>
</div>
<div class="panel kpi">
<div class="kpi-label">میزان فروش</div>
<div class="kpi-value">{{ num(dashboard?.grams_sell, 3) }}<small>گرم</small></div>
<div class="kpi-foot">مجموع وزن سفارش‌ها</div>
</div>
<div class="panel kpi">
<div class="kpi-label">سفارش‌ها</div>
<div class="kpi-value">{{ num(dashboard?.total_order ?? orders.length, 0) }}</div>
<div class="kpi-foot">
<span :class="{ 'text-warning': Number(dashboard?.open_order) > 0 }">
{{ num(dashboard?.open_order, 0) }} باز
</span>
· {{ num(dashboard?.ledger_entries ?? documents.length, 0) }} سند
</div>
</div>
</section>
<a-tab-pane v-if="showActivity" key="activity" tab="فعالیت ها">
<a-table
size="small"
:data-source="activity"
:columns="activityColumns"
:pagination="{ pageSize: 15 }"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'caj'">{{ jdateTime(record.caj) }}</template>
</template>
</a-table>
</a-tab-pane>
</a-tabs>
<div class="layout">
<!-- Main column: the customer's records -->
<section class="panel layout-main">
<a-tabs v-model:active-key="activeTab" class="detail-tabs">
<a-tab-pane key="orders">
<template #tab>
سفارش‌ها <span class="tab-count">{{ num(orders.length, 0) }}</span>
</template>
<a-table
size="middle"
:data-source="orders"
:columns="orderColumns"
:pagination="pagination(10, orders.length)"
:locale="{ emptyText: 'سفارشی برای این مشتری ثبت نشده است' }"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'name'">
<span class="mono" dir="ltr">{{ record.name }}</span>
</template>
<template v-else-if="column.dataIndex === 'w'">
{{ num(record.w, 3) }} <span class="unit">گرم</span>
</template>
<template v-else-if="column.dataIndex === 'status'">
<a-tag :color="ORDER_STATUS[record.status]?.color">
{{ ORDER_STATUS[record.status]?.label ?? record.status ?? '—' }}
</a-tag>
</template>
<template v-else-if="column.dataIndex === 'branch'">{{ record.branch ?? '—' }}</template>
<template v-else-if="column.dataIndex === 'caj'">{{ jdate(record.caj) }}</template>
</template>
</a-table>
</a-tab-pane>
<a-tab-pane key="documents">
<template #tab>
سندهای مالی <span class="tab-count">{{ num(documents.length, 0) }}</span>
</template>
<a-table
size="middle"
:data-source="documents"
:columns="documentColumns"
:pagination="pagination(10, documents.length)"
:locale="{ emptyText: 'سندی برای این مشتری ثبت نشده است' }"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'type'">
{{ SOURCE_TYPE[record.type] ?? record.type }}
</template>
<template v-else-if="column.dataIndex === 'value'">
<span :class="record.direction === 'DEBIT' ? 'debt' : record.direction === 'CREDIT' ? 'credit' : ''">
{{ record.direction === 'CREDIT' ? '−' : record.direction === 'DEBIT' ? '+' : '' }}{{
num(record.value, record.currency === 'RIAL' ? 0 : 3)
}}
</span>
<span class="unit">{{ record.currency === 'RIAL' ? 'ریال' : 'گرم' }}</span>
</template>
<template v-else-if="column.dataIndex === 'confirmed'">
<a-badge
:status="record.confirmed === 1 ? 'success' : 'default'"
:text="record.confirmed === 1 ? 'تایید شده' : 'تایید نشده'"
/>
</template>
<template v-else-if="column.dataIndex === 'note'">
<span class="note">{{ record.note || '—' }}</span>
</template>
<template v-else-if="column.dataIndex === 'caj'">{{ jdate(record.caj) }}</template>
</template>
</a-table>
</a-tab-pane>
<a-tab-pane v-if="showWallet" key="wallet">
<template #tab>
کیف پول <span class="tab-count">{{ num(wallet.length, 0) }}</span>
</template>
<a-table
size="middle"
:data-source="wallet"
:columns="walletColumns"
:pagination="pagination(10, wallet.length)"
:locale="{ emptyText: 'اعتباری در کیف پول ثبت نشده است' }"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'value'">
{{ num(record.value, 3) }} <span class="unit">گرم</span>
</template>
<template v-else-if="column.dataIndex === 'caj'">{{ jdate(record.caj) }}</template>
<template v-else-if="column.dataIndex === 'expires_at'">
{{ record.expires_at ? jdate(record.expires_at) : '—' }}
</template>
</template>
</a-table>
</a-tab-pane>
<a-tab-pane v-if="showActivity" key="activity">
<template #tab>
فعالیت‌ها <span class="tab-count">{{ num(activity.length, 0) }}</span>
</template>
<a-table
size="middle"
:data-source="activity"
:columns="activityColumns"
:pagination="pagination(15, activity.length)"
:locale="{ emptyText: 'فعالیتی ثبت نشده است' }"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'caj'">
<span dir="ltr">{{ jdateTime(record.caj) }}</span>
</template>
</template>
</a-table>
</a-tab-pane>
</a-tabs>
</section>
<!-- Side column: who the customer is -->
<aside class="layout-side">
<section class="panel">
<h2 class="panel-title">اطلاعات هویتی و تماس</h2>
<dl class="facts">
<div v-for="f in identityFacts" :key="f.label" class="fact">
<dt>{{ f.label }}</dt>
<dd :dir="f.ltr ? 'ltr' : undefined">{{ f.value || '—' }}</dd>
</div>
</dl>
</section>
<section v-if="businessFacts.length" class="panel">
<h2 class="panel-title">اطلاعات کسب‌وکار</h2>
<dl class="facts">
<div v-for="f in businessFacts" :key="f.label" class="fact">
<dt>{{ f.label }}</dt>
<dd>{{ f.value }}</dd>
</div>
</dl>
</section>
<!-- D27 — link and balance in the accounting software. -->
<CustomerAccountingCard :customer-id="id" />
</aside>
</div>
</div>
</a-spin>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import { computed, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { theme } from 'ant-design-vue';
import {
ArrowRightOutlined,
CalendarOutlined,
EditOutlined,
EnvironmentOutlined,
PhoneOutlined,
UserOutlined,
} from '@ant-design/icons-vue';
import { jdate, jdateTime } from '../../utils/filters';
import api from '../../api/client';
import { useUsersStore } from '../../stores/users';
import CustomerAccountingCard from '../../components/accounting/CustomerAccountingCard.vue';
const props = withDefaults(
@@ -130,14 +286,26 @@ const props = withDefaults(
/** The wholesale screen shows the activity log. */
showActivity?: boolean;
defaultTab?: string;
/** Named route of this customer kind's edit form; the button hides if absent or not permitted. */
editRoute?: string;
}>(),
{ title: 'مشاهده اطلاعات مشتری', showWallet: false, showActivity: false, defaultTab: 'orders' },
{
title: 'مشاهده اطلاعات مشتری',
showWallet: false,
showActivity: false,
defaultTab: 'orders',
editRoute: undefined,
},
);
const route = useRoute();
const router = useRouter();
const users = useUsersStore();
const { token } = theme.useToken();
const id = route.params.id as string;
const loading = ref(false);
const activeTab = ref(props.defaultTab);
const customer = ref<Record<string, any> | undefined>(undefined);
const dashboard = ref<Record<string, any> | undefined>(undefined);
const orders = ref<Record<string, any>[]>([]);
@@ -145,10 +313,50 @@ const documents = ref<Record<string, any>[]>([]);
const wallet = ref<Record<string, any>[]>([]);
const activity = ref<Record<string, any>[]>([]);
// Colours come from the antd theme so the page follows the panel's palette.
const themeVars = computed(() => ({
'--cd-bg': token.value.colorBgContainer,
'--cd-border': token.value.colorBorderSecondary,
'--cd-text': token.value.colorText,
'--cd-text-2': token.value.colorTextSecondary,
'--cd-text-3': token.value.colorTextTertiary,
'--cd-fill': token.value.colorFillQuaternary,
'--cd-primary': token.value.colorPrimary,
'--cd-primary-bg': token.value.colorPrimaryBg,
'--cd-error': token.value.colorError,
'--cd-success': token.value.colorSuccess,
'--cd-warning': token.value.colorWarning,
}));
// OrderStatus enum (schema.prisma); labels as on the accounting settings page.
const ORDER_STATUS: Record<string, { label: string; color?: string }> = {
SUSPENDED: { label: 'معلق (عبور از سقف)', color: 'error' },
PLACED: { label: 'ثبت سفارش', color: 'default' },
ADMIN_APPROVED: { label: 'تأیید مدیر', color: 'processing' },
SOURCING: { label: 'در حال تأمین', color: 'processing' },
PREPARING: { label: 'در حال آماده‌سازی', color: 'processing' },
DELIVERED_TO_AGENT: { label: 'تحویل مسئول فروش', color: 'cyan' },
DELIVERED_TO_CUSTOMER: { label: 'تحویل مشتری', color: 'success' },
CANCELLED: { label: 'لغو شده', color: 'default' },
};
// LedgerSourceType enum (schema.prisma).
const SOURCE_TYPE: Record<string, string> = {
ORDER_STOCK: 'سفارش کالای موجود',
ORDER_PRE_ORDER: 'پیش‌سفارش',
REPAYMENT_MELTED_GOLD: 'تسویه با آبشده',
REPAYMENT_TRANSFER: 'تسویه با واریز',
REPAYMENT_CHEQUE: 'تسویه با چک',
REPAYMENT_CASH: 'تسویه نقدی',
WALLET_BONUS: 'پاداش کیف پول',
ADJUSTMENT: 'اصلاحیه',
};
const orderColumns = [
{ title: 'شماره سفارش', dataIndex: 'name' },
{ title: 'وضعیت', dataIndex: 'status' },
{ title: 'وزن', dataIndex: 'w' },
{ title: 'تعداد آیتم', dataIndex: 'items_count' },
{ title: 'تعداد آیتم', dataIndex: 'items_count', align: 'center' as const },
{ title: 'شعبه', dataIndex: 'branch' },
{ title: 'تاریخ', dataIndex: 'caj' },
];
@@ -157,12 +365,12 @@ const documentColumns = [
{ title: 'نوع', dataIndex: 'type' },
{ title: 'مقدار', dataIndex: 'value' },
{ title: 'وضعیت', dataIndex: 'confirmed' },
{ title: 'توضیحات', dataIndex: 'note' },
{ title: 'توضیحات', dataIndex: 'note', ellipsis: true },
{ title: 'تاریخ', dataIndex: 'caj' },
];
const walletColumns = [
{ title: 'مقدار (گرم)', dataIndex: 'value' },
{ title: 'مقدار', dataIndex: 'value' },
{ title: 'تاریخ اعطا', dataIndex: 'caj' },
{ title: 'انقضا', dataIndex: 'expires_at' },
];
@@ -173,6 +381,65 @@ const activityColumns = [
{ title: 'تاریخ', dataIndex: 'caj' },
];
const num = (v: unknown, places: number) =>
v === null || v === undefined || v === '' || Number.isNaN(Number(v))
? '—'
: Number(v).toLocaleString('fa-IR', { maximumFractionDigits: places });
// Positive balance = the customer owes (same convention as the accounting card).
const sign = (v: unknown) => (Number(v) > 0 ? 'debt' : Number(v) < 0 ? 'credit' : '');
const balanceWord = (v: unknown) =>
v === null || v === undefined ? '' : Number(v) > 0 ? 'بدهکار' : Number(v) < 0 ? 'بستانکار' : 'تسویه';
const pagination = (pageSize: number, total: number) =>
total > pageSize ? { pageSize, size: 'small' as const, showSizeChanger: false } : false;
const initials = computed(() => (customer.value?.name ?? '').trim().charAt(0) || '؟');
const creditUsage = computed<number | null>(() => {
const limit = Number(customer.value?.gram_level);
if (!limit || limit <= 0) return null;
const used = Math.max(Number(customer.value?.remain) || 0, 0);
return Math.min(Math.round((used / limit) * 100), 100);
});
const creditColor = computed(() => {
const u = creditUsage.value ?? 0;
return u >= 90 ? token.value.colorError : u >= 70 ? token.value.colorWarning : token.value.colorPrimary;
});
const canEdit = computed(() => {
if (!props.editRoute || !router.hasRoute(props.editRoute)) return false;
const meta = router.resolve({ name: props.editRoute, params: { id } }).meta as Record<string, any>;
if (meta.superAdminOnly && !users.isSuperAdmin) return false;
return !meta.role || Boolean(users.hasPermission(meta.role));
});
const identityFacts = computed(() => {
const c = customer.value;
return [
{ label: 'موبایل', value: c?.mobile, ltr: true },
{ label: 'تلفن ثابت', value: c?.phone, ltr: true },
{ label: 'کد ملی', value: c?.national_code, ltr: true },
{ label: 'تاریخ تولد', value: c?.birth_date ? jdate(c.birth_date) : null },
{ label: 'شهر', value: c?.city?.title },
{ label: 'آدرس', value: c?.address },
];
});
// Only the fields this customer actually has — retail customers usually have none.
const businessFacts = computed(() => {
const c = customer.value;
return [
{ label: 'نام فروشگاه', value: c?.store_name },
{ label: 'پاساژ', value: c?.pasazh_name },
{ label: 'منطقه', value: c?.zone },
{ label: 'کد عضویت', value: c?.mcode },
{ label: 'کارشناس فروش', value: c?.reagent?.name },
{
label: 'درصد اجرت',
value: c?.percent !== null && c?.percent !== undefined && c?.percent !== '' ? `${num(c.percent, 2)}٪` : null,
},
].filter((f) => f.value);
});
onMounted(async () => {
try {
loading.value = true;
@@ -206,24 +473,282 @@ onMounted(async () => {
</script>
<style scoped>
.stats {
.customer-detail {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.mt-4 {
margin-top: 1rem;
flex-direction: column;
gap: 16px;
color: var(--cd-text);
}
.panel {
background: var(--cd-bg);
border: 1px solid var(--cd-border);
border-radius: 12px;
}
.panel-title {
margin: 0;
padding: 14px 18px;
font-size: 14px;
font-weight: 700;
border-bottom: 1px solid var(--cd-border);
}
/* ── Header ─────────────────────────────────────────── */
.hero {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 20px 24px;
}
.hero-main {
display: flex;
align-items: flex-start;
gap: 16px;
min-width: 0;
}
.hero-avatar {
flex-shrink: 0;
background: var(--cd-primary);
font-size: 26px;
font-weight: 700;
}
.hero-text {
min-width: 0;
}
.hero-eyebrow {
font-size: 12px;
color: var(--cd-text-3);
}
.hero-name {
margin: 2px 0 8px;
font-size: 22px;
font-weight: 800;
line-height: 1.4;
color: var(--cd-text);
}
.hero-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.hero-tags :deep(.ant-tag) {
margin: 0;
}
.hero-meta {
display: flex;
flex-wrap: wrap;
gap: 6px 20px;
margin-top: 12px;
font-size: 13px;
color: var(--cd-text-2);
}
.meta-item {
display: inline-flex;
align-items: center;
gap: 6px;
}
.meta-item :deep(.anticon) {
color: var(--cd-text-3);
}
.hero-actions {
display: flex;
flex-shrink: 0;
gap: 8px;
}
/* ── KPI tiles ──────────────────────────────────────── */
.kpis {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 16px;
}
.kpi {
display: flex;
flex-direction: column;
padding: 16px 18px;
}
.kpi-label {
font-size: 13px;
color: var(--cd-text-2);
}
.kpi-value {
margin-top: 6px;
font-size: 22px;
font-weight: 800;
line-height: 1.3;
font-variant-numeric: tabular-nums;
color: var(--cd-text);
}
.kpi-value small {
margin-inline-start: 4px;
font-size: 12px;
font-weight: 500;
color: var(--cd-text-3);
}
.kpi-progress {
margin: 6px 0 0;
}
.kpi-foot {
margin-top: auto;
padding-top: 6px;
font-size: 12px;
color: var(--cd-text-3);
}
/* ── Two-column body ────────────────────────────────── */
.layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 360px;
gap: 16px;
align-items: start;
}
.layout-main {
padding: 4px 20px 16px;
}
.layout-side {
display: flex;
flex-direction: column;
gap: 16px;
}
.detail-tabs :deep(.ant-tabs-nav) {
margin-bottom: 12px;
}
.detail-tabs :deep(.ant-tabs-tab) {
padding: 14px 0;
font-weight: 600;
}
.tab-count {
display: inline-block;
min-width: 22px;
margin-inline-start: 6px;
padding: 0 7px;
border-radius: 10px;
background: var(--cd-fill);
color: var(--cd-text-2);
font-size: 12px;
font-weight: 500;
line-height: 20px;
text-align: center;
}
.detail-tabs :deep(.ant-tabs-tab-active) .tab-count {
background: var(--cd-primary-bg);
color: var(--cd-primary);
}
.detail-tabs :deep(.ant-table-thead > tr > th) {
font-weight: 600;
color: var(--cd-text-2);
}
.facts {
margin: 0;
padding: 6px 18px 10px;
}
.fact {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 16px;
padding: 9px 0;
border-bottom: 1px dashed var(--cd-border);
}
.fact:last-child {
border-bottom: 0;
}
.fact dt {
flex-shrink: 0;
font-size: 13px;
color: var(--cd-text-2);
}
.fact dd {
margin: 0;
font-weight: 600;
text-align: end;
word-break: break-word;
}
/* ── Shared bits ────────────────────────────────────── */
.debt {
color: var(--cd-error);
}
.credit {
color: var(--cd-success);
}
.text-warning {
color: var(--cd-warning);
font-weight: 600;
}
.unit {
font-size: 12px;
color: var(--cd-text-3);
}
.mono {
font-variant-numeric: tabular-nums;
font-weight: 600;
}
.note {
color: var(--cd-text-2);
}
/* ── Responsive ─────────────────────────────────────── */
@media (max-width: 1399px) {
.kpis {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 1199px) {
.layout {
grid-template-columns: minmax(0, 1fr);
}
/* details first on narrow screens — they frame the records below */
.layout-side {
order: -1;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
align-items: start;
}
}
@media (max-width: 767px) {
.hero {
flex-direction: column;
padding: 16px;
}
.hero-actions {
width: 100%;
}
.hero-actions :deep(.ant-btn) {
flex: 1;
}
.kpis {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
}
@media (max-width: 575px) {
.mt-4 {
margin-top: 12px;
.customer-detail {
gap: 12px;
}
.stats :deep(.ant-tag) {
margin: 0;
.hero-avatar {
width: 48px !important;
height: 48px !important;
line-height: 48px !important;
font-size: 20px;
}
.detail-tabs :deep(.ant-tabs-nav) {
margin-bottom: 8px;
.hero-name {
font-size: 18px;
}
.kpi {
padding: 12px 14px;
}
.kpi-value {
font-size: 18px;
}
.layout-main {
padding: 0 12px 12px;
}
.layout-side {
grid-template-columns: minmax(0, 1fr);
}
.detail-tabs :deep(.ant-table-content) {
overflow-x: auto;
@@ -42,6 +42,14 @@
</a-tag>
</template>
<!-- D27 — balance in the accounting software; positive = the customer owes -->
<template v-for="cell in ACC_CELLS" :key="cell.slot" #[cell.slot]="{ record }">
<span v-if="record[cell.field] === null || record[cell.field] === undefined" class="acc-none">—</span>
<a-tooltip v-else :title="record.acc_at ? `به‌روزرسانی: ${jdt(record.acc_at)}` : undefined">
<span :class="accClass(record[cell.field])">{{ accText(record[cell.field], cell.places) }}</span>
</a-tooltip>
</template>
<template #opt="{ record }">
<div class="flex">
<a-dropdown>
@@ -253,7 +261,8 @@
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { onMounted, reactive, ref } from 'vue';
import moment from 'moment-jalaali';
import { useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import {
@@ -275,6 +284,7 @@ import BulkCustomer from '../../apps/customer/bulkCustomer.vue';
import { useUsersStore } from '../../../stores/users';
import api from '../../../api/client';
import type { XTbl } from '../../../components/x-table/model';
import { addAccountingColumns } from './omdeListModel';
const props = withDefaults(
defineProps<{
@@ -282,19 +292,12 @@ const props = withDefaults(
title: string;
/** The table definition (differs only in its url). */
model: XTbl;
/**
* OmdeNonVerifiedList's quirk: its popconfirm calls rejectStepOne() in BOTH
* the accept and reject branches, so the first step can never be approved
* from that screen. Faithfully reproduced behind this flag rather than
* silently "fixed" — see docs/ADMIN-PORT-PLAN.md.
*/
rejectOnlyStepOne?: boolean;
/** Only OmdeList.vue passes this — see the comment on its call site. */
showAddButton?: boolean;
/** D27 §12 — «سینک با حسابداری» for the whole list; only OmdeList.vue. */
accountingSync?: boolean;
}>(),
{ rejectOnlyStepOne: false, showAddButton: false, accountingSync: false },
{ showAddButton: false, accountingSync: false },
);
const router = useRouter();
@@ -315,6 +318,31 @@ function setCount(raw: unknown) {
};
}
// ── D27: accounting balance columns, only while a software is connected ──
const ACC_CELLS = [
{ slot: 'acc_gold', field: 'acc_remain', places: 3 },
{ slot: 'acc_rial', field: 'acc_rial_remain', places: 0 },
] as const;
const accClass = (v: unknown) => (Number(v) > 0 ? 'acc-debt' : Number(v) < 0 ? 'acc-credit' : '');
function accText(v: unknown, places: number) {
const n = Number(v);
if (!n) return '۰';
const abs = Math.abs(n).toLocaleString('fa-IR', { maximumFractionDigits: places });
return `${abs} ${n > 0 ? 'بدهکار' : 'بستانکار'}`;
}
const jdt = (v: string) => moment(v).format('jYYYY/jMM/jDD HH:mm');
onMounted(async () => {
try {
const { data } = await api.get('admin/accounting-status');
// The proxy, not the raw model: XTable reads the columns through reactive().
if (data?.connected && data.provider) addAccountingColumns(reactive(props.model) as XTbl, data.provider);
} catch {
// No status, no columns — the list works as before.
}
});
/**
* legacy handleMenuClick(): closes the dropdown for the keys that open a
* popconfirm, so the menu does not stay open behind the confirmation.
@@ -424,11 +452,13 @@ async function submitReject() {
d.open = false;
}
/** legacy inline ternary on @confirm, preserved including the NonVerified quirk */
/**
* legacy inline ternary on @confirm. Legacy's OmdeNonVerifiedList rejected on
* BOTH buttons, so a pending customer could never be approved there; that
* quirk was dropped 2026-10-14 — every list now approves on «تایید».
*/
function onConfirmStepOne(record: Record<string, any>) {
if (record.sts_verify_step_one === 1) {
return props.rejectOnlyStepOne ? rejectStepOne(record.id) : acceptStepOne(record.id);
}
if (record.sts_verify_step_one === 1) return acceptStepOne(record.id);
if (record.sts_verify_step_one === 2) return rejectStepOne(record.id);
return acceptStepOne(record.id);
}
@@ -504,6 +534,15 @@ function viewCash(record: Record<string, any>) {
</script>
<style scoped>
.acc-debt {
color: #cf1322;
}
.acc-credit {
color: #389e0d;
}
.acc-none {
color: #bfbfbf;
}
@media (max-width: 575px) {
.list-count {
font-size: 12px;
@@ -1,6 +1,6 @@
<template>
<!-- Ported from legacy views/customer/omde/OmdeNonVerifiedList.vue. Carries the legacy quirk where step-one confirm rejects instead of accepting. -->
<OmdeCustomerList :title="title" :model="model" reject-only-step-one />
<!-- Ported from legacy views/customer/omde/OmdeNonVerifiedList.vue. Legacy's step-one confirm rejected instead of accepting; fixed 2026-10-14. -->
<OmdeCustomerList :title="title" :model="model" />
</template>
<script setup lang="ts">
@@ -9,6 +9,7 @@
title="مشاهده اطلاعات مشتری عمده"
:show-activity="true"
:show-wallet="false"
edit-route="bulkCustomer-edit"
/>
</template>
@@ -67,4 +67,26 @@ export function buildOmdeListModel(source: OmdeSourceKey): XTbl {
return tbl;
}
/**
* D27 — while an accounting software is connected, its balance gets two
* columns named after it («مانده‌ی زرین» / «مانده‌ی ته‌حساب»), and the app's
* own debt columns are renamed so the two are not mistaken for each other
* (docs/ACCOUNTING-BALANCE-COLUMNS-PLAN.md). Not connected: nothing changes.
* Pass the reactive proxy of the model so XTable picks the change up.
*/
export function addAccountingColumns(tbl: XTbl, provider: string) {
if (tbl.cols.some((c) => c.key === 'acc_remain')) return;
const at = tbl.cols.findIndex((c) => c.key === 'rial_remain');
for (const c of tbl.cols) {
if (c.key === 'remain') c.title = 'بدهی داخل اپ (گرم)';
if (c.key === 'rial_remain') c.title = 'بدهی داخل اپ (ریال)';
}
tbl.cols.splice(
at + 1,
0,
new Xtc('acc_remain', `مانده‌ی ${provider} (گرم)`).filterNumber().renderSlot('acc_gold'),
new Xtc('acc_rial_remain', `مانده‌ی ${provider} (ریال)`).filterNumber().renderSlot('acc_rial'),
);
}
export default buildOmdeListModel;
@@ -10,6 +10,7 @@
title="مشاهده اطلاعات مشتری"
:show-wallet="true"
:show-activity="false"
edit-route="singleCustomer-edit"
/>
</template>
+93 -22
View File
@@ -101,18 +101,39 @@
key="slider"
tab="اسلایدر موبایل"
>
<div v-for="(s, index) in sliders" :key="index" class="slide">
<h6 class="mobile-c">اسلاید {{ index + 1 }}</h6>
<div class="slider-toolbar">
<span class="slider-count">{{ sliders.length }} اسلاید</span>
<div class="row">
<ImageUploadInput v-model="s.image" purpose="slider" placeholder="تصویر اسلاید" />
<a-input v-model:value="s.link" placeholder="لینک" />
<a-button danger @click="sliders.splice(index, 1)">حذف</a-button>
<a-button @click="addSlide">
<template #icon><PlusOutlined /></template>
افزودن اسلاید
</a-button>
<a-button type="primary" @click="saveSliders">ذخیره اسلایدر</a-button>
</div>
</div>
<div class="row mt-4">
<a-button @click="sliders.push({ image: '', link: '' })">افزودن اسلاید</a-button>
<a-button type="primary" @click="saveSliders">ذخیره اسلایدر</a-button>
<a-empty v-if="sliders.length === 0" description="اسلایدی ثبت نشده است" />
<div v-else class="slider-grid">
<div v-for="(s, index) in sliders" :key="index" class="slide-card">
<div class="slide-card__head">
<span class="slide-card__title">اسلاید {{ index + 1 }}</span>
<a-popconfirm
ok-text="بله"
cancel-text="خیر"
title="این اسلاید حذف شود؟"
@confirm="removeSlide(index)"
>
<a-button type="text" danger size="small" title="حذف">
<template #icon><DeleteOutlined /></template>
</a-button>
</a-popconfirm>
</div>
<div class="slide-card__media">
<ImageUploadInput v-model="s.image" purpose="slider" placeholder="تصویر اسلاید" />
</div>
<a-input v-model:value="s.link" placeholder="لینک" />
</div>
</div>
</a-tab-pane>
@@ -173,7 +194,7 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { message } from 'ant-design-vue';
import { DeleteOutlined, EditOutlined } from '@ant-design/icons-vue';
import { DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons-vue';
import { useUsersStore } from '../../stores/users';
import { useDeleteHandler } from '../../composables/useDeleteHandler';
import api from '../../api/client';
@@ -280,6 +301,14 @@ async function saveTags() {
}
// ── slider
function addSlide() {
sliders.value.push({ image: '', link: '' });
}
function removeSlide(index: number) {
sliders.value.splice(index, 1);
}
// Gated server-side by its own `custom.config.statics.slider` permission (not
// the broad `config.statics` that guards the rest of this screen) — see
// admin-misc.controller.ts. Uses the dedicated `const/slider` route rather
@@ -368,14 +397,62 @@ onMounted(async () => {
gap: 0.5rem;
flex-wrap: wrap;
}
.slide {
border: 1px solid #f0f0f0;
border-radius: 6px;
padding: 0.75rem;
margin-bottom: 0.75rem;
.slider-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
margin-bottom: 1rem;
}
.mobile-c {
margin: 0 0 0.5rem;
.slider-count {
color: rgba(0, 0, 0, 0.45);
}
/* one card per slide; min() keeps a card no wider than a narrow phone column */
.slider-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 220px), 1fr));
gap: 16px;
}
.slide-card {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
padding: 12px;
border: 1px solid #f0f0f0;
border-radius: 8px;
background: #fff;
}
.slide-card__head {
display: flex;
justify-content: space-between;
align-items: center;
}
.slide-card__title {
font-weight: 600;
}
/* Fixed-height media box: the empty upload tile, the uploading spinner and the
saved thumbnail all fill it, so the card keeps the same height when a photo
is added or removed (no jump). The shared upload tile is 104px square by
default; here it becomes a full-width banner preview. */
.slide-card__media {
height: 140px;
}
.slide-card__media :deep(.image-upload),
.slide-card__media :deep(.ant-upload-wrapper),
.slide-card__media :deep(.ant-upload-list),
.slide-card__media :deep(.ant-upload-list-item-container),
.slide-card__media :deep(.ant-upload.ant-upload-select) {
width: 100% !important;
height: 100% !important;
margin: 0 !important;
}
.slide-card__media :deep(.ant-upload-wrapper) {
display: block;
}
.slide-card__media :deep(.ant-upload-list-item-thumbnail img) {
object-fit: cover;
}
.mt-4 {
margin-top: 1rem;
@@ -387,12 +464,6 @@ onMounted(async () => {
flex: 1 1 140px;
max-width: none !important;
}
.slide {
padding: 10px;
}
.slide .row > * {
flex: 1 1 100%;
}
.mt-4 {
margin-top: 12px;
}
@@ -119,7 +119,7 @@
</div>
<div class="hint mb-4">
اسناد سفارش همیشه «موقت» ثبت می‌شوند و تا حسابدار نهایی نکند روی مانده‌ی حسابداری اثر ندارند.
سقف اعتبار سفارش همچنان با دفتر داخلی منتزر چک می‌شود.
سقف اعتبار سفارش همچنان با دفتر داخلی {{ brand.name }} چک می‌شود.
</div>
<a-button type="primary" @click="save">ذخیره</a-button>
</a-tab-pane>
@@ -129,17 +129,17 @@
<a-collapse ghost class="mb-4">
<a-collapse-panel key="guide" header="راهنمای این تب: نگاشت چیست و چطور پر می‌شود؟">
<p>
در این صفحه «منتزر» یعنی همین سامانه: <b>اپ مشتری</b> (جایی که مشتری ثبت‌نام، احراز هویت و سفارش می‌کند و مانده‌اش را می‌بیند) و
در این صفحه «{{ brand.name }}» یعنی همین سامانه: <b>اپ مشتری</b> (جایی که مشتری ثبت‌نام، احراز هویت و سفارش می‌کند و مانده‌اش را می‌بیند) و
<b>پنل مدیریت</b> (همین پنل، جایی که محصول و دسته‌بندی تعریف می‌شود). «حسابداری» یعنی نرم‌افزار حسابداری مغازه (ته‌حساب یا زرین).
</p>
<p>
منتزر و حسابداری هر کدام گروه‌بندی خودشان را دارند. این تب می‌گوید هر چیز در منتزر با کدام گروه در حسابداری متناظر است؛
{{ brand.name }} و حسابداری هر کدام گروه‌بندی خودشان را دارند. این تب می‌گوید هر چیز در {{ brand.name }} با کدام گروه در حسابداری متناظر است؛
خودِ مشتری‌ها و محصول‌ها اینجا وصل نمی‌شوند (آن‌ها در تب «لینک‌ها» دیده می‌شوند).
</p>
<ol>
<li>تنظیمات تب «اتصال» را ذخیره کنید، بعد «دریافت گروه‌ها از حسابداری» را بزنید تا فهرست‌ها از حسابداری پر شوند.</li>
<li>
<b>گروه‌های حسابی که مشتری منتزر حساب می‌شوند:</b> در حسابداری صندوق، کارمند و سرمایه‌گذار هم «حساب» هستند. فقط گروه‌هایی را انتخاب کنید که
<b>گروه‌های حسابی که مشتری {{ brand.name }} حساب می‌شوند:</b> در حسابداری صندوق، کارمند و سرمایه‌گذار هم «حساب» هستند. فقط گروه‌هایی را انتخاب کنید که
واقعاً مشتری‌اند؛ سینک مشتری فقط حساب‌های این گروه‌ها را با شماره موبایل به کاربران اپ مشتری وصل می‌کند. اگر خالی بماند سینک مشتری اجرا نمی‌شود.
</li>
<li>
@@ -147,12 +147,12 @@
اگر تعیین نشود، ساخت حساب در «لاگ ارسال» با وضعیت «نیاز به بررسی» می‌ماند.
</li>
<li>
<b>عیار پایه:</b> عیار وزن‌هایی که در منتزر ثبت می‌شود (وزن محصول‌ها و سفارش‌ها؛ معمولاً ۷۵۰ = ۱۸ عیار). در سند سفارش، وزن با همین عیار به
<b>عیار پایه:</b> عیار وزن‌هایی که در {{ brand.name }} ثبت می‌شود (وزن محصول‌ها و سفارش‌ها؛ معمولاً ۷۵۰ = ۱۸ عیار). در سند سفارش، وزن با همین عیار به
حسابداری می‌رود؛ در ته‌حساب اجرت هم روی آن اعمال می‌شود (مثلاً ۷۵۰ با اجرت ۱۰٪ می‌شود ۸۲۵).
</li>
<li v-if="form.provider === 'zarrin'">
<b>کارساخته (فقط زرین):</b> وقتی در پنل مدیریت منتزر محصول جدیدی تعریف شود، برایش یک «کارساخته» در زرین ساخته می‌شود. «نوع» تعیین می‌کند
گرمی است یا تعدادی، و «گروه ساخته» جای آن در زرین است. در جدول پایین برای هر دسته‌بندی محصولِ منتزر می‌توانید گروه جدا بدهید؛ دسته‌ای که
<b>کارساخته (فقط زرین):</b> وقتی در پنل مدیریت {{ brand.name }} محصول جدیدی تعریف شود، برایش یک «کارساخته» در زرین ساخته می‌شود. «نوع» تعیین می‌کند
گرمی است یا تعدادی، و «گروه ساخته» جای آن در زرین است. در جدول پایین برای هر دسته‌بندی محصولِ {{ brand.name }} می‌توانید گروه جدا بدهید؛ دسته‌ای که
«پیش‌فرض» بماند در «گروه ساخته‌ی پیش‌فرض» می‌رود. اگر نوع یا گروه خالی باشد، ساخت کارساخته در «لاگ ارسال» منتظر بررسی می‌ماند.
</li>
</ol>
@@ -167,10 +167,10 @@
</a-space>
<a-form layout="vertical">
<a-divider orientation="right">حساب مشتری‌ها</a-divider>
<a-alert type="info" show-icon class="mb-4" message="این بخش تعیین می‌کند کدام حساب‌های حسابداری به کاربران اپ مشتری منتزر وصل شوند">
<a-alert type="info" show-icon class="mb-4" :message="`این بخش تعیین می‌کند کدام حساب‌های حسابداری به کاربران اپ مشتری ${brand.name} وصل شوند`">
<template #description>
<div>
هر حسابی که در گروه‌های انتخاب‌شده باشد و موبایلش با یک کاربر اپ مشتری منتزر یکی باشد، به آن کاربر وصل می‌شود و مانده‌اش در اپ مشتری
هر حسابی که در گروه‌های انتخاب‌شده باشد و موبایلش با یک کاربر اپ مشتری {{ brand.name }} یکی باشد، به آن کاربر وصل می‌شود و مانده‌اش در اپ مشتری
نمایش داده می‌شود. صندوق، کارمند و سرمایه‌گذار را انتخاب نکنید.
</div>
<div>
@@ -180,7 +180,7 @@
</template>
</a-alert>
<div class="grid-2">
<a-form-item label="گروه‌های حسابی که مشتری منتزر حساب می‌شوند" extra="فقط گروه‌هایی که مشتری‌اند؛ خالی = سینک مشتری اجرا نمی‌شود">
<a-form-item :label="`گروه‌های حسابی که مشتری ${brand.name} حساب می‌شوند`" extra="فقط گروه‌هایی که مشتری‌اند؛ خالی = سینک مشتری اجرا نمی‌شود">
<a-select v-model:value="importGroupKeys" mode="multiple" :options="customerGroupOptions" allow-clear placeholder="هیچ گروهی انتخاب نشده" />
</a-form-item>
<a-form-item
@@ -189,32 +189,32 @@
>
<a-select v-model:value="newCustomerGroupKey" :options="customerGroupOptions" allow-clear />
</a-form-item>
<a-form-item label="عیار پایه‌ی وزن‌های منتزر" extra="عیار وزن محصول‌ها و سفارش‌ها در منتزر؛ ۷۵۰ یعنی ۱۸ عیار">
<a-form-item :label="`عیار پایه‌ی وزن‌های ${brand.name}`" :extra="`عیار وزن محصول‌ها و سفارش‌ها در ${brand.name}؛ ۷۵۰ یعنی ۱۸ عیار`">
<a-input-number v-model:value="form.mapping.karat" :min="1" :max="1000" class="w-full" />
</a-form-item>
</div>
<template v-if="form.provider === 'zarrin'">
<a-divider orientation="right">کارساخته (زرین)</a-divider>
<a-alert type="info" show-icon class="mb-4" message="هر محصولی که در پنل مدیریت منتزر (بخش محصولات) تعریف می‌کنید، در زرین یک «کارساخته» می‌شود">
<a-alert type="info" show-icon class="mb-4" :message="`هر محصولی که در پنل مدیریت ${brand.name} (بخش محصولات) تعریف می‌کنید، در زرین یک «کارساخته» می‌شود`">
<template #description>
<div>
زرین برای ساختن کارساخته دو چیز لازم دارد: <b>نوع</b> (گرمی یا تعدادی) و <b>گروه ساخته</b> (کارساخته در کدام پوشه‌ی زرین قرار بگیرد).
این بخش این دو را به‌جای شما انتخاب می‌کند تا لازم نباشد برای هر محصول جداگانه در زرین کاری انجام دهید.
</div>
<div>
ستون «دسته‌بندی محصول در منتزر» همان دسته‌بندی‌هایی است که در پنل مدیریت برای محصولات تعریف کرده‌اید. مثال: اگر روبه‌روی «انگشتر»
گروه «انگشتر» زرین را بگذارید، هر محصول تازه در دسته‌ی انگشتر منتزر، در گروه انگشترِ زرین ساخته می‌شود.
ستون «دسته‌بندی محصول در {{ brand.name }}» همان دسته‌بندی‌هایی است که در پنل مدیریت برای محصولات تعریف کرده‌اید. مثال: اگر روبه‌روی «انگشتر»
گروه «انگشتر» زرین را بگذارید، هر محصول تازه در دسته‌ی انگشتر {{ brand.name }}، در گروه انگشترِ زرین ساخته می‌شود.
دسته‌هایی که «پیش‌فرض» بمانند در «گروه ساخته‌ی پیش‌فرض» می‌روند.
</div>
<div>
اگر نوع یا گروه خالی باشد، محصول در منتزر ساخته می‌شود ولی ساخت کارساخته در «لاگ ارسال» منتظر می‌ماند تا آن را تعیین کنید و «تلاش دوباره» بزنید.
اگر نوع یا گروه خالی باشد، محصول در {{ brand.name }} ساخته می‌شود ولی ساخت کارساخته در «لاگ ارسال» منتظر می‌ماند تا آن را تعیین کنید و «تلاش دوباره» بزنید.
محصول‌هایی که از قبل به کارساخته وصل شده‌اند تغییری نمی‌کنند.
</div>
</template>
</a-alert>
<div class="grid-2">
<a-form-item label="نوع کارساخته‌ی جدید (گرمی/تعدادی…)" extra="برای محصول تازه‌ای که در پنل مدیریت منتزر تعریف می‌شود">
<a-form-item label="نوع کارساخته‌ی جدید (گرمی/تعدادی…)" :extra="`برای محصول تازه‌ای که در پنل مدیریت ${brand.name} تعریف می‌شود`">
<a-select v-model:value="newTypeKey" :options="typeOptions" allow-clear />
</a-form-item>
<a-form-item label="گروه ساخته‌ی پیش‌فرض" extra="برای دسته‌هایی که در جدول زیر «پیش‌فرض» مانده‌اند">
@@ -222,7 +222,7 @@
</a-form-item>
</div>
<a-table :data-source="categories" :pagination="false" row-key="id" size="small" class="mb-4">
<a-table-column title="دسته‌بندی محصول در منتزر" data-index="title" />
<a-table-column :title="`دسته‌بندی محصول در ${brand.name}`" data-index="title" />
<a-table-column title="گروه ساخته در زرین">
<template #default="{ record }">
<a-select v-model:value="categoryGroupKeys[record.id]" :options="itemGroupOptions" allow-clear class="w-full" placeholder="پیش‌فرض" />
@@ -343,7 +343,7 @@
>
<a-table-column title="کد حسابداری" data-index="externalCode" />
<a-table-column title="نام در حسابداری" data-index="externalName" />
<a-table-column title="شناسه در منتزر" data-index="entityId" />
<a-table-column :title="`شناسه در ${brand.name}`" data-index="entityId" />
<a-table-column title="منشأ">
<template #default="{ record }">{{ ORIGIN[record.origin] ?? record.origin }}</template>
</a-table-column>
@@ -396,6 +396,7 @@ import moment from 'moment-jalaali';
import { useUsersStore } from '../../stores/users';
import api from '../../api/client';
import AccountingConnection from '../../components/accounting/AccountingConnection.vue';
import { brand } from '../../brand';
type ProviderName = 'tahesab' | 'zarrin';
interface Group {
@@ -438,7 +439,7 @@ const ISSUE: Record<string, string> = {
const ORIGIN: Record<string, string> = {
LINKED: 'جفت با موبایل/نام',
IMPORTED: 'وارد شده از حسابداری',
PUSHED: 'ساخته شده از منتزر',
PUSHED: `ساخته شده از ${brand.name}`,
MANUAL: 'دستی',
};
+7 -1
View File
@@ -2,6 +2,7 @@ import { fileURLToPath, URL } from 'node:url';
import { defineConfig, loadEnv } from 'vite';
import vue from '@vitejs/plugin-vue';
import { appVersion } from '../../scripts/app-version.mjs';
import { brand } from '../../brand';
// Ported from legacy henza_admin/vue.config.js.
// The legacy build switched publicPath/outputDir on NODE_ENV=='branch'; in Vite the
@@ -25,7 +26,12 @@ export default defineConfig(({ mode }) => {
// Branch mode still mounts under `/branch/` when that mode is used.
base: isBranch ? '/branch/' : '/',
// Build number + «what changed» notes → version.json (scripts/app-version.mjs).
plugins: [vue(), appVersion('admin-panel')],
plugins: [
vue(),
appVersion('admin-panel'),
// <title> comes from the repo-root brand.ts, like every other brand text.
{ name: 'brand-title', transformIndexHtml: (html: string) => html.replace('%BRAND_TITLE%', brand.adminTitle) },
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
+1 -1
View File
@@ -28,7 +28,7 @@ const insertion = ` imageCount += 1;
{ slug: 'coins', id: coinsCategory.id, nouns: ['ربع سکه بهار آزادی', 'نیم سکه بهار آزادی', 'سکه امامی', 'سکه پارسیان', 'شمش طلای ۱۰ گرمی', 'شمش طلای ۵ گرمی'] },
];
const MOCK_ADJECTIVES = ['کلاسیک', 'مدرن', 'مینیمال', 'لوکس', 'دست‌ساز', 'ظریف', 'خاص', 'شیک', 'سنتی', 'رزگلد'];
const MOCK_BRANDS = [brandMontzar.id, brandArtisan.id];
const MOCK_BRANDS = [brandMain.id, brandArtisan.id];
const MOCK_PRODUCT_COUNT = 200;
let mockImageCount = 0;
+6 -6
View File
@@ -387,10 +387,10 @@ async function main() {
// Databases seeded before the rename hold the old id; Product/Promotion FKs cascade on update.
await prisma.brand.updateMany({ where: { id: 'brand-henza' }, data: { id: 'brand-montzar' } });
const brandMontzar = await prisma.brand.upsert({
const brandMain = await prisma.brand.upsert({
where: { id: 'brand-montzar' },
update: { title: 'منتزر طلا' },
create: { id: 'brand-montzar', title: 'منتزر طلا' },
update: { title: 'زرتاج طلا' },
create: { id: 'brand-montzar', title: 'زرتاج طلا' },
});
const brandArtisan = await prisma.brand.upsert({
where: { id: 'brand-artisan' },
@@ -404,7 +404,7 @@ async function main() {
title: 'انگشتر تک نگین کلاسیک',
description: 'انگشتر طلای ۱۸ عیار با طراحی ساده و مینیمال.',
categoryId: ringsCategory.id,
brandId: brandMontzar.id,
brandId: brandMain.id,
variants: [
{ id: 'variant-ring-size-6', title: 'سایز ۶', sku: 'RING-CLASSIC-6', rashWeightGrams: '2.850', defaultWagePercent: '9.0' },
{ id: 'variant-ring-size-7', title: 'سایز ۷', sku: 'RING-CLASSIC-7', rashWeightGrams: '3.100', defaultWagePercent: '9.0' },
@@ -425,7 +425,7 @@ async function main() {
title: 'گردنبند آویز اشکی',
description: 'گردنبند ظریف با آویز اشکی و زنجیر نازک.',
categoryId: necklacesCategory.id,
brandId: brandMontzar.id,
brandId: brandMain.id,
variants: [
{ id: 'variant-necklace-40cm', title: 'زنجیر ۴۰ سانتی', sku: 'NCK-TEARDROP-40', rashWeightGrams: '5.600', defaultWagePercent: '10.0' },
{ id: 'variant-necklace-45cm', title: 'زنجیر ۴۵ سانتی', sku: 'NCK-TEARDROP-45', rashWeightGrams: '6.100', defaultWagePercent: '10.0' },
@@ -447,7 +447,7 @@ async function main() {
title: 'سکه تمام بهار آزادی',
description: 'سکه طلای تمام بهار آزادی، عیار استاندارد.',
categoryId: coinsCategory.id,
brandId: brandMontzar.id,
brandId: brandMain.id,
variants: [
{ id: 'variant-coin-full', title: 'تمام سکه', sku: 'COIN-FULL', rashWeightGrams: '8.133', defaultWagePercent: '2.0' },
{ id: 'variant-coin-half', title: 'نیم سکه', sku: 'COIN-HALF', rashWeightGrams: '4.066', defaultWagePercent: '2.5' },
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* Legacy system (MySQL `oapi`) → montzar (PostgreSQL) data import.
* Legacy system (MySQL `oapi`) → zartaj (PostgreSQL) data import.
*
* The rules live in docs/DATA-MIGRATION-MAP.md; every step cites its section.
*
@@ -56,7 +56,7 @@ export class Report {
}
render(header: string[]): string {
const out: string[] = ['# legacy (oapi) → montzar import report', '', ...header, ''];
const out: string[] = ['# legacy (oapi) → zartaj import report', '', ...header, ''];
for (const [step, s] of this.sections) {
out.push(`## ${step}`, '');
if (s.counts.size) {
+16
View File
@@ -0,0 +1,16 @@
import { envOr } from './env';
/**
* The brand name and public site address used in every SMS and message the
* server sends. Read on each call, so changing BRAND_NAME / BRAND_SITE_URL in
* the environment and restarting renames every outgoing text at once — no code
* change. The frontends read the same values from their own brand files; see
* docs/BRANDING.md for the full list.
*/
export function brandName(): string {
return envOr('BRAND_NAME', 'زرتاج');
}
export function brandSiteUrl(): string {
return envOr('BRAND_SITE_URL', 'https://bnk.montzar.ir');
}
@@ -29,7 +29,7 @@ describe('AdminCustomersService', () => {
const prisma = { ...tx, $transaction: jest.fn(async (fn: (t: typeof tx) => unknown) => fn(tx)) };
const accounting = { customerVerified: jest.fn(), customerChanged: jest.fn() };
const sms = { send: jest.fn(async () => undefined) };
const service = new AdminCustomersService(prisma as never, {} as never, sms as never, accounting as never);
const service = new AdminCustomersService(prisma as never, {} as never, sms as never, accounting as never, {} as never);
return { service, writes, accounting };
}
@@ -57,6 +57,9 @@ describe('AdminCustomersService', () => {
verificationStep: VerificationStep.STEP_TWO_SUBMITTED,
verificationRejectionReason: null,
verificationRejectedAt: null,
// Accepting step one activates the account (an app self-registration
// is created inactive).
isActive: true,
});
});
@@ -9,6 +9,8 @@ import { OTP_ONLY_PASSWORD_HASH } from '../auth/password-hash';
import { agentSalesWhere, customerSalesWhere } from '../sales-agent/agent-orders';
import { SmsService } from '../sms/sms.service';
import { AccountingHooksService } from '../accounting-sync/accounting-hooks.service';
import { AccountingConfigService } from '../accounting-sync/accounting-config.service';
import { brandName, brandSiteUrl } from '../../common/brand';
/**
* Wholesale ("عمده") customer lists — legacy views/customer/omde/*.
@@ -137,12 +139,13 @@ export class AdminCustomersService {
private readonly table: TableService,
private readonly sms: SmsService,
private readonly accounting: AccountingHooksService,
private readonly accountingConfig: AccountingConfigService,
) {}
/**
* The welcome SMS the reference sends after every new customer is created
* (AdminController.php:378-387), with the brand and the PWA link changed to
* منتزر; same line order. The commented-out Android/iOS lines are not sent
* BRAND_NAME / BRAND_SITE_URL (common/brand.ts); same line order. The commented-out Android/iOS lines are not sent
* there and are not sent here.
*
* D20 — awaiting it would let a slow or unconfigured gateway hold up the
@@ -153,11 +156,11 @@ export class AdminCustomersService {
*/
private sendWelcomeSms(mobile: string, firstName: string, lastName: string) {
const body =
'منتزر\n' +
`${brandName()}\n` +
`همکار گرامی ${firstName} ${lastName}\n` +
`نام کاربری : ${mobile}\n` +
'لینک pwa \n' +
'https://bnk.montzar.ir';
brandSiteUrl();
void this.sms.send(mobile, body).catch((e: unknown) => {
// send() is documented never to throw; this is belt-and-braces so an
@@ -182,6 +185,8 @@ export class AdminCustomersService {
'goldCreditLimitGrams',
'goldDebtGrams',
'fiatDebtRial',
'accountingGoldBalance',
'accountingRialBalance',
'referredByAgent.fullName',
// legacy filter keys, accepted so the ported table models work unchanged
'name',
@@ -205,6 +210,8 @@ export class AdminCustomersService {
gram_level: 'goldCreditLimitGrams',
remain: 'goldDebtGrams',
rial_remain: 'fiatDebtRial',
acc_remain: 'accountingGoldBalance',
acc_rial_remain: 'accountingRialBalance',
caj: 'createdAt',
uaj: 'updatedAt',
'reagent.name': 'referredByAgent.fullName',
@@ -289,7 +296,7 @@ export class AdminCustomersService {
const [rows, total] = await Promise.all([
this.prisma.customer.findMany({
where: scoped,
orderBy: orderBy as Prisma.CustomerOrderByWithRelationInput,
orderBy: AdminCustomersService.nullsLast(orderBy) as Prisma.CustomerOrderByWithRelationInput,
skip,
take,
include: { city: true, referredByAgent: true, avatarFile: true },
@@ -297,7 +304,61 @@ export class AdminCustomersService {
this.prisma.customer.count({ where: scoped }),
]);
return this.table.respond(rows.map((r) => this.toRow(r)), total, query, page, limit);
const linked = await this.accountingLinked(rows.map((r) => r.id));
return this.table.respond(
rows.map((r) => ({ ...this.toRow(r), ...AdminCustomersService.accountingCells(r, linked) })),
total,
query,
page,
limit,
);
}
/**
* The accounting balance is null for a customer not linked to the software,
* and Postgres puts nulls FIRST on a descending sort — the biggest debtors
* would sit under every unlinked customer.
*/
private static nullsLast(orderBy: Record<string, unknown>) {
const [field, dir] = Object.entries(orderBy)[0] ?? [];
return field === 'accountingGoldBalance' || field === 'accountingRialBalance'
? { [field]: { sort: dir, nulls: 'last' } }
: orderBy;
}
/**
* D27 — which of these customers are linked to the CONNECTED accounting
* software; null when none is connected (the list then hides its columns).
* The provider is checked because a balance read from a previous provider
* stays on the customer row.
*/
private async accountingLinked(ids: string[]): Promise<Set<string> | null> {
const config = await this.accountingConfig.get();
if (config.provider === 'none' || this.accountingConfig.inactiveReason(config)) return null;
const links = await this.prisma.accountingLink.findMany({
where: { provider: config.provider, entityType: 'CUSTOMER', entityId: { in: ids } },
select: { entityId: true },
});
return new Set(links.map((l) => l.entityId));
}
/** The balance in the accounting software (app convention: positive = owes). */
private static accountingCells(
c: {
id: string;
accountingGoldBalance: Prisma.Decimal | null;
accountingRialBalance: Prisma.Decimal | null;
accountingBalanceAt: Date | null;
},
linked: Set<string> | null,
) {
if (!linked) return {};
const on = linked.has(c.id);
return {
acc_remain: on ? c.accountingGoldBalance : null,
acc_rial_remain: on ? c.accountingRialBalance : null,
acc_at: on ? c.accountingBalanceAt : null,
};
}
/** Legacy field names preserved so the ported table models bind unchanged. */
@@ -1022,6 +1083,10 @@ export class AdminCustomersService {
: VerificationStep.VERIFIED,
verificationRejectionReason: null,
verificationRejectedAt: null,
// Reference MajorCustomerController.php:108-110 — accepting step one
// activates the account. An app self-registration is created inactive
// and can sign in only from here on.
...(payload.sts_verify_step_one !== undefined ? { isActive: true } : {}),
};
} else {
data = {
@@ -5,6 +5,7 @@ import { TableService } from '../admin-table/table.service';
import { LedgerService } from '../accounting/ledger.service';
import { SmsService } from '../sms/sms.service';
import type { TableQueryDto } from '../admin-table/table-query.dto';
import { brandName } from '../../common/brand';
/**
* Financial documents ("اسناد مالی") — legacy views/finical/*, served by
@@ -345,8 +346,8 @@ export class AdminDocumentsService {
// Legacy CashController::toggleConfiremCash sent an SMS on both outcomes.
private notifyDocumentConfirm(customerId: string, confirmed: boolean) {
const text = confirmed
? 'منتزر\nسند مالی شما توسط مدیر سیستم تایید شد.'
: 'منتزر\nسند مالی شما توسط مدیر سیستم رد شد.';
? `${brandName()}\nسند مالی شما توسط مدیر سیستم تایید شد.`
: `${brandName()}\nسند مالی شما توسط مدیر سیستم رد شد.`;
this.prisma.customer
.findUnique({ where: { id: customerId }, select: { mobile: true } })
@@ -7,6 +7,7 @@ import { AdminSessionService } from '../sessions/admin-session.service';
import type { TableQueryDto } from '../admin-table/table-query.dto';
import { faToEn } from '../customer-auth/digits';
import { ALL_RIDS } from '../auth/acl.constants';
import { SUPER_ADMIN_MOBILE, isSuperAdmin } from '../auth/super-admin';
/**
* SEC-07: the rids a role may hold — every rid in the catalogue and each of
@@ -92,15 +93,20 @@ export class AdminUsersService {
defaultOrderBy: { createdAt: 'desc' },
});
// The support super admin is not listed: to everyone else it does not exist.
const visible: Prisma.AdminUserWhereInput = {
AND: [where as Prisma.AdminUserWhereInput, { mobile: { not: SUPER_ADMIN_MOBILE } }],
};
const [rows, total] = await Promise.all([
this.prisma.adminUser.findMany({
where: where as Prisma.AdminUserWhereInput,
where: visible,
orderBy: orderBy as Prisma.AdminUserOrderByWithRelationInput,
skip,
take,
include: { userType: true, branch: true, avatarFile: true },
}),
this.prisma.adminUser.count({ where: where as Prisma.AdminUserWhereInput }),
this.prisma.adminUser.count({ where: visible }),
]);
return this.table.respond(rows.map((r) => this.toRow(r)), total, query, page, limit);
@@ -170,7 +176,7 @@ export class AdminUsersService {
salesExpertProfile: { include: { city: true } },
},
});
if (!u) throw new NotFoundException('کاربر یافت نشد');
if (!u || isSuperAdmin(u.mobile)) throw new NotFoundException('کاربر یافت نشد');
return this.toRow(u);
}
@@ -263,7 +269,7 @@ export class AdminUsersService {
/** Legacy `PUT users/{id}`. */
async update(id: string, data: Record<string, unknown>) {
const existing = await this.prisma.adminUser.findUnique({ where: { id } });
if (!existing) throw new NotFoundException('کاربر یافت نشد');
if (!existing || isSuperAdmin(existing.mobile)) throw new NotFoundException('کاربر یافت نشد');
const patch: Prisma.AdminUserUpdateInput = {};
@@ -386,7 +392,7 @@ export class AdminUsersService {
_count: { select: { postedLedgerEntries: true, orderStatusEvents: true } },
},
});
if (!u) throw new NotFoundException('کاربر یافت نشد');
if (!u || isSuperAdmin(u.mobile)) throw new NotFoundException('کاربر یافت نشد');
if (id === actorId) {
throw new BadRequestException('نمی‌توانید حساب کاربری خودتان را حذف کنید');
@@ -4,6 +4,7 @@ import { CustomerAuthService } from './customer-auth.service';
import { CustomersService } from '../customers/customers.service';
import { RequestOtpDto } from './dto/request-otp.dto';
import { VerifyOtpDto } from './dto/verify-otp.dto';
import { RegisterDto } from './dto/register.dto';
import { Public } from '../auth/guards';
import { CurrentCustomer, CustomerAuth } from './customer.guards';
import type { AuthenticatedCustomer } from './customer-jwt.strategy';
@@ -36,6 +37,14 @@ export class CustomerAuthController {
return this.auth.requestOtp(dto.username, ip);
}
/** App self-registration. The new customer waits in the admin's pending list. */
@Public()
@Post('auth/register')
@HttpCode(200)
register(@Body() dto: RegisterDto, @Ip() ip: string) {
return this.auth.register(dto, ip);
}
/** Step 2. Returns the token and that one customer, as the reference did. */
@Public()
@Post('auth/verify-otp')
@@ -1,5 +1,7 @@
import { createHash, createHmac, randomBytes } from 'node:crypto';
import {
BadRequestException,
ConflictException,
ForbiddenException,
HttpException,
HttpStatus,
@@ -9,7 +11,7 @@ import {
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { AdminRole, CustomerKind, MessageAudience, Prisma } from '@prisma/client';
import { AdminRole, CustomerKind, MessageAudience, Prisma, VerificationStep } from '@prisma/client';
import * as bcrypt from 'bcryptjs';
import { PrismaService } from '../../prisma/prisma.service';
import {
@@ -25,6 +27,8 @@ import { masterCodeAllowed } from '../../common/production-checks';
import { CUSTOMER_TOKEN_TYPE, EXPERT_TOKEN_TYPE, type CustomerJwtPayload } from './customer-jwt.strategy';
import { faToEn } from './digits';
import { SmsService } from '../sms/sms.service';
import { brandName } from '../../common/brand';
import type { RegisterDto } from './dto/register.dto';
/** Nest 10 has no TooManyRequestsException; 429 with a Persian message. */
class TooManyRequestsException extends HttpException {
@@ -44,6 +48,9 @@ const MAX_SENDS_PER_IP = 20;
const SEND_WINDOW_MS = 10 * 60 * 1000;
/** Minimum gap between two sends to the same number. */
const RESEND_COOLDOWN_MS = 60 * 1000;
/** Self-registrations allowed per source IP inside the window. */
const MAX_REGISTRATIONS_PER_IP = 5;
const REGISTER_WINDOW_MS = 60 * 60 * 1000;
/**
* D20 — the delivery master OTP.
@@ -177,6 +184,15 @@ export class CustomerAuthService {
// caps a single IP at MAX_SENDS_PER_IP per window, which it did not do in
// the reference.
if (!customer) {
// A self-registration still awaiting review is inactive by design; tell
// them so rather than claim the number was never registered.
const pending = await this.prisma.customer.findFirst({
where: { mobile, isActive: false, isArchived: false, verificationStep: VerificationStep.STEP_ONE_SUBMITTED },
select: { id: true },
});
if (pending) {
throw new ForbiddenException('ثبت نام شما در انتظار تایید کارشناسان است. پس از تایید می‌توانید وارد شوید.');
}
this.logger.warn(`OTP requested for unknown or inactive mobile ${mobile}`);
throw new NotFoundException('این شماره در سیستم ثبت نشده است. لطفاً با پشتیبانی تماس بگیرید.');
}
@@ -197,6 +213,89 @@ export class CustomerAuthService {
return { sent: true };
}
/**
* Self-registration from the app — port of Major/CustomerController::register
* (henza_server :19-75). Before this the app's register form was a stub that
* showed "registered" and saved nothing, so new customers never reached the
* admin's «مشتریان در انتظار» list.
*
* Uses the reference's ORIGINAL review flow (its commented-out "نیاز به تایید
* ادمین" block), not the later auto-approve: the row is inactive with step one
* awaiting review, which is exactly what that list shows. Approving step one
* in the admin panel activates it (AdminCustomersService.verify). Credit limit
* and wage offset stay at their defaults for the admin to set on review.
*/
async register(dto: RegisterDto, ip: string | undefined) {
const mobile = faToEn(dto.mobile);
if (!/^09\d{9}$/.test(mobile)) {
throw new BadRequestException('شماره موبایل معتبر نیست');
}
this.assertRegisterAllowed(ip);
const fname = dto.fname.trim();
const lname = dto.lname.trim();
const existing = await this.prisma.customer.findUnique({ where: { mobile }, select: { id: true } });
if (existing) {
throw new ConflictException('این شماره موبایل قبلاً ثبت شده است. از بخش ورود وارد شوید.');
}
try {
await this.prisma.customer.create({
data: {
fullName: `${fname} ${lname}`,
firstName: fname,
lastName: lname,
mobile,
passwordHash: OTP_ONLY_PASSWORD_HASH,
kind: CustomerKind.APP_SHOPKEEPER,
storeName: dto.store_name.trim(),
phone: faToEn(dto.phone),
address: dto.address.trim(),
cityNameRaw: dto.city.trim(),
verificationStep: VerificationStep.STEP_ONE_SUBMITTED,
isActive: false,
},
});
} catch (e) {
// Two submits racing on the same number: the unique index catches the second.
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
throw new ConflictException('این شماره موبایل قبلاً ثبت شده است. از بخش ورود وارد شوید.');
}
throw e;
}
// Reference :57-71 (its "awaiting review" wording). Fire-and-forget: a
// failed SMS never fails the registration (D20).
const brand = brandName();
const fire = (to: string, body: string) => {
void this.sms.send(to, body).catch((err: unknown) => {
this.logger.error(`Registration SMS to ${to} failed: ${String(err)}`);
});
};
fire(
mobile,
`${brand}\nهمکار گرامی ${fname} ${lname}\nثبت نام شما با موفقیت انجام شد و در انتظار تایید کارشناسان است.`,
);
for (const admin of envList('PROFILE_ADMIN_MOBILES')) {
fire(admin, `${brand}\nمشتری با نام ${fname} ${lname} ثبت نام کرد و در انتظار تایید است.`);
}
return { ok: true };
}
/** Public endpoint with an SMS side effect: cap how many accounts one IP can open. */
private readonly registrationsByIp = new Map<string, number[]>();
private assertRegisterAllowed(ip: string | undefined) {
if (!ip) return;
const now = Date.now();
const recent = (this.registrationsByIp.get(ip) ?? []).filter((t) => now - t < REGISTER_WINDOW_MS);
if (recent.length >= MAX_REGISTRATIONS_PER_IP) {
throw new TooManyRequestsException('تعداد ثبت نام‌ها بیش از حد مجاز است. لطفاً بعداً تلاش کنید.');
}
recent.push(now);
this.registrationsByIp.set(ip, recent);
}
/**
* D21 — find an active sales expert by mobile and return their trading
* Customer account, creating it on first sign-in.
@@ -779,12 +878,12 @@ export class CustomerAuthService {
*/
private async deliver(mobile: string, code: string) {
// The reference's text (ApiMobileController.php:47) with the brand changed
// to منتزر. The leading "<#> code is <CODE>" line and the trailing
// to BRAND_NAME. The leading "<#> code is <CODE>" line and the trailing
// "aMo6iFyUQaY" are the Android SMS Retriever app hash — change either and
// autofill stops working on every installed client. Do not "tidy" them.
const smsText =
`<#> code is ${code}\n` +
`رمز عبور یکبار مصرف شما جهت ورود به اپلیکیشن منتزر ${code} می باشد\n` +
`رمز عبور یکبار مصرف شما جهت ورود به اپلیکیشن ${brandName()} ${code} می باشد\n` +
`aMo6iFyUQaY`;
// persist:false — the real text carries the live code, and the admin SMS
@@ -0,0 +1,44 @@
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
/**
* Body of POST customer/auth/register.
*
* The fields of the reference's Major/CustomerController::register
* (henza_server :23-31), all required there and here.
*/
export class RegisterDto {
@IsString()
@IsNotEmpty({ message: 'نام الزامی است' })
@MaxLength(100)
fname!: string;
@IsString()
@IsNotEmpty({ message: 'نام خانوادگی الزامی است' })
@MaxLength(100)
lname!: string;
@IsString()
@IsNotEmpty({ message: 'شماره موبایل الزامی است' })
@MaxLength(20)
mobile!: string;
@IsString()
@IsNotEmpty({ message: 'نام فروشگاه الزامی است' })
@MaxLength(150)
store_name!: string;
@IsString()
@IsNotEmpty({ message: 'تلفن فروشگاه الزامی است' })
@MaxLength(20)
phone!: string;
@IsString()
@IsNotEmpty({ message: 'آدرس الزامی است' })
@MaxLength(500)
address!: string;
@IsString()
@IsNotEmpty({ message: 'شهر الزامی است' })
@MaxLength(100)
city!: string;
}
@@ -2,6 +2,7 @@ import { BadRequestException, Injectable, Logger, NotFoundException } from '@nes
import { PrismaService } from '../../prisma/prisma.service';
import { SmsService } from '../sms/sms.service';
import { CompleteProfileDto } from './dto/complete-profile.dto';
import { brandName } from '../../common/brand';
/**
* "تکمیل پروفایل" — the customer uploads their three documents.
@@ -104,15 +105,15 @@ export class CustomerProfileService {
* reference likewise queued them (SendSmsAfterCreate::dispatch, :100).
*/
private notify(mobile: string, firstName: string, lastName: string) {
// Major/CustomerController::complete:97-99, brand changed to منتزر.
// Major/CustomerController::complete:97-99, brand read from BRAND_NAME.
const message =
'منتزر\n' +
`${brandName()}\n` +
`همکار گرامی ${firstName} ${lastName}\n` +
'تکمیل پروفایل شما با موفقیت انجام شد.';
// :103-105, brand changed to منتزر.
// :103-105, brand read from BRAND_NAME.
const adminMessage =
'منتزر\n' + `مشتری با نام ${firstName} ${lastName}` + ' مدارک خود را بارگذاری کرد.';
`${brandName()}\n` + `مشتری با نام ${firstName} ${lastName}` + ' مدارک خود را بارگذاری کرد.';
const fire = (to: string, body: string) => {
void this.sms.send(to, body).catch((e: unknown) => {
+2 -2
View File
@@ -24,7 +24,7 @@ describe('IamService', () => {
IAM_ISSUER: 'https://auth.test.local',
IAM_CLIENT_ID: 'goldbank_api',
IAM_CLIENT_SECRET: 'server-secret',
IAM_TENANT_SLUG: 'montzar',
IAM_TENANT_SLUG: 'zartaj',
IAM_LOGIN_DELEGATION: 'fallback',
...overrides,
};
@@ -110,7 +110,7 @@ describe('IamService', () => {
expect(body.identifier).toBe('09120000000');
expect(body.client_id).toBe('goldbank_api');
expect(body.client_secret).toBe('server-secret');
expect(body.tenant_slug).toBe('montzar');
expect(body.tenant_slug).toBe('zartaj');
// Without this the IAM would rate-limit on this server's IP and lock
// out every admin after a handful of attempts.
expect(body.client_ip).toBe('203.0.113.7');
@@ -241,7 +241,7 @@ describe('OrdersService.createOrder — order SMS', () => {
await service.createOrder(dto, 'c1', { placedBy: 'customer' });
await settle();
const toCustomer = sent.find((s) => s.to === '09120000001');
expect(toCustomer?.body).toContain('به ثبت رسید. منتزر');
expect(toCustomer?.body).toContain('به ثبت رسید. زرتاج');
expect(toCustomer?.body).not.toContain('معلق');
});
@@ -7,6 +7,7 @@ import { AccountingHooksService } from '../accounting-sync/accounting-hooks.serv
import { CreateOrderDto, CreateOrderItemInputDto } from './dto/create-order.dto';
import { WageService } from '../pricing/wage.service';
import { SmsService } from '../sms/sms.service';
import { brandName } from '../../common/brand';
interface ComputedItem {
productVariantId: string;
@@ -242,7 +243,7 @@ export class OrdersService {
}
/**
* Legacy ApiMobileController::setOrder:733-766, brand changed to منتزر.
* Legacy ApiMobileController::setOrder:733-766, brand read from BRAND_NAME.
*
* Three audiences: the admins, the customer, and the customer's sales expert.
* Legacy's fourth message — «در سیستم ویرایش شد» (:734-737) — is
@@ -269,14 +270,14 @@ export class OrdersService {
// dropped, which is what Decimal's own string form gives.
const weight = totalGoldWeightGrams.toDecimalPlaces(2).toString();
const adminMessage = `منتزر\nسفارشی توسط ${name}\n به وزن ${weight}\nدر سیستم ثبت شد.`;
const adminMessage = `${brandName()}\nسفارشی توسط ${name}\n به وزن ${weight}\nدر سیستم ثبت شد.`;
for (const admin of this.adminMobiles()) fire(admin, adminMessage);
fire(
customer.mobile,
isSuspendedOverCreditLimit
? `سفارش شما به شماره ${orderNumber} به ثبت رسید اما به دلیل عبور از سقف مجاز گرمی در حالت معلق قرار گرفت. منتزر`
: `سفارش شما به شماره ${orderNumber} به ثبت رسید. منتزر`,
? `سفارش شما به شماره ${orderNumber} به ثبت رسید اما به دلیل عبور از سقف مجاز گرمی در حالت معلق قرار گرفت. ${brandName()}`
: `سفارش شما به شماره ${orderNumber} به ثبت رسید. ${brandName()}`,
);
if (customer.referredByAgentId) {
@@ -294,7 +295,7 @@ export class OrdersService {
if (!agent?.mobile) return;
await this.sms.send(
agent.mobile,
`منتزر\nسفارشی توسط ${customerName} به شماره ${orderNumber}و به وزن ${weight} به ثبت رسید.`,
`${brandName()}\nسفارشی توسط ${customerName} به شماره ${orderNumber}و به وزن ${weight} به ثبت رسید.`,
);
} catch (e: unknown) {
this.logger.error(`Order SMS to sales agent ${agentId} failed: ${String(e)}`);
+3 -3
View File
@@ -310,18 +310,18 @@ describe('SmsService.send', () => {
/**
* The OTP text keeps the reference's shape (ApiMobileController.php:47) with
* the brand منتزر — the first line and the trailing token are the Android SMS
* the brand زرتاج — the first line and the trailing token are the Android SMS
* Retriever hash, and autofill breaks if either changes.
*/
describe('OTP message template', () => {
const build = (code: string) =>
`<#> code is ${code}\n` +
`رمز عبور یکبار مصرف شما جهت ورود به اپلیکیشن منتزر ${code} می باشد\n` +
`رمز عبور یکبار مصرف شما جهت ورود به اپلیکیشن زرتاج ${code} می باشد\n` +
`aMo6iFyUQaY`;
it('matches the expected text byte for byte', () => {
expect(build('1234')).toBe(
'<#> code is 1234\nرمز عبور یکبار مصرف شما جهت ورود به اپلیکیشن منتزر 1234 می باشد\naMo6iFyUQaY',
'<#> code is 1234\nرمز عبور یکبار مصرف شما جهت ورود به اپلیکیشن زرتاج 1234 می باشد\naMo6iFyUQaY',
);
});
+1 -1
View File
@@ -31,7 +31,7 @@
/* Until the bundle's palette loads. */
html.theme-dark, html.theme-dark body { background-color: #15171A !important; }
</style>
<title>طلای منتزر</title>
<title>%BRAND_TITLE%</title>
</head>
<body style="background-color: #ffffff">
<div class="w-screen h-screen fixed" style="background-color: var(--c-page, #fff)"></div>

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

+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 {
+1 -1
View File
@@ -76,7 +76,7 @@ export const THEME_TOKENS = [
const SEMANTIC_LIGHT: Record<string, string> = {
page: (light as Record<string, string>).canvas,
onInk: '#FFFFFF',
heroSurface: '#14161A',
heroSurface: '#7C0035',
hairlineStrong: '#DADCE0',
photoMat: 'transparent',
photoStage: (light as Record<string, string>).hairlineSoft,
@@ -10,10 +10,10 @@
"inkSoft": "#E1E3E7",
"inkMuted": "#B2B7C0",
"inkFaint": "#8F95A0",
"wine": "#C9566F",
"wineDeep": "#A8435C",
"wineSoft": "#DC8197",
"wineWash": "#3A222B",
"wine": "#D04A80",
"wineDeep": "#A8366A",
"wineSoft": "#E486AB",
"wineWash": "#3A1A27",
"primary": "#A3B6EA",
"primaryLighten": "#B8C8F0",
"green": "#45C677",
@@ -25,7 +25,7 @@
"lightGray1": "#4A4E56",
"lightGray2": "#3A3D44",
"onInk": "#15171A",
"heroSurface": "#2C2F36",
"heroSurface": "#7C0035",
"photoMat": "#FFFFFF",
"photoStage": "#F2F3F5"
}
+4 -4
View File
@@ -27,10 +27,10 @@
"inkSoft": "#22262E",
"inkMuted": "#5B616E",
"inkFaint": "#8A909C",
"wine": "#7A1B33",
"wineDeep": "#5E1427",
"wineSoft": "#A33C55",
"wineWash": "#FBF2F4",
"wine": "#7C0035",
"wineDeep": "#5A0027",
"wineSoft": "#A0386A",
"wineWash": "#FBEFF3",
"canvas": "#FFFFFF",
"canvasSoft": "#FAFAFA",
"hairline": "#ECEDEF",
+1 -1
View File
@@ -14,7 +14,7 @@ const curve = {
px: 'px',
}
const vars = {
prefix: 'montzar',
prefix: 'zartaj',
// headers
'font-size': curve.font + curve.px,
'h1-font-size': curve.font * 2 + curve.px,
+6 -3
View File
@@ -2,6 +2,7 @@ import { fileURLToPath, URL } from 'node:url';
import { defineConfig, loadEnv } from 'vite';
import vue from '@vitejs/plugin-vue';
import { appVersion } from '../../scripts/app-version.mjs';
import { brand } from '../../brand';
import { VitePWA } from 'vite-plugin-pwa';
import { $themeLessVars } from './themeConfig/themeConfig';
import { lessAlphaPlugin, lightHex, palettePlugin } from './themeConfig/appearance';
@@ -18,6 +19,8 @@ export default defineConfig({
plugins: [
// Build number + «what changed» notes → version.json (scripts/app-version.mjs).
appVersion('user-app-v2'),
// <title> comes from the repo-root brand.ts, like every other brand text.
{ name: 'brand-title', transformIndexHtml: (html: string) => html.replace('%BRAND_TITLE%', brand.appTitle) },
vue(),
// virtual:palette.css — the light/dark values behind the theme tokens.
palettePlugin(),
@@ -32,9 +35,9 @@ export default defineConfig({
// legacy ran the SW in dev too (mode: "development")
devOptions: { enabled: true, type: 'module' },
manifest: {
name: 'Montzar',
short_name: 'Montzar',
description: 'Montzar',
name: brand.appTitle,
short_name: brand.nameEn,
description: brand.appTitle,
theme_color: lightHex.primary,
background_color: lightHex.primary,
start_url: '/',
+1 -1
View File
@@ -31,7 +31,7 @@
/* Until the bundle's palette loads. */
html.theme-dark, html.theme-dark body { background-color: #15171A !important; }
</style>
<title>طلای منتزر</title>
<title>%BRAND_TITLE%</title>
</head>
<body style="background-color: #fff">
<div class="w-screen h-screen fixed" style="background-color: var(--c-page, #fff)"></div>

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

+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">
+1 -1
View File
@@ -14,7 +14,7 @@ const curve = {
px: 'px',
}
const vars = {
prefix: 'montzar',
prefix: 'zartaj',
// headers
'font-size': curve.font + curve.px,
'h1-font-size': curve.font * 2 + curve.px,
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/app.vue","./src/env.d.ts","./src/main.ts","./src/sw.ts","./src/api/cachedreads.ts","./src/api/client.ts","./src/components/appupdategate.vue","./src/components/dialogvant.vue","./src/components/dialogvantonebutton.vue","./src/components/emptystate.vue","./src/components/spinner.vue","./src/components/expert/actingasbar.vue","./src/components/home/categoryrail.vue","./src/components/home/ratestrip.vue","./src/components/home/sectionhead.vue","./src/components/home/showcasecard.vue","./src/components/home/showcaseslider.vue","./src/components/icon/icon.vue","./src/components/input/otpinput.vue","./src/components/input/singleotpinput.vue","./src/components/product/cartitem.vue","./src/components/product/galleryslider.vue","./src/components/product/ordercard.vue","./src/components/product/orderitemcard.vue","./src/components/product/productcard.vue","./src/components/product/productmorph.vue","./src/components/product/singleproduct.vue","./src/components/search/filters.vue","./src/components/search/searchinput.vue","./src/components/ui/container.vue","./src/components/ui/dropdown.vue","./src/components/ui/navigation.vue","./src/components/ui/pagebar.vue","./src/components/ui/scrollfetchsearch.vue","./src/components/ui/toastcard.vue","./src/components/ui/toasthost.vue","./src/components/ui/toggleswitch.vue","./src/components/upload/singleimageuploader.vue","./src/composables/useappupdate.ts","./src/composables/useorderitemproduct.ts","./src/composables/usepagetransition.ts","./src/composables/useproduct.ts","./src/composables/useproductdetail.ts","./src/composables/useproductmorph.ts","./src/composables/useproducts.ts","./src/composables/usereveal.ts","./src/composables/usetoast.ts","./src/layout/fullpagelayout.vue","./src/layout/masterlayout.vue","./src/router/index.ts","./src/stores/appearancestore.ts","./src/stores/cartstore.ts","./src/stores/catalogstore.ts","./src/stores/configstore.ts","./src/stores/favoritesstore.ts","./src/stores/filtersstore.ts","./src/stores/navstore.ts","./src/stores/querycache.ts","./src/stores/session.ts","./src/stores/transitionstore.ts","./src/stores/wagequotestore.ts","./src/stores/windowdatastore.ts","./src/types/moment-jalaali-shim.d.ts","./src/types/swiper-shim.d.ts","./src/utils/checkaccess.ts","./src/utils/filters.ts","./src/utils/orderstatus.ts","./src/utils/validator.ts","./src/views/cartview.vue","./src/views/homeview.vue","./src/views/newestview.vue","./src/views/expert/childordersview.vue","./src/views/expert/customerchoiceview.vue","./src/views/report/reportdocument.vue","./src/views/report/reportview.vue","./src/views/search/collectionview.vue","./src/views/search/searchview.vue","./src/views/user/appearanceview.vue","./src/views/user/completeprofileview.vue","./src/views/user/dashboardview.vue","./src/views/user/favoritesview.vue","./src/views/user/iamcallback.vue","./src/views/user/loginregisterview.vue","./src/views/user/loginview.vue","./src/views/user/notificationsview.vue","./src/views/user/ordersview.vue","./src/views/user/profileview.vue","./src/views/user/registerview.vue","./src/views/user/transactionsview.vue"],"version":"5.9.3"}
{"root":["./src/app.vue","./src/brand.ts","./src/env.d.ts","./src/main.ts","./src/sw.ts","./src/api/cachedreads.ts","./src/api/client.ts","./src/components/appupdategate.vue","./src/components/dialogvant.vue","./src/components/dialogvantonebutton.vue","./src/components/emptystate.vue","./src/components/spinner.vue","./src/components/expert/actingasbar.vue","./src/components/home/categoryrail.vue","./src/components/home/ratestrip.vue","./src/components/home/sectionhead.vue","./src/components/home/showcasecard.vue","./src/components/home/showcaseslider.vue","./src/components/icon/icon.vue","./src/components/input/otpinput.vue","./src/components/input/singleotpinput.vue","./src/components/product/cartitem.vue","./src/components/product/galleryslider.vue","./src/components/product/ordercard.vue","./src/components/product/orderitemcard.vue","./src/components/product/productcard.vue","./src/components/product/productmorph.vue","./src/components/product/singleproduct.vue","./src/components/search/filters.vue","./src/components/search/searchinput.vue","./src/components/ui/container.vue","./src/components/ui/dropdown.vue","./src/components/ui/navigation.vue","./src/components/ui/pagebar.vue","./src/components/ui/scrollfetchsearch.vue","./src/components/ui/toastcard.vue","./src/components/ui/toasthost.vue","./src/components/ui/toggleswitch.vue","./src/components/upload/singleimageuploader.vue","./src/composables/useappupdate.ts","./src/composables/useorderitemproduct.ts","./src/composables/usepagetransition.ts","./src/composables/useproduct.ts","./src/composables/useproductdetail.ts","./src/composables/useproductmorph.ts","./src/composables/useproducts.ts","./src/composables/usereveal.ts","./src/composables/usetoast.ts","./src/layout/fullpagelayout.vue","./src/layout/masterlayout.vue","./src/router/index.ts","./src/stores/appearancestore.ts","./src/stores/cartstore.ts","./src/stores/catalogstore.ts","./src/stores/configstore.ts","./src/stores/favoritesstore.ts","./src/stores/filtersstore.ts","./src/stores/navstore.ts","./src/stores/querycache.ts","./src/stores/session.ts","./src/stores/transitionstore.ts","./src/stores/wagequotestore.ts","./src/stores/windowdatastore.ts","./src/types/moment-jalaali-shim.d.ts","./src/types/swiper-shim.d.ts","./src/utils/checkaccess.ts","./src/utils/filters.ts","./src/utils/orderstatus.ts","./src/utils/validator.ts","./src/views/cartview.vue","./src/views/homeview.vue","./src/views/newestview.vue","./src/views/expert/childordersview.vue","./src/views/expert/customerchoiceview.vue","./src/views/report/reportdocument.vue","./src/views/report/reportview.vue","./src/views/search/collectionview.vue","./src/views/search/searchview.vue","./src/views/user/appearanceview.vue","./src/views/user/completeprofileview.vue","./src/views/user/dashboardview.vue","./src/views/user/favoritesview.vue","./src/views/user/iamcallback.vue","./src/views/user/loginregisterview.vue","./src/views/user/loginview.vue","./src/views/user/notificationsview.vue","./src/views/user/ordersview.vue","./src/views/user/profileview.vue","./src/views/user/registerview.vue","./src/views/user/transactionsview.vue"],"version":"5.9.3"}
+6 -3
View File
@@ -2,6 +2,7 @@ import { fileURLToPath, URL } from 'node:url';
import { defineConfig, loadEnv } from 'vite';
import vue from '@vitejs/plugin-vue';
import { appVersion } from '../../scripts/app-version.mjs';
import { brand } from '../../brand';
import { VitePWA } from 'vite-plugin-pwa';
import { $themeLessVars } from './themeConfig/themeConfig';
import { lessAlphaPlugin, lightHex, palettePlugin } from './themeConfig/appearance';
@@ -18,6 +19,8 @@ export default defineConfig({
plugins: [
// Build number + «what changed» notes → version.json (scripts/app-version.mjs).
appVersion('user-app'),
// <title> comes from the repo-root brand.ts, like every other brand text.
{ name: 'brand-title', transformIndexHtml: (html: string) => html.replace('%BRAND_TITLE%', brand.appTitle) },
vue(),
// virtual:palette.css — the light/dark values behind the theme tokens.
palettePlugin(),
@@ -32,9 +35,9 @@ export default defineConfig({
// legacy ran the SW in dev too (mode: "development")
devOptions: { enabled: true, type: 'module' },
manifest: {
name: 'Montzar',
short_name: 'Montzar',
description: 'Montzar',
name: brand.appTitle,
short_name: brand.nameEn,
description: brand.appTitle,
theme_color: lightHex.primary,
background_color: lightHex.primary,
start_url: '/',
+25
View File
@@ -0,0 +1,25 @@
/**
* The brand, in ONE place for the three frontends (admin panel, user-app,
* user-app-v2): page titles, PWA install name, login headers, welcome toasts,
* help texts and the public product link. Each app re-exports this from its own
* src/brand.ts, and its vite.config reads it for <title> and the PWA manifest.
*
* The server's SMS do NOT read this file — they use BRAND_NAME / BRAND_SITE_URL
* from the environment (apps/api/src/common/brand.ts), so changing the SMS
* wording needs only an env change and a restart.
*
* Everything else a rebrand touches (logo images, domain, server env) is listed
* in docs/BRANDING.md.
*/
export const brand = {
/** Persian name, used inside sentences: «ورود به زرتاج». */
name: 'زرتاج',
/** Latin name: PWA install name, alt texts, footer. */
nameEn: 'Zartaj',
/** Browser tab / home-screen title of the customer apps. */
appTitle: 'طلای زرتاج',
/** Browser tab title of the admin panel. */
adminTitle: 'زرتاج — پنل مدیریت',
/** Public customer site; product links in the admin panel point here. */
siteUrl: 'https://bnk.montzar.ir',
} as const;
+5
View File
@@ -72,6 +72,11 @@ services:
# Admin mobiles told when a customer completes their profile (D22).
# Blank = nobody is notified; the upload still succeeds.
- PROFILE_ADMIN_MOBILES=${PROFILE_ADMIN_MOBILES:-}
# Admin mobiles told when a customer places an order. Blank = nobody.
- ORDER_ADMIN_MOBILES=${ORDER_ADMIN_MOBILES:-}
# Brand in every SMS. Blank = the default in apps/api/src/common/brand.ts.
- BRAND_NAME=${BRAND_NAME:-}
- BRAND_SITE_URL=${BRAND_SITE_URL:-}
# Central IAM. Read at process start from this container only.
# Frontends compute callback URLs from the browser origin, so these
# never belong in a Vite build. Blank stays off (env.ts treats "").
+67
View File
@@ -0,0 +1,67 @@
# طرح: ستون مانده‌ی حسابداری در لیست مشتریان عمده
تاریخ: ۱۴۰۵/۰۷/۲۲ — وضعیت: **پیاده‌سازی شد** (۱۴۰۵/۰۷/۲۲) — تصمیم‌ها: کلمه‌ی بدهکار/بستانکار؛ ستون‌های فعلی هنگام اتصال «بدهی داخل اپ» نام می‌گیرند؛ اپ کارشناس فروش بعداً
## مشکل
در لیست مشتریان عمده، ستون‌های «بدهی گرمی» و «بدهی ریالی» بدهیِ **داخل اپ** را نشان می‌دهند
(جمع سفارش‌هایی که در منتزر ثبت شده). مشتری‌هایی که از نرم‌افزار حسابداری آمده‌اند هنوز در اپ
سفارشی ندارند، پس این ستون‌ها برایشان صفر است.
مانده‌ی واقعیِ حسابداری درست خوانده و ذخیره می‌شود (بررسی ۱۴۰۵/۰۷/۲۲: هر ۷۳۳ مشتری واردشده از زرین
مانده دارند، ۳۸۴ نفر غیرصفر؛ مثلاً محمدرضا لطیفی کد ۹۶۱: ۷۲٫۲۸ گرم بدهکار) ولی فقط در صفحه‌ی
«مشاهده اطلاعات» هر مشتری دیده می‌شود، نه در لیست.
## راه‌حل: ستون‌های پویا
| وضعیت اتصال حسابداری | چه نمایش داده می‌شود |
|---|---|
| به **زرین** وصل است | دو ستون «مانده‌ی زرین (گرم)» و «مانده‌ی زرین (ریال)» |
| به **ته‌حساب** وصل است | دو ستون «مانده‌ی ته‌حساب (گرم)» و «مانده‌ی ته‌حساب (ریال)» |
| به هیچ‌کدام وصل نیست (یا اتصال خاموش است) | این دو ستون اصلاً نشان داده نمی‌شوند |
نام نرم‌افزار و وصل‌بودن از همان‌جایی خوانده می‌شود که دکمه‌ی «سینک با زرین» بالای لیست می‌خواند
(`admin/accounting-status`)، پس دکمه و ستون‌ها همیشه با هم هماهنگ‌اند.
ستون‌های فعلی «بدهی گرمی / ریالی» (بدهی داخل اپ) **دست نمی‌خورند**، چون سقف اعتبار هنوز با آن‌ها
حساب می‌شود.
## نحوه‌ی نمایش هر خانه
| حالت | نمایش |
|---|---|
| مشتری بدهکار است | عدد + «بدهکار» به رنگ قرمز — مثلاً `۷۲٫۲۸ بدهکار` |
| مشتری طلبکار است | عدد + «بستانکار» به رنگ سبز — مثلاً `۱۶۷۳٫۹۳ بستانکار` |
| مانده صفر است | `۰` |
| مشتری به حسابی در نرم‌افزار وصل نیست | `—` |
با بردن موس روی عدد، زمان آخرین به‌روزرسانی مانده نشان داده می‌شود (چون اگر «سینک مانده» خاموش
باشد، عدد فقط با زدن دکمه‌ی سینک تازه می‌شود).
## کجاها
هر پنج لیست مشتریان عمده (عمده، بنکداران، مغازه‌داران حضوری، تأییدنشده، بایگانی) چون همه یک جدول
مشترک دارند. مرتب‌سازی (مشتری‌های وصل‌نشده همیشه آخر) و فیلتر عددی روی ستون‌های جدید کار می‌کند. (دکمه‌ی چاپ جدول از قبل کاری انجام نمی‌دهد؛ ربطی به این طرح ندارد.)
## تغییرات فنی (خلاصه)
- **API** — `admin-customers.service.ts`: در خروجی هر ردیف سه فیلد `acc_remain`، `acc_rial_remain`،
`acc_at` (از `accountingGoldBalance` / `accountingRialBalance` / `accountingBalanceAt`) و `acc_linked`؛
و نگاشت مرتب‌سازی/فیلتر برای دو ستون اول. هیچ تغییری در دفتر حساب و محاسبه‌ی بدهی اپ نیست.
- **پنل** — `omdeListModel.ts` / `OmdeCustomerList.vue`: بعد از خواندن وضعیت اتصال، اگر وصل بود دو ستون
با نام نرم‌افزار اضافه می‌شوند؛ اسلات نمایش بدهکار/بستانکار.
- بدون migration و بدون تغییر در پایگاه داده.
## سؤال‌ها از شما
1. **نمایش بدهکار/بستانکار:** پیشنهاد من همان جدول بالاست (عدد مثبت + کلمه‌ی «بدهکار» قرمز یا
«بستانکار» سبز). جایگزین: فقط عدد با علامت منفی برای طلبکار — کوتاه‌تر است ولی ۲۴۸ مشتری
طلبکار طلا داریم و علامت منفی راحت اشتباه خوانده می‌شود. **پیشنهاد: کلمه‌ی بدهکار/بستانکار.**
2. **ستون‌های فعلی «بدهی گرمی / ریالی» بمانند؟** وقتی حسابداری وصل است، این ستون‌ها فقط بدهی
سفارش‌های داخل اپ را نشان می‌دهند و ممکن است گیج‌کننده باشند. سه راه:
الف) بمانند کنار ستون‌های جدید (کنترل سقف اعتبار هم دیده شود)؛
ب) وقتی وصل است پنهان شوند و فقط ستون حسابداری بماند؛
ج) بمانند ولی اسمشان «بدهی داخل اپ» شود.
**پیشنهاد: ج** — چیزی پنهان نمی‌شود و اشتباه هم خوانده نمی‌شوند.
3. **اپ کارشناس فروش** هم ستون «بدهی» دارد و همین مشکل را دارد. همین الان آن را هم درست کنیم یا
بعداً؟ **پیشنهاد: بعداً، جدا** — این طرح فقط پنل مدیریت.
+1 -1
View File
@@ -345,7 +345,7 @@ schema جدید **یک مدل `Order`** دارد.
### باگ/کوییرک legacy که وفادار پورت شد
| # | مورد | محل | وضعیت |
|---|---|---|---|
| 14 | در `OmdeNonVerifiedList` هر دو شاخه‌ی popconfirm مرحله‌ی یک `rejectStepOne` را صدا می‌زنند — یعنی از آن صفحه هرگز نمی‌شود تایید کرد | `OmdeNonVerifiedList.vue:64` | **عیناً پورت شد** پشت prop به نام `rejectOnlyStepOne` |
| 14 | در `OmdeNonVerifiedList` هر دو شاخه‌ی popconfirm مرحله‌ی یک `rejectStepOne` را صدا می‌زنند — یعنی از آن صفحه هرگز نمی‌شود تایید کرد | `OmdeNonVerifiedList.vue:64` | ابتدا عیناً پورت شد؛ **۱۴۰۵/۰۷/۲۲ (2026-10-14) رفع شد** — «تایید» حالا تایید می‌کند |
| 15 | در منوی «حساب کاربری»، آیکون از `getStsIcon` (وضعیت سفارش) گرفته می‌شود ولی رنگش از `getActiveIcon` | `OmdeList.vue:124` | عیناً پورت شد |
| 16 | `ReagentListModel` ستون `name` را دو بار اضافه می‌کند (یکی نمایان، یکی مخفی) | `ReagentListModel.js:10-11` | عیناً پورت شد |
| 17 | tooltip «فعال سازی سفارش سفارش» (کلمه تکراری) | `ReagentList.vue:51` | عیناً پورت شد |
+61
View File
@@ -0,0 +1,61 @@
# جابه‌جایی مشکی و زرشکی در اپ مشتری (user-app-v2)
**وضعیت:** تأیید شد و انجام شد (2026-10-14). زرشکی دقیق برند: `#7C0035`. خروج ← مشکی؛ کارت‌های بزرگ (موجودی، دعوت، نوار نرخ) ← خودِ زرشکی برند `#7C0035` در هر دو حالت روشن و تیره (به درخواست صاحب کار، به‌جای زرشکی تیره).
**تاریخ:** ۱۴۰۵/۰۷/۲۲ (2026-10-14)
## هدف
- رنگ اصلی اپ از **مشکی** به **زرشکی** تغییر کند و مشکی نقش فرعی داشته باشد.
- مشکی کمتر دیده شود و زرشکی در اولویت باشد.
- **ظاهر و ساختار صفحه‌ها تغییر نمی‌کند**؛ فقط رنگ همان دکمه‌ها و کارت‌های فعلی عوض می‌شود.
## خارج از این کار (دست نمی‌خورد)
- **رنگ متن‌ها**: متن‌ها همان مشکی/خاکستری فعلی می‌مانند. اگر متن زرشکی شود، خواندنش سخت می‌شود.
- **سرمه‌ای و برنزیِ باقی‌مانده از اپ قدیمی**: این‌ها شامل نوار وضعیت گوشی، نوار نرخ طلا، اسپینر، `SheetArt`، `ActingAsBar` و پنجرهٔ به‌روزرسانی هستند و اگر خواستید، بعداً جداگانه انجام می‌شوند.
- اپ قدیمی `user-app` و پنل مدیریت.
## وضعیت فعلی
در این اپ، **«مشکیِ پُر»** (`@ink`) رنگ اصلی است و **زرشکی** (`@wine`) فقط برای تأکید، حالت فشرده، نشان‌ها و دکمهٔ خروج استفاده می‌شود.
## تغییرات
### ۱) هر جا که پس‌زمینه مشکیِ پُر است ← زرشکی (متن روی آن سفید)
این‌ها دکمه‌های اصلی، گزینه‌های انتخاب‌شده و کلیدها هستند:
| بخش | فایل |
|---|---|
| دکمه‌های سبد خرید (۴ مورد) | `views/CartView.vue` |
| دکمهٔ ورود | `views/user/LoginView.vue` |
| دکمهٔ ثبت‌نام | `views/user/RegisterView.vue` |
| گزینهٔ انتخاب‌شدهٔ وزن/سایز و دکمهٔ صفحهٔ محصول | `components/product/SingleProduct.vue` |
| برچسب روی کارت محصول | `components/product/ProductCard.vue` |
| چیپ‌های جستجو و فیلتر | `views/search/SearchView.vue`، `components/search/Filters.vue` |
| فیلترهای سفارش‌ها | `views/user/OrdersView.vue` |
| کلید روشن/خاموش | `components/ui/ToggleSwitch.vue` |
| بازشدن منوی کشویی | `components/ui/DropDown.vue` |
| دکمهٔ اصلی پنجره‌های پایین صفحه | `components/ui/IslandSheet.vue` |
| علاقه‌مندی‌ها، صفحهٔ تنظیمات ظاهر | `FavoritesView.vue`، `AppearanceView.vue` |
| دکمه‌های بخش کارشناس | `expert/ChildOrdersView.vue`، `expert/CustomerChoiceView.vue` |
حاشیهٔ گزینهٔ انتخاب‌شده (در تنظیمات ظاهر) و حاشیهٔ خانهٔ کد تأیید (OTP) در هنگام تایپ هم از مشکی به زرشکی تغییر می‌کنند.
### ۲) جاهایی که زرشکی قبلاً «رنگ دوم» بوده ← زرشکی تیره
برخی جاها زرشکی برای این بوده که از مشکیِ اصلی متمایز شوند؛ مثلاً چیپ فیلترِ فعال در جستجو و مرحلهٔ فعال سفارش. اکنون که رنگ اصلی زرشکی شده، این موارد **زرشکی تیره** (`@wineDeep`) می‌شوند تا تفاوت حفظ شود و مشکی هم وارد کار نشود.
### ۳) کارت‌های تیرهٔ بزرگ ← زرشکی تیره
کارت موجودی و دعوت به ورود در داشبورد، و نوار نرخ در صفحهٔ اصلی، اکنون مشکی هستند. با تغییر **یک مقدار** (`heroSurface`)، هر سه زرشکی تیره می‌شوند و گرادیان کارت موجودی از زرشکی تیره به زرشکی می‌رود.
### ۴) حالت تیره (Dark mode)
در حالت تیره همان قاعده برقرار است: دکمه‌ها با زرشکیِ روشن‌ترِ حالت تیره نمایش داده می‌شوند و متن روی آن‌ها سفید است. کارت‌های بزرگ هم زرشکی خیلی تیره می‌شوند، نه خاکستری.
## سؤال‌ها از شما
۱. **دکمهٔ «خروج»** اکنون زرشکی است تا از بقیه جدا باشد. اگر همه‌چیز زرشکی شود، این دکمه دیگر متمایز نیست.
- **پیشنهاد من:** خروج **مشکی** شود (عملاً جای دو رنگ عوض می‌شود). این یکی از معدود جاهایی است که مشکی می‌ماند.
- گزینهٔ دیگر: قرمز (رنگ خطا).
۲. **کارت‌های بزرگ داشبورد** (بند ۳): زرشکی تیره شوند یا مشکی بمانند؟
- **پیشنهاد من:** زرشکی تیره، چون خواستید مشکی کمتر دیده شود. نتیجه این است که صفحهٔ داشبورد به‌وضوح زرشکی دیده می‌شود.
## بعد از انجام
- بیلد اپ و بررسی چشمی صفحه‌های اصلی، سبد خرید، محصول، جستجو، ورود و داشبورد، در هر دو حالت روشن و تیره.
- یک خط یادداشت انتشار برای کاربر: «اپ: رنگ اصلی برنامه زرشکی شد».
+51
View File
@@ -0,0 +1,51 @@
# برند پروژه (زرتاج) — چک‌لیست تغییر نام
نام برند **زرتاج / Zartaj** است. برای عوض کردن نام دوباره **لازم نیست کل کد را بگردید**؛ فقط موارد همین فهرست.
## ۱. جاهایی که باید عوض شوند (فقط همین‌ها)
| چه چیزی | کجا | توضیح |
|---|---|---|
| نام فارسی، نام لاتین، عنوان تب مرورگر، آدرس سایت | [`brand.ts`](../brand.ts) در ریشهٔ پروژه | پنل ادمین، اپ مشتری و اپ مشتری v2 همه از همین یک فایل می‌خوانند: عنوان صفحه، نام نصب PWA، عنوان صفحهٔ ورود، پیام خوش‌آمد، متن‌های راهنما، لینک محصول. |
| نام برند در **همهٔ پیامک‌ها** | متغیر محیطی `BRAND_NAME` روی سرور API | خالی باشد = «زرتاج» (پیش‌فرض در [`apps/api/src/common/brand.ts`](../apps/api/src/common/brand.ts)). بعد از تغییر فقط API را ری‌استارت کنید. |
| لینک سایت در پیامک خوش‌آمد | متغیر محیطی `BRAND_SITE_URL` روی سرور API | پیش‌فرض فعلاً `https://bnk.montzar.ir` است (دامنهٔ زنده). |
| لوگو | فایل‌های تصویر با نام ثابت `brand-*` (پایین) | نام فایل‌ها عمومی است؛ فقط خود تصویر را با همان نام جایگزین کنید. |
### فایل‌های لوگو
- `apps/user-app/public/img/brand-logo.png` و `.webp`
- `apps/user-app/src/assets/img/brand-logo.png`
- `apps/user-app-v2/public/img/brand-logo.png` و `.webp`
- `apps/user-app-v2/src/assets/img/brand-logo.png`
- `apps/admin-panel/src/assets/images/logo/brand-icon.png` و `.webp`، `brand-horizontal.png`
- آیکون‌های PWA و favicon: `apps/*/public/icon-192.png`، `icon-512.png`، `favicon.*`
- اسکریپت ساخت همهٔ این‌ها از یک تصویر: [`scripts/generate-brand-icons.py`](../scripts/generate-brand-icons.py) (مسیر تصویر ورودی را در خط اول آن عوض کنید)
### بعد از تغییر
1. `dist` هر سه فرانت و `apps/api/release` را دوباره بسازید (طبق روال انتشار).
2. اگر در دیتابیس فعلی برندی به نام قدیمی در «برندهای محصول» هست، از پنل ادمین ویرایشش کنید (seed فقط دیتابیس خالی را پر می‌کند).
## ۲. عمداً عوض نشده‌اند (نام داخلی، دیده نمی‌شوند)
این‌ها به کاربر نمایش داده نمی‌شوند و تغییرشان خرابی می‌آورد:
| مورد | چرا عوض نشد |
|---|---|
| کلیدهای مرورگر مثل `gold-bank:token` | تغییر = خروج همهٔ مشتری‌ها از اپ |
| جدول `_goldbank_migrations`، نام دیتابیس `goldbank`، سرویس داکر `goldbank-api` | تغییر = خراب شدن مایگریشن و استقرار روی سرور |
| نام پکیج‌ها `@gold-bank/*` و پوشهٔ پروژه | فقط نام فنی |
| شناسهٔ برند محصول `brand-montzar` در دیتابیس | کلید اصلی است و محصولات به آن وصل‌اند؛ عنوانش به «زرتاج طلا» تغییر کرد |
| پوشه‌های `henza_admin`، `henza_pwa`، `henza_server` و ارجاع‌های کد به آن‌ها | کد سیستم قدیمی، فقط برای مرجع |
| آدرس‌های `henzagold.com` در توضیحات کد | سابقهٔ سیستم قدیمی، اجرا نمی‌شوند |
| پوشهٔ `docs/` (سوابق) | سابقهٔ کار است، نه متن برنامه |
## ۳. بررسی سریع
برای اطمینان از اینکه نام قدیمی جایی در متن‌های برنامه نمانده:
```bash
grep -rn "منتزر\|Montzar\|هنزا" apps/*/src apps/*/index.html apps/api/prisma/seed.ts
```
خروجی خالی یعنی تمیز است.
@@ -10,12 +10,12 @@ root = Path(r"C:\App\gold-bank")
(root / "apps/user-app/src/assets/img").mkdir(parents=True, exist_ok=True)
(root / "apps/admin-panel/src/assets/images/logo").mkdir(parents=True, exist_ok=True)
img.save(root / "apps/user-app/public/img/montzar-logo.webp", "WEBP")
img.save(root / "apps/user-app/public/img/montzar-logo.png", "PNG")
img.save(root / "apps/user-app/src/assets/img/montzar-logo.png", "PNG")
img.save(root / "apps/admin-panel/src/assets/images/logo/montzar-icon.webp", "WEBP")
img.save(root / "apps/admin-panel/src/assets/images/logo/montzar-icon.png", "PNG")
img.save(root / "apps/admin-panel/src/assets/images/logo/montzar-horizontal.png", "PNG")
img.save(root / "apps/user-app/public/img/brand-logo.webp", "WEBP")
img.save(root / "apps/user-app/public/img/brand-logo.png", "PNG")
img.save(root / "apps/user-app/src/assets/img/brand-logo.png", "PNG")
img.save(root / "apps/admin-panel/src/assets/images/logo/brand-icon.webp", "WEBP")
img.save(root / "apps/admin-panel/src/assets/images/logo/brand-icon.png", "PNG")
img.save(root / "apps/admin-panel/src/assets/images/logo/brand-horizontal.png", "PNG")
for size, path in [
(192, root / "apps/user-app/public/icon-192.png"),