feat: move brand name to one config and redesign the accounting card
اپ: نام برند در همهی صفحهها و پیامها یکسان شده و از یک تنظیم مرکزی خوانده میشود. اپ: کارت حسابداری در صفحهی مشتری بازطراحی شد؛ ماندهها و وضعیت لینک واضحتر نمایش داده میشوند. پنل: کارت حسابداری در پروندهی مشتری بازطراحی شد؛ ماندهها، آخرین ارسال و فرم لینک دستی واضحتر شدهاند. پنل: ستون «کد» در جدولها بهطور پیشفرض پنهان است و از انتخاب ستونها قابل نمایش است. پنل: تغییر نمایش ستونها در جدولها بلافاصله اعمال میشود. Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
@@ -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=
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 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>
|
||||
|
||||
<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>
|
||||
<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 size="small" danger>حذف لینک</a-button>
|
||||
<a-button type="link" danger size="small" class="unlink">حذف لینک فعلی</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</div>
|
||||
</div>
|
||||
</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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ? 'فعال' : 'غیر فعال' }}
|
||||
<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>
|
||||
</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>
|
||||
<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>
|
||||
</a-card>
|
||||
<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>
|
||||
|
||||
<!-- 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
|
||||
<!-- 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>
|
||||
<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>
|
||||
|
||||
<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="{ pageSize: 10 }"
|
||||
:pagination="pagination(10, orders.length)"
|
||||
:locale="{ emptyText: 'سفارشی برای این مشتری ثبت نشده است' }"
|
||||
row-key="id"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'caj'">{{ jdate(record.caj) }}</template>
|
||||
<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" tab="سند های مالی">
|
||||
<a-tab-pane key="documents">
|
||||
<template #tab>
|
||||
سندهای مالی <span class="tab-count">{{ num(documents.length, 0) }}</span>
|
||||
</template>
|
||||
<a-table
|
||||
size="small"
|
||||
size="middle"
|
||||
:data-source="documents"
|
||||
:columns="documentColumns"
|
||||
:pagination="{ pageSize: 10 }"
|
||||
:pagination="pagination(10, documents.length)"
|
||||
:locale="{ emptyText: 'سندی برای این مشتری ثبت نشده است' }"
|
||||
row-key="id"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'confirmed'">
|
||||
<a-tag :color="record.confirmed === 1 ? 'green' : ''">
|
||||
{{ record.confirmed === 1 ? 'تایید شده' : 'تایید نشده' }}
|
||||
</a-tag>
|
||||
<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" tab="کیف پول">
|
||||
<a-tab-pane v-if="showWallet" key="wallet">
|
||||
<template #tab>
|
||||
کیف پول <span class="tab-count">{{ num(wallet.length, 0) }}</span>
|
||||
</template>
|
||||
<a-table
|
||||
size="small"
|
||||
size="middle"
|
||||
:data-source="wallet"
|
||||
:columns="walletColumns"
|
||||
:pagination="false"
|
||||
:pagination="pagination(10, wallet.length)"
|
||||
:locale="{ emptyText: 'اعتباری در کیف پول ثبت نشده است' }"
|
||||
row-key="id"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'caj'">{{ jdate(record.caj) }}</template>
|
||||
<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'">
|
||||
{{ jdate(record.expires_at) }}
|
||||
{{ record.expires_at ? jdate(record.expires_at) : '—' }}
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane v-if="showActivity" key="activity" tab="فعالیت ها">
|
||||
<a-tab-pane v-if="showActivity" key="activity">
|
||||
<template #tab>
|
||||
فعالیتها <span class="tab-count">{{ num(activity.length, 0) }}</span>
|
||||
</template>
|
||||
<a-table
|
||||
size="small"
|
||||
size="middle"
|
||||
:data-source="activity"
|
||||
:columns="activityColumns"
|
||||
:pagination="{ pageSize: 15 }"
|
||||
:pagination="pagination(15, activity.length)"
|
||||
:locale="{ emptyText: 'فعالیتی ثبت نشده است' }"
|
||||
row-key="id"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'caj'">{{ jdateTime(record.caj) }}</template>
|
||||
<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);
|
||||
}
|
||||
|
||||
@media (max-width: 575px) {
|
||||
.mt-4 {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.stats :deep(.ant-tag) {
|
||||
.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));
|
||||
}
|
||||
.detail-tabs :deep(.ant-tabs-nav) {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
@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) {
|
||||
.customer-detail {
|
||||
gap: 12px;
|
||||
}
|
||||
.hero-avatar {
|
||||
width: 48px !important;
|
||||
height: 48px !important;
|
||||
line-height: 48px !important;
|
||||
font-size: 20px;
|
||||
}
|
||||
.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>
|
||||
|
||||
|
||||
@@ -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: 'دستی',
|
||||
};
|
||||
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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) {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)}`);
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 |
@@ -260,6 +260,17 @@ export interface GoldPriceQuote {
|
||||
quotedAt: string;
|
||||
}
|
||||
|
||||
/** Body of POST customer/auth/register — the app register form, field for field. */
|
||||
export interface RegisterBody {
|
||||
fname: string;
|
||||
lname: string;
|
||||
mobile: string;
|
||||
store_name: string;
|
||||
phone: string;
|
||||
address: string;
|
||||
city: string;
|
||||
}
|
||||
|
||||
/** One admin-uploaded home banner slide — raw shape as saved by the admin
|
||||
* panel's manage/Const.vue "اسلایدر موبایل" tab (image + optional link). */
|
||||
export interface AdminSlide {
|
||||
@@ -444,6 +455,13 @@ export const api = {
|
||||
* which used to be derived by scanning the whole catalogue in the browser.
|
||||
*/
|
||||
getCatalogFacets: () => request<CatalogFacets>('/catalog/products/facets'),
|
||||
/** App self-registration. The new customer waits for admin approval before signing in. */
|
||||
register: (body: RegisterBody) =>
|
||||
request<{ ok: boolean }>('/customer/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
|
||||
/**
|
||||
* Step 1 of login — ports henza_server ApiMobileController::userAuth.
|
||||
* Always resolves `{ sent: true }`, even for an unregistered number, so the
|
||||
|
||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
@@ -704,6 +704,24 @@ html.motion-reduced .toast {
|
||||
background: alpha(@canvas, 97%) !important;
|
||||
}
|
||||
|
||||
/* Browser autofill paints its own pale-blue fill (and dark text) over the
|
||||
field, which can't be overridden with `background`. Cover it with an inset
|
||||
shadow in the field's own colour instead. Inside an ant affix wrapper only
|
||||
the inner <input> gets autofilled, so without this it shows as a blue box
|
||||
floating inside the bordered wrapper. */
|
||||
input:-webkit-autofill,
|
||||
input:-webkit-autofill:hover,
|
||||
input:-webkit-autofill:focus,
|
||||
textarea:-webkit-autofill,
|
||||
textarea:-webkit-autofill:hover,
|
||||
textarea:-webkit-autofill:focus {
|
||||
-webkit-text-fill-color: @ink;
|
||||
caret-color: @ink;
|
||||
-webkit-box-shadow: 0 0 0 1000px @canvas inset !important;
|
||||
box-shadow: 0 0 0 1000px @canvas inset !important;
|
||||
transition: background-color 9999s ease-out 0s;
|
||||
}
|
||||
|
||||
/* ── Dark theme: third-party surfaces ──────────────────────────
|
||||
ant-design-vue compiles its theme at build time with black text
|
||||
(fade(@black, 85%)) and white fills; only its surface backgrounds were
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
// The brand lives in the repo-root brand.ts, shared by all three frontends.
|
||||
export { brand } from '../../../brand';
|
||||
@@ -2,7 +2,7 @@
|
||||
<template>
|
||||
<Spin :spinning="spinning" :tip="tip" :size="(size as 'small' | 'default' | 'large')" :delay="delay" :class="fixed ? '!max-h-screen !fixed' : ''">
|
||||
<template #indicator>
|
||||
<img v-if="!indicator" src="/img/montzar-logo.png" alt="Montzar" class="spinner-brand" width="48" height="48" />
|
||||
<img v-if="!indicator" src="/img/brand-logo.png" :alt="brand.nameEn" class="spinner-brand" width="48" height="48" />
|
||||
<!-- keep-motion: a working spinner, exempt from reduced motion. -->
|
||||
<div class="loading keep-motion" v-else>
|
||||
<div class="effect-1 effects"></div>
|
||||
@@ -17,6 +17,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { brand } from '../brand'
|
||||
import { Spin } from 'ant-design-vue'
|
||||
|
||||
withDefaults(
|
||||
|
||||
@@ -6,10 +6,8 @@
|
||||
module drags in its own CSS that fights the app's RTL/less theming, and the
|
||||
effect here is three transforms — cheaper to own than to configure.
|
||||
|
||||
Slides are built from REAL categories (see HomeView), so each one deep-links
|
||||
into a genuine filtered search. The artwork is the first catalogue image of
|
||||
that category; a slide with no image renders its tile blank rather than
|
||||
substituting stock art.
|
||||
Slides come only from the admin slider section (see HomeView) and are
|
||||
display-only: they link nowhere.
|
||||
-->
|
||||
<template>
|
||||
<section class="hero" v-if="slides.length">
|
||||
@@ -27,9 +25,9 @@
|
||||
class="hero__card"
|
||||
:class="{ 'is-active': i === index }"
|
||||
:style="cardStyle(i)"
|
||||
@click="onCardClick(i, slide)"
|
||||
@click="onCardClick(i)"
|
||||
>
|
||||
<img v-if="slide.image" :src="slide.image" :alt="slide.title" class="hero__art" draggable="false" />
|
||||
<img v-if="slide.image" :src="slide.image" alt="" class="hero__art" draggable="false" />
|
||||
<div v-else class="hero__artFallback" aria-hidden="true"></div>
|
||||
</article>
|
||||
</div>
|
||||
@@ -41,7 +39,7 @@
|
||||
type="button"
|
||||
class="hero__dot"
|
||||
:class="{ 'is-active': i === index }"
|
||||
:aria-label="slide.title"
|
||||
:aria-label="`اسلاید ${i + 1}`"
|
||||
@click="go(i)"
|
||||
/>
|
||||
</div>
|
||||
@@ -52,19 +50,15 @@
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useAppearanceStore } from '../../stores/appearanceStore'
|
||||
|
||||
/** A slide from the admin slider section. Display-only — it has no destination. */
|
||||
export interface HeroSlide {
|
||||
key: string
|
||||
title: string
|
||||
image?: string
|
||||
categoryId?: string
|
||||
/** Admin-uploaded slides carry their own destination instead of a category. */
|
||||
link?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{ slides: HeroSlide[]; autoplay?: boolean }>(), {
|
||||
autoplay: true,
|
||||
})
|
||||
const emit = defineEmits<{ (e: 'select', slide: HeroSlide): void }>()
|
||||
|
||||
const index = ref(0)
|
||||
const stage = ref<HTMLElement | null>(null)
|
||||
@@ -169,14 +163,11 @@ function onUp(e: PointerEvent) {
|
||||
else restart()
|
||||
}
|
||||
|
||||
function onCardClick(i: number, slide: HeroSlide) {
|
||||
// A click that ended a drag must not also open the category.
|
||||
// Slides are display-only. Tapping a peeking side card only brings it to the
|
||||
// front; tapping the front card does nothing.
|
||||
function onCardClick(i: number) {
|
||||
if (Math.abs(drag.value) > 1) return
|
||||
if (i !== index.value) {
|
||||
go(i)
|
||||
return
|
||||
}
|
||||
emit('select', slide)
|
||||
if (i !== index.value) go(i)
|
||||
}
|
||||
|
||||
function stop() {
|
||||
@@ -232,6 +223,7 @@ onBeforeUnmount(stop)
|
||||
// The seam: a shadow cast by the front card onto the two tucked behind it, so
|
||||
// the centre card reads as lifted off the stack rather than pasted onto it.
|
||||
.hero__card.is-active {
|
||||
cursor: default;
|
||||
box-shadow: 0 18px 44px -12px rgba(20, 22, 26, 0.45), 0 0 0 1px rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
|
||||
@@ -182,8 +182,8 @@ html.theme-dark .pc__art img {
|
||||
border-radius: 999px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: @onInk;
|
||||
background: @ink;
|
||||
color: #fff;
|
||||
background: @wine;
|
||||
}
|
||||
|
||||
.pc__flag--soft {
|
||||
|
||||
@@ -299,7 +299,7 @@
|
||||
v-model:show="loginNotify"
|
||||
art="login"
|
||||
title="ابتدا وارد حساب کاربری شوید"
|
||||
description="برای دسترسی به تمامی امکانات نرم افزار منتزر، گزینه ورود/ثبت نام را لمس کنید و در صورت نداشتن حساب کاربری، ثبت نام نمایید."
|
||||
:description="`برای دسترسی به تمامی امکانات نرم افزار ${brand.name}، گزینه ورود/ثبت نام را لمس کنید و در صورت نداشتن حساب کاربری، ثبت نام نمایید.`"
|
||||
confirm-text="ورود/ثبت نام"
|
||||
cancel-text="بیخیال"
|
||||
@confirm="router.push({ name: 'login' })"
|
||||
@@ -308,6 +308,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { brand } from '../../brand'
|
||||
import { computed, onBeforeMount, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import Icon from '../icon/Icon.vue'
|
||||
@@ -1132,9 +1133,9 @@ onBeforeUnmount(() => {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
.pd__chip.is-on {
|
||||
color: @onInk;
|
||||
background: @ink;
|
||||
border-color: @ink;
|
||||
color: #fff;
|
||||
background: @wine;
|
||||
border-color: @wine;
|
||||
}
|
||||
/* Same treatment as v1's .pd__variant.is-out: an un-orderable weight stays
|
||||
visible (so the customer can see it exists) but reads as unavailable. */
|
||||
@@ -1314,9 +1315,9 @@ onBeforeUnmount(() => {
|
||||
font-weight: 800;
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
color: @onInk;
|
||||
background: @ink;
|
||||
box-shadow: 0 14px 30px -18px rgba(20, 22, 26, 0.9);
|
||||
color: #fff;
|
||||
background: @wine;
|
||||
box-shadow: 0 14px 30px -18px rgba(124, 0, 53, 0.9);
|
||||
transition: transform 0.18s ease, opacity 0.18s ease;
|
||||
}
|
||||
.pd__cta:active {
|
||||
|
||||
@@ -504,9 +504,9 @@ function onTouchEnd() {
|
||||
border: 1px solid @hairline;
|
||||
}
|
||||
.fSheet__chip.is-on {
|
||||
color: @onInk;
|
||||
background: @ink;
|
||||
border-color: @ink;
|
||||
color: #fff;
|
||||
background: @wine;
|
||||
border-color: @wine;
|
||||
}
|
||||
|
||||
.fSheet__gram {
|
||||
@@ -578,8 +578,8 @@ function onTouchEnd() {
|
||||
.fSheet__apply {
|
||||
flex: 1;
|
||||
border: 0;
|
||||
color: @onInk;
|
||||
background: @ink;
|
||||
box-shadow: 0 14px 30px -18px rgba(20, 22, 26, 0.9);
|
||||
color: #fff;
|
||||
background: @wine;
|
||||
box-shadow: 0 14px 30px -18px rgba(124, 0, 53, 0.9);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -152,8 +152,8 @@ const show = ref(props.defaultOpen)
|
||||
background: @canvas;
|
||||
}
|
||||
.dd.is-open .dd__chevron {
|
||||
color: @onInk;
|
||||
background: @ink;
|
||||
color: #fff;
|
||||
background: @wine;
|
||||
}
|
||||
|
||||
.dd__body {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
ProductPanel / catalogue-card treatment of a piece of jewellery;
|
||||
- a centred title and description under it, as on those cards;
|
||||
- the detail page's action bar: an outlined pill beside a filled pill.
|
||||
The filled pill is @ink, or @wine for the destructive tone (logout).
|
||||
The filled pill is @wine (brand), or @ink for the destructive tone (logout; the class is still .iSheet--wine).
|
||||
The component name, props, events and illustrations are unchanged, so no
|
||||
call site had to change.
|
||||
|
||||
@@ -234,18 +234,18 @@ function onTouchEnd() {
|
||||
animation-play-state: paused !important;
|
||||
}
|
||||
|
||||
// Two looks, one per job: the filled button is @wine for the destructive
|
||||
// action (logout) and @ink — the app's primary surface — for everything else.
|
||||
// Two looks, one per job: the filled button is @ink for the destructive
|
||||
// action (logout) and @wine — the brand colour — for everything else.
|
||||
.iSheet__card {
|
||||
--t: @wine;
|
||||
--t-on: #fff;
|
||||
--t-rgb: 124, 0, 53;
|
||||
}
|
||||
.iSheet--wine {
|
||||
--t: @ink;
|
||||
--t-on: @onInk;
|
||||
--t-rgb: 20, 22, 26;
|
||||
}
|
||||
.iSheet--wine {
|
||||
--t: @wine;
|
||||
--t-on: #fff;
|
||||
--t-rgb: 122, 27, 51;
|
||||
}
|
||||
|
||||
// The detail page's / filter sheet's shell: white, attached to the bottom
|
||||
// edge, 32px top radius, a centred column on wide screens.
|
||||
|
||||
@@ -26,23 +26,23 @@
|
||||
<template v-if="name === 'logout'">
|
||||
<defs>
|
||||
<linearGradient :id="ids.a" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#A33C55" />
|
||||
<stop offset="1" stop-color="#5E1427" />
|
||||
<stop offset="0" stop-color="#A0386A" />
|
||||
<stop offset="1" stop-color="#5A0027" />
|
||||
</linearGradient>
|
||||
<radialGradient :id="ids.b" cx=".35" cy=".5" r=".75">
|
||||
<stop offset="0" stop-color="#FFF7E6" />
|
||||
<stop offset="1" stop-color="#F4C57A" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<ellipse class="sa-shadow" cx="62" cy="106" rx="36" ry="4" fill="#7A1B33" opacity=".12" />
|
||||
<rect x="48" y="18" width="40" height="84" rx="5" :fill="`url(#${ids.b})`" stroke="#7A1B33" stroke-width="3" />
|
||||
<ellipse class="sa-shadow" cx="62" cy="106" rx="36" ry="4" fill="#7C0035" opacity=".12" />
|
||||
<rect x="48" y="18" width="40" height="84" rx="5" :fill="`url(#${ids.b})`" stroke="#7C0035" stroke-width="3" />
|
||||
<g class="sa-door">
|
||||
<path d="M88 18 L88 102 L69 108 L69 12 Z" :fill="`url(#${ids.a})`" stroke="#5E1427" stroke-width="2" stroke-linejoin="round" />
|
||||
<path d="M88 18 L88 102 L69 108 L69 12 Z" :fill="`url(#${ids.a})`" stroke="#5A0027" stroke-width="2" stroke-linejoin="round" />
|
||||
<circle cx="73.5" cy="61" r="2.6" fill="#F4C57A" />
|
||||
</g>
|
||||
<g class="sa-arrow" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M60 60 H22 M33 48 L21 60 L33 72" stroke="#fff" stroke-width="12" />
|
||||
<path d="M60 60 H22 M33 48 L21 60 L33 72" stroke="#7A1B33" stroke-width="6" />
|
||||
<path d="M60 60 H22 M33 48 L21 60 L33 72" stroke="#7C0035" stroke-width="6" />
|
||||
</g>
|
||||
</template>
|
||||
|
||||
@@ -152,7 +152,7 @@
|
||||
<g class="sa-confetti">
|
||||
<rect x="18" y="30" width="6" height="6" rx="1.5" fill="#F4C57A" transform="rotate(20 21 33)" />
|
||||
<circle cx="98" cy="26" r="3.2" fill="#16a34a" />
|
||||
<rect x="100" y="52" width="7" height="3.5" rx="1.75" fill="#A33C55" transform="rotate(-30 103.5 53.75)" />
|
||||
<rect x="100" y="52" width="7" height="3.5" rx="1.75" fill="#A0386A" transform="rotate(-30 103.5 53.75)" />
|
||||
<circle cx="20" cy="72" r="2.6" fill="#335CB1" />
|
||||
<rect x="88" y="8" width="3.5" height="7" rx="1.75" fill="#D9A441" transform="rotate(35 89.75 11.5)" />
|
||||
</g>
|
||||
|
||||
@@ -46,8 +46,8 @@ defineProps<{ on: boolean }>()
|
||||
}
|
||||
|
||||
/* ── Look: user-app-v2 ──────────────────────────────────────── */
|
||||
// The app's chosen state is a filled @ink (chips, CTAs): the track fills @ink
|
||||
// and the knob takes @onInk, so it stays a contrasting pair in both themes.
|
||||
// The app's chosen state is a filled @wine (chips, CTAs): the track fills @wine
|
||||
// and the knob stays white, which reads on @wine in both themes.
|
||||
.tsw {
|
||||
background: @hairline;
|
||||
}
|
||||
@@ -55,9 +55,9 @@ defineProps<{ on: boolean }>()
|
||||
background: #fff;
|
||||
}
|
||||
.tsw.is-on {
|
||||
background: @ink;
|
||||
background: @wine;
|
||||
}
|
||||
.tsw.is-on .tsw__knob {
|
||||
background: @onInk;
|
||||
background: #fff;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -25,7 +25,8 @@ import { defineStore } from 'pinia'
|
||||
import { api } from '../api/client'
|
||||
import shippingArt from '../assets/img/shipping-and-delivery.png'
|
||||
import competitionArt from '../assets/img/sports-and-competition.png'
|
||||
import brandArt from '../assets/img/montzar-logo.png'
|
||||
import brandArt from '../assets/img/brand-logo.png'
|
||||
import { brand } from '../brand'
|
||||
|
||||
export interface Slide {
|
||||
text: string | undefined
|
||||
@@ -50,13 +51,13 @@ const PLACEHOLDER_SLIDES: Slide[] = [
|
||||
{
|
||||
type: 'popup',
|
||||
src: competitionArt,
|
||||
text: 'جدیدترین محصولات منتزر را در بخش محصولات جدید ببینید.',
|
||||
text: `جدیدترین محصولات ${brand.name} را در بخش محصولات جدید ببینید.`,
|
||||
link: undefined,
|
||||
},
|
||||
{
|
||||
type: 'popup',
|
||||
src: brandArt,
|
||||
text: 'منتزر، همراه شما در خرید و فروش طلا.',
|
||||
text: `${brand.name}، همراه شما در خرید و فروش طلا.`,
|
||||
link: undefined,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -344,8 +344,8 @@ onMounted(async () => {
|
||||
font-family: inherit;
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
color: @onInk;
|
||||
background: @ink;
|
||||
color: #fff;
|
||||
background: @wine;
|
||||
}
|
||||
|
||||
.cart__done {
|
||||
@@ -400,8 +400,8 @@ onMounted(async () => {
|
||||
font-family: inherit;
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
color: @onInk;
|
||||
background: @ink;
|
||||
color: #fff;
|
||||
background: @wine;
|
||||
}
|
||||
|
||||
.cart__bar {
|
||||
@@ -460,8 +460,8 @@ onMounted(async () => {
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: @onInk;
|
||||
background: @ink;
|
||||
color: #fff;
|
||||
background: @wine;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
@@ -489,7 +489,7 @@ onMounted(async () => {
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: @onInk;
|
||||
background: @ink;
|
||||
color: #fff;
|
||||
background: @wine;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<div class="home">
|
||||
<header class="home__bar">
|
||||
<div class="home__barInner">
|
||||
<img src="/img/montzar-logo.png" alt="منتزر" class="home__logo" />
|
||||
<img src="/img/brand-logo.png" :alt="brand.name" class="home__logo" />
|
||||
<RateStrip :quote="goldPrice" compact class="home__rate" />
|
||||
</div>
|
||||
</header>
|
||||
@@ -39,7 +39,6 @@
|
||||
:slides="slides"
|
||||
class="home__hero home__reveal"
|
||||
:style="revealDelay(0)"
|
||||
@select="onSlide"
|
||||
/>
|
||||
|
||||
<section v-if="memory.cats.length" class="home__section home__reveal" :style="revealDelay(1)">
|
||||
@@ -80,6 +79,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { brand } from '../brand'
|
||||
import { computed, nextTick, onActivated, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Loading, PullRefresh } from 'vant'
|
||||
@@ -149,35 +149,15 @@ const newestProducts = computed(() => products.value)
|
||||
const reelProducts = computed(() => products.value.slice(0, REEL_LIMIT))
|
||||
const listProducts = computed(() => products.value.slice(0, LIST_LIMIT))
|
||||
|
||||
// The hero is built from REAL categories, not a curated banner payload (none
|
||||
// exists). Each slide deep-links into search filtered by that category, so the
|
||||
// artwork is decorative but the destination is genuine.
|
||||
const slides = computed<HeroSlide[]>(() => {
|
||||
// Prefer what the admin actually configured; the category-derived hero below
|
||||
// stays as the fallback for when no slide has been uploaded.
|
||||
if (adminSlides.value?.length) {
|
||||
return adminSlides.value
|
||||
// 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}`,
|
||||
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
|
||||
}
|
||||
.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 {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: '/',
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -0,0 +1,2 @@
|
||||
// The brand lives in the repo-root brand.ts, shared by all three frontends.
|
||||
export { brand } from '../../../brand';
|
||||
@@ -2,7 +2,7 @@
|
||||
<template>
|
||||
<Spin :spinning="spinning" :tip="tip" :size="(size as 'small' | 'default' | 'large')" :delay="delay" :class="fixed ? '!max-h-screen !fixed' : ''">
|
||||
<template #indicator>
|
||||
<img v-if="!indicator" src="/img/montzar-logo.png" alt="Montzar" class="spinner-brand" width="48" height="48" />
|
||||
<img v-if="!indicator" src="/img/brand-logo.png" :alt="brand.nameEn" class="spinner-brand" width="48" height="48" />
|
||||
<!-- keep-motion: a working spinner, exempt from reduced motion. -->
|
||||
<div class="loading keep-motion" v-else>
|
||||
<div class="effect-1 effects"></div>
|
||||
@@ -17,6 +17,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { brand } from '../brand'
|
||||
import { Spin } from 'ant-design-vue'
|
||||
|
||||
withDefaults(
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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
|
||||
// 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}`,
|
||||
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
|
||||
}
|
||||
.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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 @@
|
||||
{"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"}
|
||||
@@ -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: '/',
|
||||
|
||||
@@ -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;
|
||||
@@ -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 "").
|
||||
|
||||
@@ -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. **اپ کارشناس فروش** هم ستون «بدهی» دارد و همین مشکل را دارد. همین الان آن را هم درست کنیم یا
|
||||
بعداً؟ **پیشنهاد: بعداً، جدا** — این طرح فقط پنل مدیریت.
|
||||
@@ -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` | عیناً پورت شد |
|
||||
|
||||
@@ -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)
|
||||
در حالت تیره همان قاعده برقرار است: دکمهها با زرشکیِ روشنترِ حالت تیره نمایش داده میشوند و متن روی آنها سفید است. کارتهای بزرگ هم زرشکی خیلی تیره میشوند، نه خاکستری.
|
||||
|
||||
## سؤالها از شما
|
||||
|
||||
۱. **دکمهٔ «خروج»** اکنون زرشکی است تا از بقیه جدا باشد. اگر همهچیز زرشکی شود، این دکمه دیگر متمایز نیست.
|
||||
- **پیشنهاد من:** خروج **مشکی** شود (عملاً جای دو رنگ عوض میشود). این یکی از معدود جاهایی است که مشکی میماند.
|
||||
- گزینهٔ دیگر: قرمز (رنگ خطا).
|
||||
|
||||
۲. **کارتهای بزرگ داشبورد** (بند ۳): زرشکی تیره شوند یا مشکی بمانند؟
|
||||
- **پیشنهاد من:** زرشکی تیره، چون خواستید مشکی کمتر دیده شود. نتیجه این است که صفحهٔ داشبورد بهوضوح زرشکی دیده میشود.
|
||||
|
||||
## بعد از انجام
|
||||
- بیلد اپ و بررسی چشمی صفحههای اصلی، سبد خرید، محصول، جستجو، ورود و داشبورد، در هر دو حالت روشن و تیره.
|
||||
- یک خط یادداشت انتشار برای کاربر: «اپ: رنگ اصلی برنامه زرشکی شد».
|
||||
@@ -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"),
|
||||