Remove user-app v1 and ship pending order, auth, and app fixes.

Drop the duplicate customer app from the monorepo and keep user-app-v2 as the only app; include order pricing, admin cart, OTP/SMS, and dashboard cache updates.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
mahmood
2026-10-28 16:22:32 +03:30
co-authored by Cursor
parent bc881a0a8a
commit 520454481b
367 changed files with 335 additions and 28536 deletions
@@ -12,6 +12,9 @@
<MenuUnfoldOutlined v-if="collapsed" />
<MenuFoldOutlined v-else />
</a-button>
<a-button type="text" class="nav-back" title="بازگشت" aria-label="بازگشت" @click="goBack">
<ArrowRightOutlined />
</a-button>
<span class="page-title">{{ pageTitle }}</span>
<a-breadcrumb v-if="breadcrumb" class="breadcrumb">
<a-breadcrumb-item v-for="(crumb, i) in breadcrumb" :key="i">
@@ -75,6 +78,7 @@
import { computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import {
ArrowRightOutlined,
LogoutOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
@@ -104,6 +108,22 @@ const displayName = computed(() => auth.user?.name ?? auth.user?.fullName ?? 'ک
const initial = computed(() => displayName.value.trim().charAt(0) || 'ک');
const canSeePermissions = computed(() => Boolean(users.hasPermission('user.permission')));
function goBack() {
const parent = breadcrumb.value
?.slice()
.reverse()
.find((c) => c.url && !c.active);
if (parent?.url) {
router.push(parent.url);
return;
}
if (window.history.length > 1) {
router.back();
return;
}
router.push('/');
}
async function onLogout() {
const left = await auth.logout();
if (!left) router.push('/auth/login');
@@ -133,6 +153,10 @@ async function onLogout() {
.nav-right {
flex-shrink: 0;
}
.nav-back {
color: #1c2d56;
font-size: 16px;
}
.page-title {
font-size: 16px;
color: #1c2d56;
+18
View File
@@ -23,6 +23,9 @@ export interface CartVariant {
price_with_discount?: number;
grams?: number;
title?: string;
/** Fixed rial making charge on the variant (legacy rial_pay). */
rial_pay?: number | null;
percentage_pay?: number | null;
}
export interface CartProduct {
@@ -38,6 +41,11 @@ export interface CartItem {
product: CartProduct;
qty: number;
promotion_id?: string | null;
/**
* Operator's per-unit rial charge for this line. Unset means "use the
* variant's rial_pay"; 0 means the operator cleared it.
*/
rial_input?: number | null;
}
export const useCartStore = defineStore('cart', {
@@ -82,6 +90,15 @@ export const useCartStore = defineStore('cart', {
this.setItems(this.items);
},
/** Keeps an edited rial charge on the line across quantity changes and reloads. */
updateItemRial(payload: { index: number; rial: number | null }) {
const item = this.items[payload.index];
if (!item) return;
const n = payload.rial == null ? 0 : Number(payload.rial);
item.rial_input = Number.isFinite(n) ? n : 0;
this.setItems(this.items);
},
clear() {
this.items = [];
this.setItems(this.items);
@@ -112,6 +129,7 @@ export const useCartStore = defineStore('cart', {
product,
qty: cartItem.qty,
promotion_id: product.promotion && product.promotion.id ? product.promotion.id : null,
...(cartItem.rial_input != null ? { rial_input: Number(cartItem.rial_input) } : {}),
});
}
}
@@ -82,6 +82,17 @@
{{ record.product.title }}
</div>
</template>
<template v-else-if="column.dataIndex === 'rial'">
<a-input-number
:value="lineRial(record)"
:min="0"
:step="50000"
style="width: 140px"
:formatter="rialFormatter"
:parser="rialParser"
@change="onRialChange(index, $event)"
/>
</template>
<template v-else-if="column.dataIndex === 'qty'">
<a-input-number
v-model:value="items[index].qty"
@@ -146,11 +157,16 @@ const items = computed(() => cart.items);
const columns = [
{ title: 'حذف', dataIndex: 'delete', width: 60, align: 'center' },
{ title: 'محصول', dataIndex: 'product' },
{ title: 'اجرت ریالی', dataIndex: 'rial', align: 'center', width: 160 },
{ title: 'تعداد', dataIndex: 'qty', align: 'center', width: 140 },
{ title: 'وزن', dataIndex: 'weight', align: 'center', width: 90 },
{ title: 'مجموع', dataIndex: 'total', align: 'center' },
];
const rialFormatter = (val: string | number | undefined) =>
val ? `${val}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',') : '';
const rialParser = (val: string | undefined) => (val ? val.replace(/,/g, '') : '');
function getVariant(item: CartItem): CartVariant & Record<string, any> {
return (item.product.variants_all.find((x) => x.id === item.vid) ?? {}) as CartVariant;
}
@@ -171,6 +187,20 @@ function onQtyChange(index: number, value: unknown) {
cart.updateItemQuantity({ index, quantity: Number(value) });
}
/** Unset on the line means the variant's rial_pay; 0 means the operator cleared it. */
function lineRial(item: CartItem): number {
if (item.rial_input != null) {
const n = Number(item.rial_input);
return Number.isFinite(n) ? n : 0;
}
const n = Number(getVariant(item).rial_pay ?? 0);
return Number.isFinite(n) ? n : 0;
}
function onRialChange(index: number, value: number | string | null) {
cart.updateItemRial({ index, rial: value == null || value === '' ? 0 : Number(value) });
}
function onVariantPick(record: Record<string, any>, e: { key: string | number }) {
cart.add({
vid: String(e.key),
@@ -209,7 +239,7 @@ async function submitOrder() {
loading.value = true;
const order_items = items.value.map((x) => {
const v = getVariant(x);
return { vid: x.vid, qty: x.qty, percent: v.percentage_pay };
return { vid: x.vid, qty: x.qty, percent: v.percentage_pay, rial_input: lineRial(x) };
});
await api.post('order', {
+68 -1
View File
@@ -132,6 +132,35 @@
</div>
</a-tab-pane>
<!-- دکمه سفارشی — یک دکمه با عنوان و لینک، قابل فعال/غیرفعال -->
<a-tab-pane key="custom-button" tab="دکمه سفارشی">
<a-form layout="vertical" class="custom-btn-form" @submit.prevent="saveCustomButton">
<a-form-item label="وضعیت">
<a-switch
v-model:checked="customButton.enabled"
checked-children="فعال"
un-checked-children="غیرفعال"
/>
</a-form-item>
<a-form-item label="عنوان دکمه">
<a-input
v-model:value="customButton.title"
placeholder="مثلاً پشتیبانی تلگرام"
style="max-width: 360px"
/>
</a-form-item>
<a-form-item label="لینک">
<a-input
v-model:value="customButton.link"
placeholder="https://..."
style="max-width: 360px"
dir="ltr"
/>
</a-form-item>
<a-button type="primary" @click="saveCustomButton">ذخیره دکمه</a-button>
</a-form>
</a-tab-pane>
<!-- سینی‌ها — super admin only -->
<a-tab-pane v-if="users.isSuperAdmin" key="collections" tab="سینی ها">
<div class="row">
@@ -218,6 +247,9 @@ const tagsInputValue = ref('');
// ── mobile slider (legacy key: mobile_main_slider)
const sliders = ref<{ image: string; link: string }[]>([]);
// ── custom button (Setting key: custom_button)
const customButton = ref({ enabled: false, title: '', link: '' });
// ── collections
const collections = ref<{ id: string; title: string }[]>([]);
const newCollection = ref('');
@@ -315,6 +347,27 @@ async function saveSliders() {
}
}
async function saveCustomButton() {
const title = customButton.value.title.trim();
const link = customButton.value.link.trim();
if (customButton.value.enabled && (!title || !link)) {
message.warning('برای فعال‌سازی، عنوان و لینک را وارد کنید');
return;
}
try {
await api.put('const/custom_button', {
val: {
enabled: customButton.value.enabled,
title,
link,
},
});
message.success('دکمه سفارشی ذخیره شد');
} catch (e: any) {
message.error(e?.response?.data?.message ?? 'خطا در ذخیره');
}
}
// ── collections
async function loadCollections() {
const { data } = await api.post('ciny', { page: 1, limit: 200 });
@@ -363,9 +416,20 @@ onMounted(async () => {
try {
loading.value = true;
await Promise.all([loadBrands(), users.isSuperAdmin ? loadCollections() : Promise.resolve()]);
const [t, s] = await Promise.all([api.get('const/__list__tags'), api.get('const/slider')]);
const [t, s, btn] = await Promise.all([
api.get('const/__list__tags'),
api.get('const/slider'),
api.get('const/custom_button'),
]);
tags.value = Array.isArray(t.data) ? t.data : [];
sliders.value = Array.isArray(s.data) ? s.data : [];
if (btn.data && typeof btn.data === 'object' && !Array.isArray(btn.data)) {
customButton.value = {
enabled: Boolean(btn.data.enabled),
title: String(btn.data.title ?? ''),
link: String(btn.data.link ?? ''),
};
}
} catch (e) {
console.log(e);
} finally {
@@ -446,6 +510,9 @@ onMounted(async () => {
.mt-4 {
margin-top: 1rem;
}
.custom-btn-form {
max-width: 480px;
}
@media (max-width: 575px) {
/* the add-row input fills what the button leaves instead of a fixed 260px */
@@ -128,6 +128,9 @@
/>
<a-tag v-else color="orange">{{ item.qty }}</a-tag>
</a-descriptions-item>
<a-descriptions-item v-if="Number(item.flatRialCharge) > 0" label="اجرت ریالی">
<a-tag>{{ Number(item.flatRialCharge).toLocaleString('fa-IR') }} ریال</a-tag>
</a-descriptions-item>
<a-descriptions-item label="توضیحات">
<div>{{ item.note }}</div>
</a-descriptions-item>
@@ -121,6 +121,9 @@
<a-descriptions-item label="تعداد">
<a-tag color="orange">{{ item.qty }}</a-tag>
</a-descriptions-item>
<a-descriptions-item v-if="Number(item.flatRialCharge) > 0" label="اجرت ریالی">
<a-tag>{{ Number(item.flatRialCharge).toLocaleString('fa-IR') }} ریال</a-tag>
</a-descriptions-item>
</a-descriptions>
<a-divider v-if="index < tempRow.items.length - 1" />
</div>
@@ -108,6 +108,9 @@
<a-descriptions-item label="تعداد">
<a-tag color="orange">{{ item.qty }}</a-tag>
</a-descriptions-item>
<a-descriptions-item v-if="Number(item.flatRialCharge) > 0" label="اجرت ریالی">
<a-tag>{{ Number(item.flatRialCharge).toLocaleString('fa-IR') }} ریال</a-tag>
</a-descriptions-item>
</a-descriptions>
<a-divider v-if="index < tempRow.items.length - 1" />
</div>
+35 -2
View File
@@ -9,8 +9,9 @@
Price columns: legacy read `price` and `price_with_discount` off each variant,
which the redesigned catalog does not store (gold price is quoted live and
wage is a percentage). Those cells show the gram weight and wage instead of
inventing a Rial figure — see docs/ADMIN-PORT-PLAN.md.
wage is a percentage). Those cells show the gram weight and percent wage.
The fixed rial making charge (rial_pay) is its own editable column and is
sent as rial_input.
-->
<a-spin v-if="hasPermission('order.create')" :spinning="loading">
<a-card title="لیست محصولات" class="page-card">
@@ -67,6 +68,18 @@
{{ getVariant(record).percentage_pay }}٪
</template>
<template v-else-if="column.dataIndex === 'rial'">
<a-input-number
:value="lineRial(record)"
:min="0"
:step="50000"
style="width: 140px"
:formatter="rialFormatter"
:parser="rialParser"
@change="onRialChange(index, $event)"
/>
</template>
<template v-else-if="column.dataIndex === 'qty'">
<!--
legacy capped quantity at the variant's stock, falling back to
@@ -262,11 +275,16 @@ const columns = [
{ title: 'کد محصول', dataIndex: 'pid', align: 'center', width: 90 },
{ title: 'محصول', dataIndex: 'product' },
{ title: 'اجرت', dataIndex: 'wage', align: 'center' },
{ title: 'اجرت ریالی', dataIndex: 'rial', align: 'center', width: 160 },
{ title: 'تعداد', dataIndex: 'qty', align: 'center', width: 140 },
{ title: 'وزن', dataIndex: 'weight', align: 'center', width: 90 },
{ title: 'مجموع', dataIndex: 'total', align: 'center' },
];
const rialFormatter = (val: string | number | undefined) =>
val ? `${val}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',') : '';
const rialParser = (val: string | undefined) => (val ? val.replace(/,/g, '') : '');
// ── inline weight editor
const variant = reactive<{ id?: string; grams?: number }>({});
@@ -303,6 +321,20 @@ function onQtyChange(index: number, value: unknown) {
cart.updateItemQuantity({ index, quantity: Number(value) });
}
/** Unset on the line means the variant's rial_pay; 0 means the operator cleared it. */
function lineRial(item: CartItem): number {
if (item.rial_input != null) {
const n = Number(item.rial_input);
return Number.isFinite(n) ? n : 0;
}
const n = Number(getVariant(item).rial_pay ?? 0);
return Number.isFinite(n) ? n : 0;
}
function onRialChange(index: number, value: number | string | null) {
cart.updateItemRial({ index, rial: value == null || value === '' ? 0 : Number(value) });
}
function onVariantPick(record: Record<string, any>, e: { key: string | number }) {
addToCart(record, String(e.key));
}
@@ -374,6 +406,7 @@ async function submitOrder() {
qty: x.qty,
percent: v.percentage_pay,
promotion_id: x.promotion_id,
rial_input: lineRial(x),
};
});