feat: product design surveys, cart expiry/buy-later, and catalog polish

Add like/dislike surveys with majority alerts for manufacturing decisions, clearer accounting mapping copy, and related cart/catalog admin-app improvements.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
mahmood
2026-11-12 17:55:52 +03:30
co-authored by Cursor
parent 032fa800d9
commit 7dd74ff4f7
74 changed files with 6351 additions and 893 deletions
+1
View File
@@ -17,6 +17,7 @@
"ant-design-vue": "^4.2.6",
"apexcharts": "^7.4.0",
"axios": "^1.7.7",
"dayjs": "^1.11.23",
"lodash": "^4.17.21",
"moment-jalaali": "^0.10.0",
"pinia": "^2.2.0",
@@ -509,11 +509,14 @@ const dbMismatch = computed(
unicode-bidi: isolate;
}
.status--ok {
background: #f6ffed;
border-color: #b7eb8f;
background: #e6f4ff;
border-color: #91caff;
}
.status--ok .status__icon {
color: #52c41a;
color: #1677ff;
}
.status--ok .status__title {
color: #0958d9;
}
.status--bad {
background: #fff2f0;
+17 -1
View File
@@ -219,6 +219,13 @@ export const adminMenu: NavMenuNode[] = [
i18n: 'تغییر درصد محصولات',
permissionId:"product.percent"
},
{
url: '/apps/product/survey',
name: 'ProductSurvey',
slug: 'product-survey',
i18n: 'نظرسنجی طرح',
permissionId: 'product.survey',
},
]
},
{
@@ -338,6 +345,7 @@ export const adminMenu: NavMenuNode[] = [
name: 'reports and documents',
icon: 'FileTextIcon',
i18n: 'گزارشات و اسناد مالی',
superAdminOnly: true,
submenu: [
{
url: null,
@@ -462,7 +470,8 @@ export const adminMenu: NavMenuNode[] = [
name: 'report Management',
slug: 'manage-report',
i18n: 'گزارش',
permissionId:"config.report"
permissionId:"config.report",
superAdminOnly: true
},
{
url: '/config/manage/wallet',
@@ -472,6 +481,13 @@ export const adminMenu: NavMenuNode[] = [
permissionId:"config.wallet",
superAdminOnly: true
},
{
url: '/config/manage/cart-expiry',
name: 'cart Expiry',
slug: 'manage-cart-expiry',
i18n: 'انقضای سبد خرید',
permissionId: 'config.cart',
},
// D27 — Tahesab / Zarrin integration.
{
url: '/config/manage/accounting',
+48 -2
View File
@@ -102,6 +102,27 @@ export const adminRoute: RouteRecordRaw[] = [
no_scroll: true
}
},
{
path: '/config/manage/cart-expiry',
component: () => import('../views/manage/cart-expiry.vue'),
meta: {
breadcrumb: [
{
title: 'خانه',
url: '/'
},
{
title: 'انقضای سبد خرید',
active: true
}
],
pageTitle: 'انقضای سبد خرید',
rule: 'editor',
role: 'config.cart',
parent: 'manage',
no_scroll: true
}
},
{
path: '/config/manage/accounting',
component: () => import('../views/manage/accounting.vue'),
@@ -162,6 +183,7 @@ export const adminRoute: RouteRecordRaw[] = [
pageTitle: 'گزارش',
rule: 'editor',
role: 'config.report',
superAdminOnly: true,
parent: 'manage',
no_scroll: true
}
@@ -214,6 +236,22 @@ export const adminRoute: RouteRecordRaw[] = [
no_scroll: true
}
},
{
path: '/apps/product/survey',
name: 'product-survey',
component: () => import('../views/apps/product/ProductSurvey.vue'),
meta: {
breadcrumb: [
{ title: 'خانه', url: '/' },
{ title: 'نظرسنجی طرح', active: true },
],
pageTitle: 'نظرسنجی طرح محصول',
rule: 'editor',
role: 'product.survey',
parent: 'product',
no_scroll: true,
},
},
// Category
{
path: '/category',
@@ -259,7 +297,8 @@ export const adminRoute: RouteRecordRaw[] = [
component: () => import('../views/finical/addFinDoc.vue'),
meta: {
rule: 'editor',
role: 'document.create'
role: 'document.create',
superAdminOnly: true
//no_scroll: true
}
},
@@ -268,7 +307,8 @@ export const adminRoute: RouteRecordRaw[] = [
component: () => import('../views/finical/addFinDoc.vue'),
meta: {
rule: 'editor',
role: 'document.list.edit'
role: 'document.list.edit',
superAdminOnly: true
//no_scroll: true
}
},
@@ -760,6 +800,7 @@ export const adminRoute: RouteRecordRaw[] = [
],*/
//pageTitle: 'لیست مشتریان',
role: 'report.order.item',
superAdminOnly: true,
rule: 'editor'
}
},
@@ -769,6 +810,7 @@ export const adminRoute: RouteRecordRaw[] = [
component: () => import('../views/report/product.vue'),
meta: {
role: 'report.product',
superAdminOnly: true,
rule: 'editor'
}
},
@@ -783,6 +825,7 @@ export const adminRoute: RouteRecordRaw[] = [
],*/
//pageTitle: 'لیست مشتریان',
role: 'document.list',
superAdminOnly: true,
rule: 'editor'
}
},
@@ -1087,6 +1130,7 @@ export const adminRoute: RouteRecordRaw[] = [
],
pageTitle: 'افزودن فاکتور',
role: 'invoice.create',
superAdminOnly: true,
rule: 'editor'
}
},
@@ -1097,6 +1141,7 @@ export const adminRoute: RouteRecordRaw[] = [
meta: {
pageTitle: 'ویرایش فاکتورها',
role: 'invoice.create',
superAdminOnly: true,
rule: 'editor'
}
},
@@ -1118,6 +1163,7 @@ export const adminRoute: RouteRecordRaw[] = [
],
pageTitle: 'لیست فاکتورها',
role: 'invoice.list',
superAdminOnly: true,
rule: 'editor'
}
}
+48
View File
@@ -22,6 +22,26 @@
</div>
</header>
<!-- Majority survey alert: half+1 of active customers liked -->
<a-alert
v-if="surveyMajorityHits.length"
type="success"
show-icon
class="survey-majority"
:message="`${surveyMajorityHits.length} طرح نظرسنجی به حد اکثریت رسیده`"
>
<template #description>
<div v-for="hit in surveyMajorityHits" :key="hit.id" class="survey-majority__row">
<img v-if="hit.imageUrl" :src="hit.imageUrl" alt="" />
<span>
<b>{{ hit.title || 'بدون عنوان' }}</b>
— ▲ {{ hit.likes }} لایک (حد: {{ surveyMajorityThreshold }})
</span>
<a-button size="small" type="link" @click="$router.push('/apps/product/survey')">مشاهده و تصمیم</a-button>
</div>
</template>
</a-alert>
<!-- ── KPI row ── -->
<section class="kpi-grid">
<div class="kpi kpi--success">
@@ -303,6 +323,10 @@ const layout = useLayoutStore();
// Retail («تک فروشی») figures are for the super admin only; the API also omits them.
const users = useUsersStore();
const isSuper = computed(() => users.isSuperAdmin);
const surveyMajorityHits = ref<
{ id: string; title: string | null; imageUrl: string; likes: number }[]
>([]);
const surveyMajorityThreshold = ref(0);
const chartHeight = computed(() => (layout.isMobile ? 240 : 300));
const donutHeight = computed(() => (layout.isMobile ? 220 : 240));
@@ -596,6 +620,16 @@ onMounted(async () => {
} finally {
loading.value = false;
}
if (users.hasPermission('product.survey')) {
try {
const { data: maj } = await api.get('admin/product-surveys/majority-alerts');
surveyMajorityHits.value = maj?.rows ?? [];
surveyMajorityThreshold.value = Number(maj?.majorityThreshold ?? 0);
} catch {
surveyMajorityHits.value = [];
}
}
});
</script>
@@ -618,6 +652,20 @@ b {
min-width: 0;
}
.survey-majority__row {
display: flex;
align-items: center;
gap: 10px;
margin-top: 8px;
flex-wrap: wrap;
}
.survey-majority__row img {
width: 40px;
height: 40px;
object-fit: cover;
border-radius: 6px;
}
/* ── header ── */
.dash-head {
display: flex;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,355 @@
<template>
<div id="page-user-list" class="survey-page">
<a-card v-if="hasPermission('product.survey')" title="نظرسنجی طرح محصول" class="page-card table-scroll">
<a-alert
v-if="majorityHits.length"
type="success"
show-icon
class="mb-4"
:message="`${majorityHits.length} طرح به حد اکثریت رسید (نصف+۱ = ${majorityThreshold} لایک از ${customerCount} مشتری)`"
>
<template #description>
<div v-for="hit in majorityHits" :key="hit.id" class="majority-hit">
<img v-if="hit.imageUrl" :src="hit.imageUrl" class="thumb" alt="" />
<div class="majority-hit__text">
<b>{{ hit.title || 'بدون عنوان' }}</b>
<div>▲ {{ hit.likes }} لایک · {{ hit.statusLabel }} · {{ hit.suggestedWeightGrams }} گرم</div>
</div>
<a-button
v-if="hit.status === 'OPEN' || hit.status === 'CLOSED'"
size="small"
type="primary"
@click="decide(hit.id, 'APPROVED')"
>
تأیید ساخت
</a-button>
</div>
</template>
</a-alert>
<a-alert
v-else
type="info"
show-icon
class="mb-4"
:message="`هشدار اکثریت: وقتی لایک یک طرح به ${majorityThreshold || '—'} برسد (نصف+۱ از ${customerCount || '—'} مشتری)، اینجا دیده می‌شود`"
/>
<div class="survey-toolbar">
<a-select
v-model:value="statusFilter"
allow-clear
placeholder="همه وضعیت‌ها"
style="width: 180px"
:options="statusOptions"
@change="load"
/>
<a-button type="primary" @click="openCreate">ثبت طرح جدید</a-button>
<a-button @click="load">تازه‌سازی</a-button>
</div>
<div class="survey-table-wrap">
<a-spin :spinning="loading">
<a-table
:data-source="rows"
:columns="columns"
:pagination="false"
row-key="id"
size="middle"
:locale="{ emptyText: 'طرحی ثبت نشده' }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'image'">
<img v-if="record.imageUrl" :src="record.imageUrl" class="thumb" alt="" />
</template>
<template v-else-if="column.key === 'title'">
{{ record.title || '—' }}
<a-tag v-if="record.reachedMajority" color="success" class="ms-1">اکثریت</a-tag>
</template>
<template v-else-if="column.key === 'weight'">
{{ record.suggestedWeightGrams }}
</template>
<template v-else-if="column.key === 'votes'">
<span class="vote like">▲ {{ record.likes }}</span>
<span class="vote dislike">▼ {{ record.dislikes }}</span>
<div v-if="majorityThreshold" class="vote-prog">حد اکثریت: {{ majorityThreshold }}</div>
</template>
<template v-else-if="column.key === 'status'">
<a-tag>{{ record.statusLabel }}</a-tag>
</template>
<template v-else-if="column.key === 'creator'">
<span v-if="record.createdByCustomer">
مشتری: {{ record.createdByCustomer.fullName }} ({{ record.createdByCustomer.mobile }})
</span>
<span v-else-if="record.createdByAdmin">ادمین: {{ record.createdByAdmin.fullName }}</span>
<span v-else>—</span>
</template>
<template v-else-if="column.key === 'actions'">
<a-space wrap>
<a-button
v-if="record.status === 'PENDING_REVIEW'"
size="small"
type="primary"
@click="act(record.id, 'publish')"
>
انتشار
</a-button>
<a-button v-if="record.status === 'OPEN'" size="small" @click="act(record.id, 'close')">
بستن رأی‌گیری
</a-button>
<a-button
v-if="record.status === 'OPEN' || record.status === 'CLOSED'"
size="small"
type="primary"
@click="decide(record.id, 'APPROVED')"
>
تأیید ساخت
</a-button>
<a-button
v-if="record.status === 'OPEN' || record.status === 'CLOSED'"
size="small"
danger
@click="decide(record.id, 'REJECTED')"
>
رد
</a-button>
</a-space>
</template>
</template>
</a-table>
</a-spin>
</div>
</a-card>
<a-modal
v-model:visible="createOpen"
title="ثبت طرح برای نظرسنجی"
ok-text="ثبت و انتشار"
cancel-text="انصراف"
:confirm-loading="saving"
@ok="submitCreate"
>
<a-form layout="vertical">
<a-form-item label="تصویر طرح" required>
<ImageUploadInput v-model="form.imageUrl" purpose="product.survey" placeholder="بارگذاری تصویر" />
</a-form-item>
<a-form-item label="وزن پیشنهادی (گرم)" required>
<a-input-number v-model:value="form.suggestedWeightGrams" :min="0.01" :step="0.1" class="w-full" />
</a-form-item>
<a-form-item label="عنوان (اختیاری)">
<a-input v-model:value="form.title" :maxlength="200" allow-clear />
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { message } from 'ant-design-vue';
import { useUsersStore } from '../../../stores/users';
import api from '../../../api/client';
import ImageUploadInput from '../../../components/upload/ImageUploadInput.vue';
type SurveyRow = {
id: string;
title: string | null;
imageUrl: string;
suggestedWeightGrams: number;
status: string;
statusLabel: string;
likes: number;
dislikes: number;
reachedMajority?: boolean;
createdByCustomer: { id: string; fullName: string; mobile: string } | null;
createdByAdmin: { id: string; fullName: string } | null;
};
const users = useUsersStore();
const hasPermission = (rid: string) => Boolean(users.hasPermission(rid));
const loading = ref(false);
const saving = ref(false);
const createOpen = ref(false);
const rows = ref<SurveyRow[]>([]);
const majorityHits = ref<SurveyRow[]>([]);
const customerCount = ref(0);
const majorityThreshold = ref(0);
const statusFilter = ref<string | undefined>();
const statusOptions = ref<{ value: string; label: string }[]>([]);
const columns = [
{ title: 'تصویر', key: 'image', width: 88 },
{ title: 'عنوان', key: 'title' },
{ title: 'وزن پیشنهادی (گرم)', key: 'weight', width: 150 },
{ title: 'رأی', key: 'votes', width: 150 },
{ title: 'وضعیت', key: 'status', width: 130 },
{ title: 'سازنده', key: 'creator' },
{ title: 'عملیات', key: 'actions', width: 280 },
];
const form = reactive({
imageUrl: null as string | null,
suggestedWeightGrams: null as number | null,
title: '',
});
async function load() {
if (!hasPermission('product.survey')) return;
try {
loading.value = true;
const q = statusFilter.value ? `?status=${encodeURIComponent(statusFilter.value)}` : '';
const res = await api.get(`admin/product-surveys${q}`);
rows.value = res.data?.rows ?? [];
majorityHits.value = res.data?.majorityHits ?? [];
customerCount.value = Number(res.data?.customerCount ?? 0);
majorityThreshold.value = Number(res.data?.majorityThreshold ?? 0);
statusOptions.value = (res.data?.statusOptions ?? []).map((x: { value: string; label: string }) => ({
value: x.value,
label: x.label,
}));
} catch (e: any) {
message.error(e?.response?.data?.message ?? 'خطا در بارگذاری');
} finally {
loading.value = false;
}
}
function openCreate() {
form.imageUrl = null;
form.suggestedWeightGrams = null;
form.title = '';
createOpen.value = true;
}
async function submitCreate() {
if (!form.imageUrl) {
message.warning('تصویر الزامی است');
return Promise.reject();
}
if (!form.suggestedWeightGrams || form.suggestedWeightGrams <= 0) {
message.warning('وزن پیشنهادی را وارد کنید');
return Promise.reject();
}
try {
saving.value = true;
await api.post('admin/product-surveys', {
imageUrl: form.imageUrl,
suggestedWeightGrams: form.suggestedWeightGrams,
title: form.title || undefined,
});
message.success('طرح ثبت و منتشر شد');
createOpen.value = false;
await load();
} catch (e: any) {
message.error(e?.response?.data?.message ?? 'خطا در ثبت');
return Promise.reject();
} finally {
saving.value = false;
}
}
async function act(id: string, kind: 'publish' | 'close') {
try {
await api.post(`admin/product-surveys/${id}/${kind}`);
message.success(kind === 'publish' ? 'منتشر شد' : 'رأی‌گیری بسته شد');
await load();
} catch (e: any) {
message.error(e?.response?.data?.message ?? 'خطا');
}
}
async function decide(id: string, decision: 'APPROVED' | 'REJECTED') {
try {
await api.post(`admin/product-surveys/${id}/decide`, { decision });
message.success(decision === 'APPROVED' ? 'برای ساخت تأیید شد' : 'رد شد');
await load();
} catch (e: any) {
message.error(e?.response?.data?.message ?? 'خطا');
}
}
onMounted(load);
</script>
<style scoped>
.survey-page {
display: flex;
flex-direction: column;
min-height: 0;
flex: 1;
}
.survey-page :deep(> .ant-card) {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.survey-page :deep(> .ant-card .ant-card-body) {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.survey-toolbar {
flex: 0 0 auto;
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
margin-bottom: 16px;
position: relative;
z-index: 2;
background: #fff;
}
.survey-table-wrap {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
position: relative;
z-index: 1;
}
.mb-4 {
flex: 0 0 auto;
margin-bottom: 1rem;
}
.w-full {
width: 100%;
}
.thumb {
width: 56px;
height: 56px;
object-fit: cover;
border-radius: 6px;
display: block;
}
.vote {
margin-inline-end: 0.75rem;
font-variant-numeric: tabular-nums;
}
.vote.like {
color: #389e0d;
}
.vote.dislike {
color: #cf1322;
}
.vote-prog {
margin-top: 2px;
font-size: 11px;
color: rgba(0, 0, 0, 0.45);
}
.majority-hit {
display: flex;
align-items: center;
gap: 12px;
margin-top: 8px;
}
.majority-hit__text {
flex: 1;
min-width: 0;
}
.ms-1 {
margin-inline-start: 6px;
}
</style>
@@ -0,0 +1,627 @@
<template>
<div
ref="rootRef"
class="vre"
:class="[`vre--${mode}`, { 'vre--invalid': hasErrors }]"
role="group"
:aria-label="mode === 'create' ? 'افزودن تنوع جدید' : 'ویرایش تنوع'"
@keydown="onKeydown"
>
<!-- required: what every weight variant needs -->
<div class="vre__primary">
<a-form-item
class="vre__item"
label="وزن"
required
:validate-status="errors.grams ? 'error' : ''"
:help="errors.grams"
>
<a-input-number
ref="gramsRef"
v-model:value="draft.grams"
class="w-full"
:min="0"
:step="0.001"
addon-after="گرم"
placeholder="مثلاً ۴٫۵"
:formatter="gramsInput.formatter"
:parser="gramsInput.parser"
/>
</a-form-item>
<a-form-item
class="vre__item"
label="اجرت درصدی"
required
:validate-status="errors.percentage_pay ? 'error' : ''"
:help="errors.percentage_pay"
>
<a-input-number
v-model:value="draft.percentage_pay"
class="w-full"
:min="0"
:step="0.1"
addon-after="٪"
placeholder="مثلاً ۱۸"
:formatter="percentInput.formatter"
:parser="percentInput.parser"
/>
</a-form-item>
<a-form-item
class="vre__item"
label="اجرت ریالی"
required
:validate-status="errors.rial_pay ? 'error' : ''"
:help="errors.rial_pay"
>
<a-input-number
v-model:value="draft.rial_pay"
class="w-full"
:min="0"
:step="50000"
addon-after="ریال"
placeholder="نداشت: صفر"
:formatter="formatThousands"
:parser="parseThousands"
/>
</a-form-item>
</div>
<!-- optional: collapsed until asked for -->
<button
type="button"
class="vre__more-toggle"
:class="{ 'is-open': moreOpen }"
:aria-expanded="moreOpen"
@click="moreOpen = !moreOpen"
>
<DownOutlined class="vre__more-chevron" />
<span class="vre__more-title">موارد بیشتر</span>
<span class="vre__more-hint">رنگ، تعداد، وضعیت فروش، تصویر و توضیحات</span>
<span v-if="extraCount" class="vre__more-count">{{ faNumber(extraCount) }} مورد تنظیم شده</span>
</button>
<div class="vre__more-wrap" :class="{ 'is-open': moreOpen }" :inert="!moreOpen || undefined">
<div class="vre__more-inner">
<div class="vre__more">
<div class="vre__more-col">
<div class="vre__pair">
<a-form-item class="vre__item" label="رنگ طلا">
<a-select v-model:value="draft.color" placeholder="انتخاب رنگ" allow-clear>
<a-select-option v-for="c in colorOptions" :key="c.id" :value="c.id">
<span class="vre-color">
<span class="vre-color__dot" :class="`vre-color__dot--${c.id}`"></span>
{{ c.title }}
</span>
</a-select-option>
</a-select>
</a-form-item>
<a-form-item
class="vre__item"
label="تعداد موجودی"
:validate-status="errors.qty ? 'error' : ''"
:help="errors.qty"
:extra="!errors.qty && !draft.qty && !draft.is_pre_order ? 'خالی یا صفر: در اپ «ناموجود»' : undefined"
>
<a-input-number
v-model:value="draft.qty"
class="w-full"
:min="0"
:precision="0"
addon-after="عدد"
placeholder="اختیاری"
/>
</a-form-item>
</div>
<div class="vre__switches">
<label class="vre__switch">
<span>
<b>فعال برای فروش</b>
<small>{{ draft.is_active ? 'در اپ قابل سفارش است' : 'پنهان از مشتری' }}</small>
</span>
<a-switch v-model:checked="draft.is_active" size="small" />
</label>
<label class="vre__switch">
<span>
<b>پیش‌سفارش</b>
<small>{{ draft.is_pre_order ? 'بدون موجودی هم سفارش می‌گیرد' : 'فقط با موجودی' }}</small>
</span>
<a-switch v-model:checked="draft.is_pre_order" size="small" />
</label>
</div>
<a-form-item class="vre__item" label="توضیح مختصر">
<a-input v-model:value="draft.details" placeholder="مثلاً: طراحی مات با تراش برلیان" />
</a-form-item>
</div>
<div class="vre__more-col">
<a-form-item class="vre__item" label="تصویر اختصاصی تنوع">
<div v-if="gallery.length" class="vre-picker">
<button
type="button"
class="vre-picker__item vre-picker__item--none"
:class="{ 'is-active': !draft.image_id }"
@click="pickImage(null)"
>
<PictureOutlined />
<span>بدون عکس</span>
</button>
<button
v-for="img in gallery"
:key="img.id"
type="button"
class="vre-picker__item"
:class="{ 'is-active': draft.image_id === img.id }"
@click="pickImage(img)"
>
<img :src="img.url" alt="" />
<span v-if="draft.image_id === img.id" class="vre-picker__check"><CheckOutlined /></span>
</button>
</div>
<div v-else class="vre-picker-empty">{{ emptyGalleryText }}</div>
</a-form-item>
<a-form-item class="vre__item" label="توضیحات کامل">
<a-textarea
v-model:value="draft.description"
:auto-size="{ minRows: 3, maxRows: 6 }"
placeholder="ویژگی‌های خاص این تنوع..."
/>
</a-form-item>
</div>
</div>
</div>
</div>
<div class="vre__actions">
<a-button v-if="mode === 'edit' || dirty" @click="cancel">
{{ mode === 'create' ? 'پاک کردن' : 'انصراف' }}
</a-button>
<a-button type="primary" @click="submit">
<template #icon>
<PlusOutlined v-if="mode === 'create'" />
<CheckOutlined v-else />
</template>
{{ mode === 'create' ? 'افزودن ردیف' : 'ذخیره ردیف' }}
</a-button>
<span class="vre__keys">
<kbd>Enter</kbd> {{ mode === 'create' ? 'افزودن' : 'ذخیره' }}
<template v-if="mode === 'edit'"><kbd>Esc</kbd> انصراف</template>
</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue';
import { CheckOutlined, DownOutlined, PictureOutlined, PlusOutlined } from '@ant-design/icons-vue';
import {
autoTitle,
composerDirty,
decimalInput,
extraFieldsFilled,
faNumber,
scrollFieldIntoView,
validateVariant,
type ColorOption,
type GalleryImage,
type VariantErrors,
type VariantRow,
} from './variant';
const props = withDefaults(
defineProps<{
mode: 'create' | 'edit';
colorOptions: ColorOption[];
gallery: GalleryImage[];
emptyGalleryText?: string;
autofocus?: boolean;
}>(),
{ emptyGalleryText: 'تصویری در گالری محصول ثبت نشده است.', autofocus: false },
);
const emit = defineEmits<{ submit: []; cancel: [] }>();
const draft = defineModel<VariantRow>({ required: true });
const rootRef = ref<HTMLElement | null>(null);
const gramsRef = ref<{ focus: () => void } | null>(null);
const errors = reactive<VariantErrors>({});
const moreOpen = ref(false);
const hasErrors = computed(() => Object.keys(errors).length > 0);
const extraCount = computed(() => extraFieldsFilled(draft.value));
const dirty = computed(() => composerDirty(draft.value));
// The title is the weight («۴٫۵ گرمی») — never typed by hand.
const title = computed(() => autoTitle(draft.value.grams));
watch(
title,
(t) => {
draft.value.title = t;
},
{ immediate: true },
);
// Clear an error as soon as its field becomes valid.
watch(
draft,
(v) => {
if (!hasErrors.value) return;
const now = validateVariant(v);
(Object.keys(errors) as (keyof VariantErrors)[]).forEach((k) => {
if (!now[k]) delete errors[k];
});
},
{ deep: true },
);
function pickImage(img: GalleryImage | null) {
draft.value.image_id = img ? img.id : null;
draft.value.image_url = img ? img.url : null;
}
const gramsInput = decimalInput(3);
const percentInput = decimalInput(2);
const formatThousands = (val: unknown) =>
val === null || val === undefined || val === '' ? '' : String(val).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
const parseThousands = (val: string) => (val ? val.replace(/,/g, '') : '');
/** Paint field errors, open «موارد بیشتر» if needed, and land on the first one. */
async function validateAndScroll(): Promise<boolean> {
await nextTick();
const found = validateVariant(draft.value);
(Object.keys(errors) as (keyof VariantErrors)[]).forEach((k) => delete errors[k]);
Object.assign(errors, found);
if (!Object.keys(found).length) return true;
if (found.qty) moreOpen.value = true;
await nextTick();
scrollFieldIntoView(rootRef.value?.querySelector('.ant-form-item-has-error'));
return false;
}
async function submit() {
if (!(await validateAndScroll())) return;
emit('submit');
}
function cancel() {
emit('cancel');
}
function onKeydown(e: KeyboardEvent) {
const target = e.target as HTMLElement | null;
// dropdowns and multi-line text own their Enter/Escape
if (!target || target.closest('.ant-select') || target.tagName === 'TEXTAREA') return;
if (e.key === 'Enter' && !e.isComposing) {
e.preventDefault();
submit();
} else if (e.key === 'Escape' && props.mode === 'edit') {
e.preventDefault();
cancel();
}
}
/** Back to a clean slate after the parent swapped in the next row's draft. */
function reset() {
(Object.keys(errors) as (keyof VariantErrors)[]).forEach((k) => delete errors[k]);
draft.value.title = title.value;
}
function focus() {
gramsRef.value?.focus();
}
onMounted(() => {
moreOpen.value = props.mode === 'edit' && extraCount.value > 0;
if (props.autofocus) nextTick(focus);
});
defineExpose({ focus, reset, validateAndScroll });
</script>
<style scoped>
.vre {
--vre-primary: #1c2d56;
--vre-line: #e4e8f0;
--vre-muted: #8a94a6;
position: relative;
padding: 14px 16px 12px;
border: 1px dashed #c9d1e0;
border-radius: 14px;
background: #fafbfd;
transition: border-color 0.2s, box-shadow 0.2s, background 0.2s;
}
.vre:focus-within {
border-style: solid;
border-color: var(--vre-primary);
background: #fff;
box-shadow: 0 0 0 3px rgba(28, 45, 86, 0.08);
}
.vre--edit {
border-style: solid;
border-color: #b9c3d8;
background: #fff;
box-shadow: 0 6px 20px -12px rgba(16, 24, 40, 0.25);
}
.vre--invalid {
border-color: #ff7875;
}
.vre__keys {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: var(--vre-muted);
}
.vre__keys kbd {
padding: 0 5px;
border: 1px solid var(--vre-line);
border-bottom-width: 2px;
border-radius: 4px;
background: #fff;
font-family: inherit;
font-size: 10.5px;
line-height: 16px;
color: #475467;
}
.vre__keys kbd:not(:first-child) {
margin-inline-start: 8px;
}
.vre__primary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0 10px;
}
.vre__pair {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0 10px;
}
.vre__item {
margin-bottom: 10px;
}
.vre__item :deep(.ant-form-item-label) {
padding-bottom: 3px;
}
.vre__item :deep(.ant-form-item-label > label) {
height: auto;
font-size: 12px;
font-weight: 500;
color: #475467;
}
.vre__item :deep(.ant-form-item-explain) {
font-size: 11.5px;
}
/* «موارد بیشتر» */
.vre__more-toggle {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 7px 10px;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--vre-primary);
font: inherit;
font-size: 12.5px;
cursor: pointer;
text-align: start;
transition: background 0.15s;
}
.vre__more-toggle:hover,
.vre__more-toggle:focus-visible {
background: rgba(28, 45, 86, 0.05);
outline: none;
}
.vre__more-chevron {
font-size: 10px;
transition: transform 0.2s;
}
.vre__more-toggle.is-open .vre__more-chevron {
transform: rotate(180deg);
}
.vre__more-title {
font-weight: 600;
}
.vre__more-hint {
color: var(--vre-muted);
font-size: 11.5px;
}
.vre__more-count {
margin-inline-start: auto;
padding: 0 8px;
border-radius: 999px;
font-size: 11px;
line-height: 20px;
color: #1d6b40;
background: #e8f6ee;
}
/* height animates via grid rows, no JS measuring */
.vre__more-wrap {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.25s ease;
}
.vre__more-wrap.is-open {
grid-template-rows: 1fr;
}
.vre__more-inner {
min-height: 0;
overflow: hidden;
}
.vre__more {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0 16px;
margin-top: 8px;
padding: 12px 12px 2px;
border: 1px solid var(--vre-line);
border-radius: 10px;
background: #fff;
}
.vre__switches {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
margin-bottom: 10px;
}
.vre__switch {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin: 0;
padding: 7px 10px;
border: 1px solid var(--vre-line);
border-radius: 8px;
cursor: pointer;
}
.vre__switch:hover {
border-color: #c9d1e0;
}
.vre__switch span {
display: flex;
flex-direction: column;
min-width: 0;
}
.vre__switch b {
font-size: 12px;
font-weight: 600;
color: #344054;
}
.vre__switch small {
font-size: 10.5px;
color: var(--vre-muted);
}
.vre-color {
display: inline-flex;
align-items: center;
gap: 6px;
}
.vre-color__dot {
width: 10px;
height: 10px;
border-radius: 50%;
border: 1px solid rgba(0, 0, 0, 0.2);
}
.vre-color__dot--0 { background: #fdfdfd; }
.vre-color__dot--1 { background: #ffd700; }
.vre-color__dot--2 { background: #795548; }
.vre-color__dot--3 { background: #b76e79; }
.vre-picker {
display: flex;
flex-wrap: wrap;
gap: 8px;
max-height: 128px;
overflow-y: auto;
padding: 2px;
}
.vre-picker__item {
position: relative;
width: 56px;
height: 56px;
padding: 2px;
border: 1.5px solid var(--vre-line);
border-radius: 8px;
background: #fff;
cursor: pointer;
overflow: hidden;
transition: border-color 0.15s, box-shadow 0.15s;
}
.vre-picker__item:hover {
border-color: var(--vre-primary);
}
.vre-picker__item.is-active {
border-color: var(--vre-primary);
box-shadow: 0 0 0 2px rgba(28, 45, 86, 0.18);
}
.vre-picker__item img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 5px;
display: block;
}
.vre-picker__item--none {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
font-size: 10px;
color: #98a2b3;
}
.vre-picker__check {
position: absolute;
top: 3px;
right: 3px;
width: 15px;
height: 15px;
display: grid;
place-items: center;
border-radius: 50%;
font-size: 8px;
color: #fff;
background: var(--vre-primary);
}
.vre-picker-empty {
padding: 10px;
border: 1px dashed var(--vre-line);
border-radius: 8px;
font-size: 11.5px;
color: var(--vre-muted);
text-align: center;
}
.vre__actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
margin-top: 10px;
}
.vre__actions .ant-btn {
min-width: 96px;
border-radius: 8px;
}
@media (max-width: 600px) {
.vre__primary {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.vre__primary > .vre__item:last-child {
grid-column: 1 / -1;
}
}
@media (max-width: 767px) {
.vre__more {
grid-template-columns: minmax(0, 1fr);
}
.vre__keys,
.vre__more-hint {
display: none;
}
}
@media (max-width: 420px) {
.vre__switches {
grid-template-columns: minmax(0, 1fr);
}
.vre__actions .ant-btn {
flex: 1;
}
}
</style>
@@ -0,0 +1,136 @@
export interface VariantRow {
__key: string;
id?: string;
title: string;
grams?: number | null;
percentage_pay?: number | null;
qty?: number | null;
is_active: boolean;
is_pre_order: boolean;
color?: number | null;
color_str?: string | null;
rial_pay?: number | null;
details?: string | null;
description?: string | null;
image_id?: string | null;
image_url?: string | null;
}
export interface GalleryImage {
id: string;
url: string;
role: string;
sortOrder: number;
}
export interface ColorOption {
id: number;
title: string;
}
export type VariantErrors = Partial<Record<'grams' | 'qty' | 'percentage_pay' | 'rial_pay', string>>;
export function newVariantKey(): string {
return `v-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
}
export function emptyVariant(defaultWage?: number): VariantRow {
return {
__key: newVariantKey(),
id: undefined,
title: '',
grams: null,
percentage_pay: defaultWage ?? null,
qty: null,
is_active: true,
is_pre_order: false,
color: null,
color_str: null,
rial_pay: null,
details: '',
description: '',
image_id: null,
image_url: null,
};
}
export const faNumber = (n: number, maxFraction = 3) =>
Number(n).toLocaleString('fa-IR', { maximumFractionDigits: maxFraction });
/**
* Scroll the page's scroll container (`#content-area`) so `el` sits in the
* middle of the viewport, then focus its first control without a second jump.
* Prefer a form-item (or the first `.ant-form-item-has-error` under a root).
*/
export function scrollFieldIntoView(el: Element | null | undefined) {
if (!el || typeof (el as HTMLElement).scrollIntoView !== 'function') return;
const target = (el.closest?.('.ant-form-item') as HTMLElement | null) ?? (el as HTMLElement);
target.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' });
const focusable = target.querySelector<HTMLElement>(
'input:not([type="hidden"]):not([disabled]), textarea:not([disabled]), .ant-select-selector, .ant-input-number-input, button:not([disabled])',
);
window.setTimeout(() => focusable?.focus?.({ preventScroll: true }), 280);
}
const toAsciiDigits = (s: string) =>
s.replace(/[۰-۹]/g, (d) => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(d))).replace(/[٫٬,]/g, (c) => (c === '٫' ? '.' : ''));
/**
* InputNumber formatter/parser pair for decimals: shows exactly what was typed
* (no `step`-driven zero padding like «5.100»), capped at `maxFraction` digits,
* and accepts Persian digits and «٫».
*/
export function decimalInput(maxFraction: number) {
return {
formatter: (val: unknown, info?: { userTyping: boolean; input: string }) => {
if (info?.userTyping) return info.input;
if (val === '' || val === null || val === undefined) return '';
const n = Number(val);
return Number.isFinite(n) ? String(Number(n.toFixed(maxFraction))) : String(val);
},
parser: (val: string) => {
const m = toAsciiDigits(val ?? '')
.replace(/[^\d.]/g, '')
.match(/^(\d*)(?:\.(\d*))?/);
if (!m) return '';
return m[2] !== undefined ? `${m[1]}.${m[2].slice(0, maxFraction)}` : m[1];
},
};
}
/** «۵ گرمی» — a variant's title is always derived from its weight. */
export function autoTitle(grams?: number | null): string {
return grams != null && grams > 0 ? `${faNumber(grams)} گرمی` : '';
}
const isBlank = (v: unknown) => v === null || v === undefined || v === '';
export function validateVariant(v: VariantRow): VariantErrors {
const e: VariantErrors = {};
if (isBlank(v.grams)) e.grams = 'وزن را وارد کنید';
else if (Number(v.grams) <= 0) e.grams = 'وزن باید بیشتر از صفر باشد';
if (!isBlank(v.qty) && Number(v.qty) < 0) e.qty = 'تعداد منفی نمی‌شود';
if (isBlank(v.percentage_pay)) e.percentage_pay = 'اجرت درصدی را وارد کنید';
if (isBlank(v.rial_pay)) e.rial_pay = 'اجرت ریالی را وارد کنید؛ اگر ندارد صفر بزنید';
return e;
}
/** Fields behind «موارد بیشتر» that differ from a fresh row. */
export function extraFieldsFilled(v: VariantRow): number {
return [
v.color != null,
!isBlank(v.qty),
Boolean(v.image_id),
Boolean(v.details),
Boolean(v.description),
!v.is_active,
v.is_pre_order,
].filter(Boolean).length;
}
/** The composer holds something the operator would lose by navigating away. */
export function composerDirty(v: VariantRow): boolean {
return Boolean(
v.title?.trim() || !isBlank(v.grams) || !isBlank(v.qty) || v.image_id || v.details || v.description,
);
}
@@ -37,7 +37,7 @@
show-icon
class="mb-4"
message="سینک مشتری اجرا نمی‌شود"
description="هیچ گروه مشتری برای ورود انتخاب نشده است؛ در تب «نگاشت» گروه‌هایی را که واقعاً مشتری هستند انتخاب و ذخیره کنید."
description="در تب «نگاشت» مشخص کنید کدام گروه‌های حسابداری مشتری‌اند؛ اگر خالی باشد کسی وصل نمی‌شود."
/>
<a-spin :spinning="loading">
@@ -139,21 +139,21 @@
<ol>
<li>تنظیمات تب «اتصال» را ذخیره کنید، بعد «دریافت گروه‌ها از حسابداری» را بزنید تا فهرست‌ها از حسابداری پر شوند.</li>
<li>
<b>گروه‌های حسابی که مشتری {{ brand.name }} حساب می‌شوند:</b> در حسابداری صندوق، کارمند و سرمایه‌گذار هم «حساب» هستند. فقط گروه‌هایی را انتخاب کنید که
واقعاً مشتری‌اند؛ سینک مشتری فقط حساب‌های این گروه‌ها را با شماره موبایل به کاربران اپ مشتری وصل می‌کند. اگر خالی بماند سینک مشتری اجرا نمی‌شود.
<b>کدام گروه‌های حسابداری مشتری‌اند؟</b> این‌ها گروه‌های خودِ نرم‌افزار حسابداری‌اند (نه {{ brand.name }}). صندوق، کارمند و سرمایه‌گذار را نزنید؛
فقط گروه‌های واقعی مشتری. سینک فقط همین‌ها را با موبایل به کاربران اپ وصل می‌کند. خالی = سینک مشتری اجرا نمی‌شود.
</li>
<li>
<b>گروه مشتری جدید:</b> وقتی کاربری در اپ مشتری احراز هویت شود و در حسابداری حساب نداشته باشد، حسابش در این گروه ساخته می‌شود.
اگر تعیین نشود، ساخت حساب در «لاگ ارسال» با وضعیت «نیاز به بررسی» می‌ماند.
<b>حساب جدید اپ در کدام گروه ساخته شود؟</b> اگر کسی در اپ مشتری احراز هویت کند و در حسابداری هنوز حساب نداشته باشد، حسابش اینجا ساخته می‌شود.
خالی = ساخت حساب در «لاگ ارسال» می‌ماند تا خودتان تعیین کنید.
</li>
<li>
<b>عیار پایه:</b> عیار وزن‌هایی که در {{ brand.name }} ثبت می‌شود (وزن محصول‌ها و سفارش‌ها؛ معمولاً ۷۵۰ = ۱۸ عیار). در سند سفارش، وزن با همین عیار به
حسابداری می‌رود؛ در ته‌حساب اجرت هم روی آن اعمال می‌شود (مثلاً ۷۵۰ با اجرت ۱۰٪ می‌شود ۸۲۵).
</li>
<li v-if="form.provider === 'zarrin'">
<b>کارساخته (فقط زرین):</b> وقتی در پنل مدیریت {{ brand.name }} محصول جدیدی تعریف شود، برایش یک «کارساخته» در زرین ساخته می‌شود. «نوع» تعیین می‌کند
گرمی است یا تعدادی، و «گروه ساخته» جای آن در زرین است. در جدول پایین برای هر دسته‌بندی محصولِ {{ brand.name }} می‌توانید گروه جدا بدهید؛ دسته‌ای که
«پیش‌فرض» بماند در «گروه ساخته‌ی پیش‌فرض» می‌رود. اگر نوع یا گروه خالی باشد، ساخت کارساخته در «لاگ ارسال» منتظر بررسی می‌ماند.
<b>کارساخته (فقط زرین):</b> وقتی در پنل مدیریت {{ brand.name }} محصول جدیدی تعریف شود، برایش یک «کارساخته» در زرین ساخته می‌شود.
نوع (گرمی/تعدادی) و گروه ساخته را اینجا مشخص کنید. در جدول برای هر دسته می‌توانید گروه جدا بدهید؛ بقیه به گروه پیش‌فرض می‌روند.
اگر نوع یا گروه خالی باشد، ساخت کارساخته در «لاگ ارسال» منتظر بررسی می‌ماند.
</li>
</ol>
<p class="hint">
@@ -167,25 +167,23 @@
</a-space>
<a-form layout="vertical">
<a-divider orientation="right">حساب مشتری‌ها</a-divider>
<a-alert type="info" show-icon class="mb-4" :message="`این بخش تعیین می‌کند کدام حساب‌های حسابداری به کاربران اپ مشتری ${brand.name} وصل شوند`">
<a-alert type="info" show-icon class="mb-4" message="این‌ها گروه‌های نرم‌افزار حسابداری‌اند؛ مشخص می‌کنند چه کسی مشتری است و حساب تازه کجا ساخته شود">
<template #description>
<div>
هر حسابی که در گروه‌های انتخاب‌شده باشد و موبایلش با یک کاربر اپ مشتری {{ brand.name }} یکی باشد، به آن کاربر وصل می‌شود و مانده‌اش در اپ مشتری
نمایش داده می‌شود. صندوق، کارمند و سرمایه‌گذار را انتخاب نکنید.
فیلد اول: حساب‌های همین گروه‌ها (با موبایل یکسان) به کاربر اپ وصل می‌شوند و مانده‌شان در اپ دیده می‌شود. صندوق، کارمند و سرمایه‌گذار را نزنید.
</div>
<div>
مثال: «بنکدار» و «مغازه‌دار» را در فیلد اول انتخاب کنید و در فیلد دوم «مغازه‌دار» را بگذارید. در این حالت کسی که در اپ مشتری ثبت‌نام و
احراز هویت کند و در حسابداری حساب نداشته باشد، در گروه «مغازه‌دار» برایش حساب ساخته می‌شود.
فیلد دوم: اگر کاربر اپ هنوز در حسابداری حساب ندارد، آنجا ساخته می‌شود. مثال: اولی «بنکدار» و «مغازه‌دار»، دومی «مغازه‌دار».
</div>
</template>
</a-alert>
<div class="grid-2">
<a-form-item :label="`گروه‌های حسابی که مشتری ${brand.name} حساب می‌شوند`" extra="فقط گروه‌هایی که مشتری‌اند؛ خالی = سینک مشتری اجرا نمی‌شود">
<a-form-item label="کدام گروه‌های حسابداری مشتری‌اند؟" extra="فقط گروه‌های واقعی مشتری؛ خالی = سینک مشتری اجرا نمی‌شود">
<a-select v-model:value="importGroupKeys" mode="multiple" :options="customerGroupOptions" allow-clear placeholder="هیچ گروهی انتخاب نشده" />
</a-form-item>
<a-form-item
label="گروه حسابداریِ مشتری تازه‌ی اپ مشتری"
extra="کاربری که در اپ مشتری احراز هویت شده و در حسابداری حساب ندارد، در این گروه برایش حساب ساخته می‌شود"
label="حساب جدید اپ در کدام گروه ساخته شود؟"
extra="اگر کاربر اپ در حسابداری حساب ندارد، اینجا ساخته می‌شود؛ خالی = منتظر بررسی می‌ماند"
>
<a-select v-model:value="newCustomerGroupKey" :options="customerGroupOptions" allow-clear />
</a-form-item>
@@ -205,7 +203,7 @@
<div>
ستون «دسته‌بندی محصول در {{ brand.name }}» همان دسته‌بندی‌هایی است که در پنل مدیریت برای محصولات تعریف کرده‌اید. مثال: اگر روبه‌روی «انگشتر»
گروه «انگشتر» زرین را بگذارید، هر محصول تازه در دسته‌ی انگشتر {{ brand.name }}، در گروه انگشترِ زرین ساخته می‌شود.
دسته‌هایی که «پیش‌فرض» بمانند در «گروه ساخته‌ی پیش‌فرض» می‌روند.
دسته‌هایی که در جدول «پیش‌فرض» بمانند، به همان گروه پیش‌فرض پایین می‌روند.
</div>
<div>
اگر نوع یا گروه خالی باشد، محصول در {{ brand.name }} ساخته می‌شود ولی ساخت کارساخته در «لاگ ارسال» منتظر می‌ماند تا آن را تعیین کنید و «تلاش دوباره» بزنید.
@@ -214,10 +212,10 @@
</template>
</a-alert>
<div class="grid-2">
<a-form-item label="نوع کارساخته‌ی جدید (گرمی/تعدادی…)" :extra="`برای محصول تازه‌ای که در پنل مدیریت ${brand.name} تعریف می‌شود`">
<a-form-item label="محصول جدید با چه نوعی در زرین ساخته شود؟" extra="مثلاً گرمی یا تعدادی؛ برای هر محصول تازه‌ی پنل">
<a-select v-model:value="newTypeKey" :options="typeOptions" allow-clear />
</a-form-item>
<a-form-item label="گروه ساخته‌ی پیش‌فرض" extra="برای دسته‌هایی که در جدول زیر «پیش‌فرض» مانده‌اند">
<a-form-item label="اگر برای دسته گروه نگذاشتید، کجا برود؟" extra="دسته‌هایی که در جدول زیر «پیش‌فرض» مانده‌اند، اینجا ساخته می‌شوند">
<a-select v-model:value="newItemGroupKey" :options="itemGroupOptions" allow-clear />
</a-form-item>
</div>
@@ -0,0 +1,173 @@
<template>
<div>
<a-card v-if="hasPermission('config.cart')" title="انقضای سبد خرید اپ" class="page-card">
<a-spin :spinning="loading" tip="لطفا کمی تامل کنید">
<a-form layout="vertical" @submit.prevent="save">
<a-form-item label="فعال‌سازی خالی‌شدن روزانه">
<a-switch v-model:checked="form.enabled" />
<div class="hint">
خاموش = رفتار فعلی (بدون کرون و بدون پیامک). سبد مهمان فقط در کلاینت پاک می‌شود.
</div>
</a-form-item>
<div class="grid-2">
<a-form-item label="ساعت خالی‌شدن (تهران)">
<a-time-picker
v-model:value="clearAt"
class="w-full"
format="HH:mm"
value-format="HH:mm"
:show-now="false"
:allow-clear="false"
placeholder="انتخاب ساعت"
/>
</a-form-item>
<a-form-item label="شروع پنجرهٔ تعویق (تهران)">
<a-time-picker
v-model:value="deferFrom"
class="w-full"
format="HH:mm"
value-format="HH:mm"
:show-now="false"
:allow-clear="false"
placeholder="انتخاب ساعت"
/>
</a-form-item>
</div>
<div class="hint" style="margin-bottom: 1rem">
اگر بین شروع تعویق تا ساعت خالی‌شدن چیزی به سبد اضافه شود، انقضا می‌رود روی
<b>خالی‌شدن فردا</b>
(پیش‌فرض: ۰۸:۰۰ تا ۱۳:۰۰ → ۱۳:۰۰ فردا). قبل از این پنجره همان‌روز خالی می‌شود.
</div>
<a-form-item label="پیامک یادآوری">
<a-switch v-model:checked="form.reminderEnabled" />
</a-form-item>
<a-form-item label="چند ساعت قبل از خالی‌شدن">
<a-input-number
v-model:value="form.reminderHoursBefore"
class="w-full"
:min="0"
:max="48"
/>
</a-form-item>
<a-form-item label="متن پیامک">
<a-textarea
v-model:value="form.reminderBody"
:rows="3"
placeholder="از %count برای تعداد ساعت تا خالی‌شدن استفاده کنید"
/>
<div class="hint">جایگزین `%count` با تعداد ساعت قبل از خالی‌شدن.</div>
</a-form-item>
<a-button type="primary" class="form-submit" :loading="saving" @click="save">
ذخیره
</a-button>
</a-form>
</a-spin>
</a-card>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { message } from 'ant-design-vue';
import { useUsersStore } from '../../stores/users';
import api from '../../api/client';
const users = useUsersStore();
const hasPermission = (rid: string) => Boolean(users.hasPermission(rid));
const loading = ref(false);
const saving = ref(false);
/** `HH:mm` strings for a-time-picker with value-format. */
const clearAt = ref('13:00');
const deferFrom = ref('08:00');
const form = reactive({
enabled: false,
reminderEnabled: true,
reminderHoursBefore: 2,
reminderBody:
'سبد خرید شما تا %count ساعت دیگر خالی می‌شود. برای ثبت سفارش همین امروز اقدام کنید.',
});
function pad2(n: number) {
return String(Math.max(0, Math.min(59, Math.trunc(n)))).padStart(2, '0');
}
function toHm(hour: number, minute: number) {
return `${pad2(Math.max(0, Math.min(23, Math.trunc(hour))))}:${pad2(minute)}`;
}
function fromHm(value: string | null | undefined, fallbackHour: number, fallbackMinute: number) {
const m = /^(\d{1,2}):(\d{1,2})$/.exec((value ?? '').trim());
if (!m) return { hour: fallbackHour, minute: fallbackMinute };
return {
hour: Math.max(0, Math.min(23, Number(m[1]))),
minute: Math.max(0, Math.min(59, Number(m[2]))),
};
}
async function load() {
try {
loading.value = true;
const res = await api.get('admin/cart-expiry/config');
const data = res.data ?? {};
form.enabled = data.enabled === true;
form.reminderEnabled = data.reminderEnabled !== false;
form.reminderHoursBefore = Number(data.reminderHoursBefore ?? 2);
if (typeof data.reminderBody === 'string' && data.reminderBody.trim()) {
form.reminderBody = data.reminderBody;
}
clearAt.value = toHm(Number(data.clearHour ?? 13), Number(data.clearMinute ?? 0));
deferFrom.value = toHm(
Number(data.sameDayDeferFromHour ?? 8),
Number(data.sameDayDeferFromMinute ?? 0),
);
} catch (e: any) {
message.error(e?.response?.data?.message ?? 'خطا در بارگذاری تنظیمات');
} finally {
loading.value = false;
}
}
async function save() {
try {
saving.value = true;
const clear = fromHm(clearAt.value, 13, 0);
const defer = fromHm(deferFrom.value, 8, 0);
await api.put('admin/cart-expiry/config', {
enabled: form.enabled,
clearHour: clear.hour,
clearMinute: clear.minute,
sameDayDeferFromHour: defer.hour,
sameDayDeferFromMinute: defer.minute,
reminderEnabled: form.reminderEnabled,
reminderHoursBefore: form.reminderHoursBefore,
reminderBody: form.reminderBody,
});
message.success('تغییرات ثبت شد');
} catch (e: any) {
message.error(e?.response?.data?.message ?? 'خطا در ثبت تغییرات');
} finally {
saving.value = false;
}
}
onMounted(load);
</script>
<style scoped>
.hint {
margin-top: 0.35rem;
color: rgba(0, 0, 0, 0.45);
font-size: 12px;
}
.form-submit {
margin-top: 0.5rem;
}
</style>
+324 -76
View File
@@ -1,91 +1,339 @@
<template>
<!--
Ported from legacy henza_admin/src/views/statics/index.vue — the customer
activity log ("آمار"). Pick a customer, see their recent actions.
-->
<a-spin :spinning="loading">
<a-card title="آمار" class="page-card table-scroll">
<a-form layout="vertical">
<div class="grid-2">
<a-form-item label="مشتری">
<a-select
v-model:value="customer"
show-search
allow-clear
placeholder="انتخاب کنید"
:filter-option="filterOption"
@change="load"
>
<a-select-option v-for="c in customers" :key="c.id" :value="c.id">
{{ c.name }} - {{ c.mobile }}
</a-select-option>
</a-select>
</a-form-item>
</div>
</a-form>
<!-- Best-selling products and most-viewed products/brands. -->
<a-card title="آمار فروش و بازدید" class="page-card statics">
<template #extra>
<a-segmented v-model:value="days" :options="periodOptions" size="small" @change="loadTop" />
</template>
<a-table
:data-source="logs"
:columns="columns"
:pagination="{ pageSize: 20 }"
size="small"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'caj'">
{{ jdateTime(record.caj) }}
<a-alert v-if="topError" type="error" show-icon message="آمار بارگذاری نشد." class="statics__alert">
<template #action>
<a-button size="small" @click="loadTop">تلاش دوباره</a-button>
</template>
</a-alert>
<a-spin :spinning="topLoading">
<div class="kpis">
<div v-for="k in kpis" :key="k.label" class="kpi">
<span class="kpi__label">{{ k.label }}</span>
<b class="kpi__value">{{ fa(k.value) }}</b>
</div>
</div>
<section class="panel">
<h3 class="panel__title">پرفروش‌ترین محصولات</h3>
<a-table
:data-source="top.topSoldProducts"
:columns="soldColumns"
:pagination="false"
:locale="{ emptyText: `در ${periodLabel} سفارشی ثبت نشده است.` }"
size="small"
row-key="id"
class="table-scroll"
>
<template #bodyCell="{ column, record, index }">
<template v-if="column.key === 'rank'">
<span class="pos" :class="{ 'is-top': index < 3 }">{{ fa(index + 1) }}</span>
</template>
<template v-else-if="column.key === 'title'">
<router-link v-if="productLink(record.id)" :to="productLink(record.id)!" class="name">
{{ record.title }}
</router-link>
<span v-else class="name">{{ record.title }}</span>
<span v-if="record.code" class="code">{{ record.code }}</span>
</template>
<template v-else-if="column.key === 'qty'">
<div class="share">
<b>{{ fa(record.qty) }}</b>
<span class="share__bar"><i :style="{ width: `${share(record.qty, soldMax)}%` }" /></span>
</div>
</template>
<template v-else-if="column.key === 'orders'">{{ fa(record.orders) }}</template>
</template>
</template>
</a-table>
</a-card>
</a-spin>
</a-table>
</section>
<p v-if="viewsNotice" class="statics__notice">
<InfoCircleOutlined />
{{ viewsNotice }}
</p>
<div class="panels-2">
<section class="panel">
<h3 class="panel__title">پربازدیدترین محصولات</h3>
<a-table
:data-source="top.topViewedProducts"
:columns="viewColumns('محصول')"
:pagination="false"
:locale="{ emptyText: viewsEmptyText }"
size="small"
row-key="id"
class="table-scroll"
>
<template #bodyCell="{ column, record, index }">
<template v-if="column.key === 'rank'">
<span class="pos" :class="{ 'is-top': index < 3 }">{{ fa(index + 1) }}</span>
</template>
<template v-else-if="column.key === 'title'">
<router-link v-if="productLink(record.id)" :to="productLink(record.id)!" class="name">
{{ record.title }}
</router-link>
<span v-else class="name">{{ record.title }}</span>
<span v-if="record.code" class="code">{{ record.code }}</span>
</template>
<template v-else-if="column.key === 'views'">
<div class="share">
<b>{{ fa(record.views) }}</b>
<span class="share__bar"><i :style="{ width: `${share(record.views, viewMax)}%` }" /></span>
</div>
</template>
</template>
</a-table>
</section>
<section class="panel">
<h3 class="panel__title">پربازدیدترین برندها</h3>
<a-table
:data-source="top.topViewedBrands"
:columns="viewColumns('برند')"
:pagination="false"
:locale="{ emptyText: top.totals.views ? 'محصولات بازدیدشده در این بازه برند ندارند.' : viewsEmptyText }"
size="small"
row-key="id"
class="table-scroll"
>
<template #bodyCell="{ column, record, index }">
<template v-if="column.key === 'rank'">
<span class="pos" :class="{ 'is-top': index < 3 }">{{ fa(index + 1) }}</span>
</template>
<template v-else-if="column.key === 'title'">
<span class="name">{{ record.title }}</span>
</template>
<template v-else-if="column.key === 'views'">
<div class="share">
<b>{{ fa(record.views) }}</b>
<span class="share__bar"><i :style="{ width: `${share(record.views, brandMax)}%` }" /></span>
</div>
</template>
</template>
</a-table>
</section>
</div>
</a-spin>
</a-card>
</template>
<script setup lang="ts">
import { optionText } from '../../utils/select';
import { onMounted, ref } from 'vue';
import { jdateTime } from '../../utils/filters';
import { computed, onMounted, ref } from 'vue';
import { InfoCircleOutlined } from '@ant-design/icons-vue';
import { jdate } from '../../utils/filters';
import { useUsersStore } from '../../stores/users';
import api from '../../api/client';
const loading = ref(false);
const customers = ref<{ id: string; name: string; mobile: string }[]>([]);
const customer = ref<string | undefined>(undefined);
const logs = ref<Record<string, any>[]>([]);
interface TopStats {
totals: { soldQty: number; orders: number; views: number; viewedProducts: number };
viewsTrackedSince: string | null;
topSoldProducts: { id: string; title: string; code: string | null; qty: number; orders: number }[];
topViewedProducts: { id: string; title: string; code: string | null; views: number }[];
topViewedBrands: { id: string; title: string; views: number }[];
}
const columns = [
{ title: 'عملیات', dataIndex: 'action' },
{ title: 'جزئیات', dataIndex: 'detail' },
{ title: 'تاریخ', dataIndex: 'caj' },
const users = useUsersStore();
const fa = (n: number) => n.toLocaleString('fa-IR');
const days = ref(30);
const periodOptions = [
{ label: '۷ روز', value: 7 },
{ label: '۳۰ روز', value: 30 },
{ label: '۹۰ روز', value: 90 },
{ label: 'همه', value: 0 },
];
const periodLabel = computed(() => (days.value ? `${fa(days.value)} روز گذشته` : 'کل دوره'));
const topLoading = ref(false);
const topError = ref(false);
const top = ref<TopStats>({
totals: { soldQty: 0, orders: 0, views: 0, viewedProducts: 0 },
viewsTrackedSince: null,
topSoldProducts: [],
topViewedProducts: [],
topViewedBrands: [],
});
const kpis = computed(() => [
{ label: 'تعداد فروش', value: top.value.totals.soldQty },
{ label: 'سفارش‌ها', value: top.value.totals.orders },
{ label: 'بازدید محصولات', value: top.value.totals.views },
{ label: 'محصولات دیده‌شده', value: top.value.totals.viewedProducts },
]);
const soldMax = computed(() => Math.max(1, ...top.value.topSoldProducts.map((r) => r.qty)));
const viewMax = computed(() => Math.max(1, ...top.value.topViewedProducts.map((r) => r.views)));
const brandMax = computed(() => Math.max(1, ...top.value.topViewedBrands.map((r) => r.views)));
const share = (value: number, max: number) => Math.max(4, Math.round((value / max) * 100));
const rankColumn = { key: 'rank', title: '#', width: 48, align: 'center' as const };
const soldColumns = [
rankColumn,
{ key: 'title', title: 'محصول' },
{ key: 'qty', title: 'تعداد فروش', width: 180 },
{ key: 'orders', title: 'سفارش', width: 80, align: 'center' as const },
];
const viewColumns = (label: string) => [
rankColumn,
{ key: 'title', title: label },
{ key: 'views', title: 'بازدید', width: 150 },
];
function filterOption(input: string, option: any) {
return optionText(option).includes(input);
}
const viewsEmptyText = computed(() =>
top.value.viewsTrackedSince
? `در ${periodLabel.value} بازدیدی ثبت نشده است.`
: 'هنوز بازدیدی ثبت نشده. هر بار که مشتری صفحهٔ یک محصول را در اپ باز کند شمرده می‌شود.',
);
async function load() {
if (!customer.value) {
logs.value = [];
return;
}
try {
loading.value = true;
const { data } = await api.get(`log/ago/${customer.value}`);
logs.value = data;
} catch (e) {
console.log(e);
} finally {
loading.value = false;
}
}
onMounted(async () => {
try {
const { data } = await api.get('log/list/customer');
customers.value = data;
} catch (e) {
console.log(e);
}
/** Shown when the chosen period starts before view tracking existed. */
const viewsNotice = computed(() => {
const since = top.value.viewsTrackedSince;
if (!since) return null;
const periodStart = days.value ? Date.now() - days.value * 86_400_000 : 0;
if (periodStart >= new Date(since).getTime()) return null;
return `آمار بازدید از ${jdate(since)} ثبت می‌شود؛ قبل از این تاریخ بازدیدی در دست نیست.`;
});
const canEditProduct = computed(() => Boolean(users.hasPermission('product.list.edit')));
function productLink(id: string) {
return canEditProduct.value ? { name: 'product-edit', params: { id } } : null;
}
async function loadTop() {
try {
topLoading.value = true;
topError.value = false;
const { data } = await api.get('admin/misc/stats/top', { params: { days: days.value } });
top.value = data;
} catch (e) {
console.log(e);
topError.value = true;
} finally {
topLoading.value = false;
}
}
onMounted(loadTop);
</script>
<!-- .grid-2 now comes from the global utilities.less (dedup) -->
<style scoped>
/* The layout lets the page root shrink (min-height: 0); this page must grow with its content. */
.statics {
min-height: auto;
}
.statics__alert {
margin-bottom: 12px;
}
.kpis {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.kpi {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
padding: 10px 14px;
border-radius: 10px;
background: #f7f8fa;
}
.kpi__label {
font-size: 12.5px;
color: #6b7280;
}
.kpi__value {
font-size: 18px;
font-weight: 700;
color: #1c2d56;
}
.panel {
margin-top: 16px;
}
.panel__title {
margin: 0 0 8px;
font-size: 14px;
font-weight: 600;
color: #1c2d56;
}
.panels-2 {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.statics__notice {
display: flex;
align-items: center;
gap: 6px;
margin: 16px 0 0;
font-size: 12px;
color: #8a909c;
}
.pos {
display: inline-grid;
place-items: center;
width: 24px;
height: 24px;
border-radius: 7px;
font-size: 12px;
color: #6b7280;
background: #f2f4f7;
}
.pos.is-top {
color: #fff;
background: #1c2d56;
}
.name {
color: #2b2f38;
}
a.name:hover {
color: #1c2d56;
text-decoration: underline;
}
.code {
display: inline-block;
margin-right: 8px;
font-size: 12px;
color: #9ca3af;
direction: ltr;
unicode-bidi: isolate;
white-space: nowrap;
}
.share {
display: flex;
align-items: center;
gap: 10px;
}
.share b {
min-width: 28px;
font-weight: 600;
color: #1c2d56;
}
.share__bar {
flex: 1;
height: 6px;
border-radius: 999px;
background: #eef0f3;
overflow: hidden;
}
.share__bar i {
display: block;
height: 100%;
border-radius: inherit;
background: #1c2d56;
}
@media (max-width: 991px) {
.panels-2 {
grid-template-columns: minmax(0, 1fr);
gap: 0;
}
.kpis {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>
@@ -838,6 +838,10 @@ export const treeData: PermissionNode[] = [
"title": "مدیریت کیف پول",
"key": "config.wallet",
},
{
"title": "انقضای سبد خرید",
"key": "config.cart",
},
{
"title": "گزارش",
"key": "config.report",
@@ -0,0 +1,13 @@
-- Product detail opens in the customer app, for the admin «آمار» screen.
CREATE TABLE "ProductView" (
"id" TEXT NOT NULL,
"productId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ProductView_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "ProductView_productId_createdAt_idx" ON "ProductView"("productId", "createdAt");
CREATE INDEX "ProductView_createdAt_idx" ON "ProductView"("createdAt");
ALTER TABLE "ProductView" ADD CONSTRAINT "ProductView_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,8 @@
-- Product labels from the «برچسب‌ها» list. IF NOT EXISTS: some databases
-- already carry this column from an earlier, unmerged migration.
ALTER TABLE "Product" ADD COLUMN IF NOT EXISTS "tags" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
UPDATE "Product" SET "tags" = ARRAY[]::TEXT[] WHERE "tags" IS NULL;
ALTER TABLE "Product" ALTER COLUMN "tags" SET DEFAULT ARRAY[]::TEXT[];
ALTER TABLE "Product" ALTER COLUMN "tags" SET NOT NULL;
CREATE INDEX IF NOT EXISTS "Product_tags_idx" ON "Product" USING GIN ("tags");
@@ -0,0 +1,13 @@
-- Server-side customer cart for daily expiry + reminder SMS (cart.expiry).
CREATE TABLE "CustomerCart" (
"customerId" TEXT NOT NULL,
"items" JSONB NOT NULL DEFAULT '[]',
"reminderSentForClearAt" TIMESTAMP(3),
"clearedAt" TIMESTAMP(3),
"updatedAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "CustomerCart_pkey" PRIMARY KEY ("customerId")
);
ALTER TABLE "CustomerCart" ADD CONSTRAINT "CustomerCart_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,12 @@
-- Deferred «خرید بعدی» list — separate from CustomerCart so daily expiry
-- and order-clear never touch it, and stock is never reserved.
CREATE TABLE "CustomerBuyLater" (
"customerId" TEXT NOT NULL,
"items" JSONB NOT NULL DEFAULT '[]',
"updatedAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "CustomerBuyLater_pkey" PRIMARY KEY ("customerId")
);
ALTER TABLE "CustomerBuyLater" ADD CONSTRAINT "CustomerBuyLater_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,49 @@
-- Product design survey (like/dislike before manufacturing).
CREATE TYPE "ProductSurveyStatus" AS ENUM ('PENDING_REVIEW', 'OPEN', 'CLOSED', 'APPROVED', 'REJECTED');
CREATE TYPE "ProductSurveyVoteValue" AS ENUM ('LIKE', 'DISLIKE');
CREATE TABLE "ProductSurvey" (
"id" TEXT NOT NULL,
"title" TEXT,
"imageUrl" TEXT NOT NULL,
"suggestedWeightGrams" DECIMAL(14,4) NOT NULL,
"status" "ProductSurveyStatus" NOT NULL DEFAULT 'PENDING_REVIEW',
"createdByCustomerId" TEXT,
"createdByAdminId" TEXT,
"publishedAt" TIMESTAMP(3),
"decidedAt" TIMESTAMP(3),
"decidedByAdminId" TEXT,
"decisionNote" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ProductSurvey_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "ProductSurveyVote" (
"id" TEXT NOT NULL,
"surveyId" TEXT NOT NULL,
"customerId" TEXT NOT NULL,
"value" "ProductSurveyVoteValue" NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ProductSurveyVote_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "ProductSurvey_status_idx" ON "ProductSurvey"("status");
CREATE INDEX "ProductSurvey_createdByCustomerId_idx" ON "ProductSurvey"("createdByCustomerId");
CREATE INDEX "ProductSurvey_createdAt_idx" ON "ProductSurvey"("createdAt");
CREATE INDEX "ProductSurveyVote_customerId_idx" ON "ProductSurveyVote"("customerId");
CREATE UNIQUE INDEX "ProductSurveyVote_surveyId_customerId_key" ON "ProductSurveyVote"("surveyId", "customerId");
ALTER TABLE "ProductSurvey" ADD CONSTRAINT "ProductSurvey_createdByCustomerId_fkey" FOREIGN KEY ("createdByCustomerId") REFERENCES "Customer"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "ProductSurvey" ADD CONSTRAINT "ProductSurvey_createdByAdminId_fkey" FOREIGN KEY ("createdByAdminId") REFERENCES "Admin"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "ProductSurvey" ADD CONSTRAINT "ProductSurvey_decidedByAdminId_fkey" FOREIGN KEY ("decidedByAdminId") REFERENCES "Admin"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "ProductSurveyVote" ADD CONSTRAINT "ProductSurveyVote_surveyId_fkey" FOREIGN KEY ("surveyId") REFERENCES "ProductSurvey"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "ProductSurveyVote" ADD CONSTRAINT "ProductSurveyVote_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
INSERT INTO "permission" ("rid", "userTypeId")
SELECT 'product.survey', 85
WHERE EXISTS (SELECT 1 FROM "user_type" WHERE "id" = 85)
ON CONFLICT DO NOTHING;
+121
View File
@@ -181,6 +181,8 @@ model Admin {
sentSmsMessages SmsMessage[] @relation("SmsAuthor")
sentNotifications Notification[] @relation("NotificationAuthor")
uploadedFiles UploadedFile[] @relation("FileUploader")
createdProductSurveys ProductSurvey[] @relation("SurveyAdminCreator")
decidedProductSurveys ProductSurvey[] @relation("SurveyDecider")
/// Issued admin-panel tokens, recorded only while session tracking is on
/// (AUTH_SESSION_TRACKING, or implicitly whenever the IAM is enabled) so a
@@ -564,12 +566,51 @@ model Customer {
activityLogs CustomerActivityLog[]
otpChallenges CustomerOtp[]
favorites CustomerFavorite[]
productSurveys ProductSurvey[] @relation("SurveyCustomerCreator")
productSurveyVotes ProductSurveyVote[]
/// App shopping cart synced from user-app-v2 so daily expiry + reminder SMS
/// can run on the server (localStorage alone cannot).
cart CustomerCart?
/// Deferred «خرید بعدی» list — never reserved against stock and never
/// cleared by the daily cart-expiry worker.
buyLater CustomerBuyLater?
@@index([referredByAgentId])
@@index([firstName])
@@index([lastName])
}
/// Server-side mirror of the customer-app cart (`appCart` in localStorage).
/// Empty `items` means no basket; the daily expiry worker clears it and may
/// SMS a reminder before the configured clear time (Setting `cart.expiry`).
model CustomerCart {
customerId String @id
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
/// Cart lines as JSON — same shape the app stores (pid, vid, qty, …).
items Json @default("[]")
/// Which clear-deadline this cart already got a reminder SMS for (ISO of
/// that deadline). Null = never reminded for the current cycle.
reminderSentForClearAt DateTime?
clearedAt DateTime?
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
}
/// «سبد خرید بعدی» — wish/defer list. Same line shape as the cart, but:
/// never decrements stock, never participates in checkout, never cleared by
/// cart.expiry. Moving a line here only removes it from the active cart.
model CustomerBuyLater {
customerId String @id
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
items Json @default("[]")
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
}
/// Legacy `fav` (251 rows) — saved products. The target had no model for this,
/// so every customer's favourites would have been lost in the migration; the
/// owner confirmed on 2026-10-06 that they matter.
@@ -694,6 +735,9 @@ model Product {
showType Int @default(3)
isActive Boolean @default(true)
createdAt DateTime @default(now())
/// Labels picked from the «ثابت‌ها → برچسب‌ها» list (Setting `__list__tags`).
/// Shown on the app's product cards; tagged products get their own home section.
tags String[] @default([])
/// Stock of the linked کارساخته as last read from the accounting software
/// (D27). Weight for gram-type items, count for count-type ones — whichever
@@ -710,6 +754,7 @@ model Product {
promotions Promotion[]
allocationItems BranchAllocationItem[]
favoritedBy CustomerFavorite[]
views ProductView[]
// Storefront paging (owner: the real catalogue reaches ~16k products, so
// GET /catalog/products must page rather than return everything). This model
@@ -722,6 +767,19 @@ model Product {
@@index([isActive, createdAt])
@@index([categoryId])
@@index([brandId])
@@index([tags], type: Gin)
}
/// One opening of a product's detail screen in the customer app. Read by the
/// admin «آمار» screen for the most-viewed products and brands.
model ProductView {
id String @id @default(cuid())
productId String
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@index([productId, createdAt])
@@index([createdAt])
}
model ProductImage {
@@ -1097,6 +1155,69 @@ model Comment {
@@index([status])
}
// ─────────────────────────────────────────────────────────────────────────
// Product design survey — before manufacturing, a design (photo + suggested
// weight) is shown for like/dislike. Admin or customer can create; customer
// submissions need admin publish. Admin decides APPROVED/REJECTED by hand.
// Not linked to Product / PreOrder.
// ─────────────────────────────────────────────────────────────────────────
enum ProductSurveyStatus {
PENDING_REVIEW
OPEN
CLOSED
APPROVED
REJECTED
}
enum ProductSurveyVoteValue {
LIKE
DISLIKE
}
model ProductSurvey {
id String @id @default(cuid())
title String?
imageUrl String
suggestedWeightGrams Decimal @db.Decimal(14, 4)
status ProductSurveyStatus @default(PENDING_REVIEW)
createdByCustomerId String?
createdByCustomer Customer? @relation("SurveyCustomerCreator", fields: [createdByCustomerId], references: [id])
createdByAdminId String?
createdByAdmin Admin? @relation("SurveyAdminCreator", fields: [createdByAdminId], references: [id])
publishedAt DateTime?
decidedAt DateTime?
decidedByAdminId String?
decidedByAdmin Admin? @relation("SurveyDecider", fields: [decidedByAdminId], references: [id])
decisionNote String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
votes ProductSurveyVote[]
@@index([status])
@@index([createdByCustomerId])
@@index([createdAt])
}
model ProductSurveyVote {
id String @id @default(cuid())
surveyId String
survey ProductSurvey @relation(fields: [surveyId], references: [id], onDelete: Cascade)
customerId String
customer Customer @relation(fields: [customerId], references: [id])
value ProductSurveyVoteValue
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([surveyId, customerId])
@@index([customerId])
}
// ─────────────────────────────────────────────────────────────────────────
// Settings — replaces legacy `config_static`, a key/value table the admin
// panel read and wrote through endpoints like /wallet/config and
+4
View File
@@ -10,6 +10,7 @@ import { CustomerFavoritesModule } from './modules/customer-favorites/customer-f
import { CustomerReportModule } from './modules/customer-report/customer-report.module';
import { SmsModule } from './modules/sms/sms.module';
import { OrdersModule } from './modules/orders/orders.module';
import { CartModule } from './modules/cart/cart.module';
import { AccountingModule } from './modules/accounting/accounting.module';
import { AccountingSyncModule } from './modules/accounting-sync/accounting-sync.module';
import { PricingModule } from './modules/pricing/pricing.module';
@@ -21,6 +22,7 @@ import { AdminOrdersModule } from './modules/admin-orders/admin-orders.module';
import { AdminCitiesModule } from './modules/admin-cities/admin-cities.module';
import { AdminPreOrdersModule } from './modules/admin-preorders/admin-preorders.module';
import { CustomerPreOrdersModule } from './modules/customer-preorders/customer-preorders.module';
import { ProductSurveyModule } from './modules/product-survey/product-survey.module';
import { AdminWorkshopsModule } from './modules/admin-workshops/admin-workshops.module';
import { AdminCustomersModule } from './modules/admin-customers/admin-customers.module';
import { AdminCommentsModule } from './modules/admin-comments/admin-comments.module';
@@ -50,6 +52,7 @@ import { SalesAgentModule } from './modules/sales-agent/sales-agent.module';
AdminCitiesModule,
AdminPreOrdersModule,
CustomerPreOrdersModule,
ProductSurveyModule,
AdminWorkshopsModule,
AdminCustomersModule,
AdminCommentsModule,
@@ -74,6 +77,7 @@ import { SalesAgentModule } from './modules/sales-agent/sales-agent.module';
CustomerReportModule,
SalesAgentModule,
OrdersModule,
CartModule,
AccountingModule,
// Tahesab / Zarrin integration (D27) — inert until switched on in the admin panel
AccountingSyncModule,
@@ -0,0 +1,62 @@
import { appendFileSync, mkdirSync } from 'fs';
import { dirname, resolve } from 'path';
import { envOrNull } from '../../common/env';
import { AccountingProviderError } from './providers/accounting-provider';
/**
* Optional trace of the order-document path, one JSON object per line, for
* testing the integration against a real installation. Off unless
* ACCOUNTING_DEBUG_LOG names a file. Tokens never reach it: callers pass
* params without them, and error text from Zarrin is already scrubbed.
*
* Each failure is tagged with who is at fault:
* ZARRIN — the software refused the call or answered badly
* NETWORK — the server could not be reached or did not answer in time
* DATA — the order cannot be turned into a document (NeedsReviewError)
* CODE — anything else: a bug on our side, logged with its stack
*/
let warned = false;
function target(): string | null {
const file = envOrNull('ACCOUNTING_DEBUG_LOG');
return file ? resolve(file) : null;
}
export function accountingDebugEnabled() {
return target() !== null;
}
export function accountingDebug(event: string, data: Record<string, unknown> = {}) {
const file = target();
if (!file) return;
try {
mkdirSync(dirname(file), { recursive: true });
appendFileSync(file, JSON.stringify({ at: new Date().toISOString(), event, ...data }) + '\n', 'utf8');
} catch (err) {
if (!warned) {
warned = true;
// eslint-disable-next-line no-console
console.warn(`ACCOUNTING_DEBUG_LOG: cannot write ${file}: ${(err as Error).message}`);
}
}
}
export function faultOf(err: unknown): 'ZARRIN' | 'NETWORK' | 'DATA' | 'CODE' {
if (err instanceof AccountingProviderError) {
return /timeout|ECONN|ENOTFOUND|EHOSTUNREACH|socket hang up|برقرار نشد|در زمان مقرر/.test(err.message) ? 'NETWORK' : 'ZARRIN';
}
if ((err as Error)?.name === 'NeedsReviewError') return 'DATA';
return 'CODE';
}
export function errorDetails(err: unknown): Record<string, unknown> {
const fault = faultOf(err);
const e = err as Error & { response?: unknown; ambiguous?: boolean };
return {
fault,
error: e?.message ?? String(err),
...(e instanceof AccountingProviderError ? { ambiguous: e.ambiguous, zarrinResponse: e.response } : {}),
...(fault === 'CODE' ? { stack: e?.stack } : {}),
};
}
@@ -6,6 +6,7 @@ import { AccountingFeatures, documentStatusRank } from './accounting-config';
import { AccountingSyncService } from './accounting-sync.service';
import { AccountingWorkerService } from './accounting-worker.service';
import { ACCOUNTING_PROVIDERS } from './providers';
import { accountingDebug } from './accounting-debug-log';
type Tx = Prisma.TransactionClient | PrismaService;
@@ -54,11 +55,18 @@ export class AccountingHooksService {
*/
async orderStatusReached(tx: Tx, orderId: string, status: string) {
const c = await this.config.get();
if (c.provider === 'none' || !c.features.documents) return;
const trace = (decision: string) =>
accountingDebug('order.status', { orderId, status, provider: c.provider, triggerStatus: c.documents.triggerStatus, decision });
if (c.provider === 'none' || !c.features.documents) return trace('ignored: provider none or documents switched off');
const rank = documentStatusRank(status);
if (!rank) return;
if (rank >= documentStatusRank(c.documents.triggerStatus)) await this.queueOrderDocument(tx, c.provider, orderId);
else await this.orderCancelled(tx, orderId);
if (!rank) return trace('ignored: status never triggers a document');
if (rank >= documentStatusRank(c.documents.triggerStatus)) {
await this.queueOrderDocument(tx, c.provider, orderId);
trace('document queued (unless one was already queued)');
} else {
await this.orderCancelled(tx, orderId);
trace('below trigger status: any sent document withdrawn');
}
}
private async queueOrderDocument(tx: Tx, provider: string, orderId: string) {
@@ -6,6 +6,7 @@ import { ActiveProvider, AccountingConfigService } from './accounting-config.ser
import { AccountingConfig } from './accounting-config';
import { JalaliDate, parseJalali, toJalali } from './jalali';
import { documentWeightTolerance } from './document-check';
import { accountingDebug } from './accounting-debug-log';
import {
AccountingProviderError,
CustomerPayload,
@@ -890,6 +891,7 @@ export class AccountingSyncService {
const doc = await this.buildOrderDocument(a, job.entityId);
const request = { ...doc.input, lines: doc.input.lines.map((l) => ({ ...l, weightGrams: l.weightGrams.toFixed(3), wagePercent: l.wagePercent.toString(), flatRial: l.flatRial.toFixed(0) })), check: doc.check };
await this.prisma.accountingOutbox.update({ where: { id: job.id }, data: { lastRequest: request as unknown as Prisma.InputJsonValue } });
accountingDebug('document.built', { jobId: job.id, orderId: job.entityId, orderNumber: doc.order.orderNumber, progress: job.progress, document: request });
const result = await a.provider.postOrderDocument(
a.settings,
doc.input,
@@ -6,6 +6,7 @@ import { PrismaService } from '../../prisma/prisma.service';
import { ActiveProvider, AccountingConfigService, JobName } from './accounting-config.service';
import { AccountingSyncService, NeedsReviewError } from './accounting-sync.service';
import { AccountingNotSupportedError, AccountingProviderError } from './providers';
import { accountingDebug, errorDetails } from './accounting-debug-log';
/** Scheduled pulls (customers, full balances, full products): the safety net. */
const TICK_MS = 30_000;
@@ -75,7 +76,11 @@ export class AccountingWorkerService implements OnApplicationBootstrap, OnModule
) {}
onApplicationBootstrap() {
if (envOr('ACCOUNTING_WORKER', 'on') === 'off' || process.env.NODE_ENV === 'test') return;
if (envOr('ACCOUNTING_WORKER', 'on') === 'off' || process.env.NODE_ENV === 'test') {
accountingDebug('worker.off', { note: 'ACCOUNTING_WORKER=off: queued documents will not be sent' });
return;
}
accountingDebug('worker.on');
this.timer = setInterval(() => void this.tick(), TICK_MS);
this.timer.unref?.();
this.fastTimer = setInterval(() => void this.fastTick(), FAST_TICK_MS);
@@ -170,6 +175,7 @@ export class AccountingWorkerService implements OnApplicationBootstrap, OnModule
this.lastChangePollAt = Date.now();
await this.pollChanges(a);
} catch (err) {
accountingDebug('worker.tickFailed', errorDetails(err));
this.logger.error(`Accounting fast tick failed: ${(err as Error).message}`);
} finally {
this.fastRunning = false;
@@ -335,8 +341,10 @@ export class AccountingWorkerService implements OnApplicationBootstrap, OnModule
const attempts = job.attempts + 1;
await this.prisma.accountingOutbox.update({ where: { id: job.id }, data: { status: 'SENDING', attempts } });
const fresh = { ...job, attempts };
accountingDebug('job.start', { jobId: job.id, kind: job.kind, entityId: job.entityId, attempt: attempts });
try {
const res = await this.sync.runJob(a, fresh);
accountingDebug('job.sent', { jobId: job.id, kind: job.kind, entityId: job.entityId, externalRef: res.externalRef ?? null, response: res.response });
await this.prisma.accountingOutbox.update({
where: { id: job.id },
data: {
@@ -368,6 +376,7 @@ export class AccountingWorkerService implements OnApplicationBootstrap, OnModule
}
if (response !== undefined) data.lastResponse = toJson(response);
await this.prisma.accountingOutbox.update({ where: { id: job.id }, data });
accountingDebug('job.failed', { jobId: job.id, kind: job.kind, entityId: job.entityId, nextStatus: String(data.status), ...errorDetails(err) });
this.logger.warn(`Accounting job ${job.kind} ${job.entityId} → ${String(data.status)}: ${msg}`);
return false;
}
@@ -1,5 +1,6 @@
import { Prisma } from '@prisma/client';
import { AccountingHttpError, httpRequest, parseLooseJson } from '../accounting-http';
import { accountingDebug, accountingDebugEnabled } from '../accounting-debug-log';
import { JalaliDate, jalaliToGregorian, jalaliToIsoDate, toIsoDateTehran, toJalali } from '../jalali';
const jalaliKey = (d: JalaliDate) => d.jy * 10000 + d.jm * 100 + d.jd;
@@ -80,6 +81,14 @@ function isMissingKind(err: unknown) {
return err instanceof ZarrinHttpError && err.status === 400;
}
function scrubToken(text: string, token: string) {
if (token) for (const t of new Set([token, encodeURIComponent(token)])) text = text.split(t).join('***');
return text;
}
/** Polled every few seconds; traced only when they fail. */
const QUIET_KINDS = new Set(['parLastTenRadif', 'listMandeHesabha', 'listhesabha', 'listKarSakhte', 'mojoodiKala', 'version', 'listGorooheHesabha', 'parvande']);
/**
* What the server said on a non-2xx reply, short enough for the admin screen.
* Error pages may echo the request URL, so the token is scrubbed.
@@ -94,8 +103,7 @@ function errorBodyText(body: string, token: string) {
} catch {
text = body.replace(/<(script|style)[\s\S]*?<\/\1>/gi, ' ').replace(/<[^>]+>/g, ' ');
}
if (token) for (const t of new Set([token, encodeURIComponent(token)])) text = text.split(t).join('***');
text = text.replace(/\s+/g, ' ').trim();
text = scrubToken(text, token).replace(/\s+/g, ' ').trim();
return text.length > 200 ? `${text.slice(0, 200)}…` : text;
}
@@ -133,15 +141,28 @@ async function call(
if (v !== null && v !== undefined && v !== '') url.searchParams.set(k, String(v));
}
const started = Date.now();
const trace = (extra: Record<string, unknown>) => {
if (!accountingDebugEnabled()) return;
if (QUIET_KINDS.has(kind) && !extra.error) return;
accountingDebug('zarrin.call', { kind, params, ms: Date.now() - started, ...extra });
};
let res;
try {
res = await httpRequest({ method: 'GET', url: url.toString(), timeoutMs: opts.timeoutMs ?? s.timeoutMs, ca: s.caCert });
} catch (err) {
trace({ error: (err as Error).message, code: (err as AccountingHttpError).code });
if (err instanceof AccountingHttpError) {
throw new AccountingProviderError(`زرین (${kind}): ${err.message}`, err.ambiguous);
}
throw err;
}
trace({
status: res.status,
body: scrubToken(res.body, s.token).slice(0, 2000),
...(res.status < 200 || res.status >= 300 ? { error: `HTTP ${res.status}` } : {}),
});
if (res.status < 200 || res.status >= 300) {
// v228 answers goroohSakhte with HTTP 400 and a perfectly good body.
@@ -8,6 +8,7 @@ import {
Param,
Post,
Put,
Query,
} from '@nestjs/common';
import { AdminMiscService } from './admin-misc.service';
import { TableQueryDto } from '../admin-table/table-query.dto';
@@ -142,6 +143,13 @@ export class AdminMiscController {
return this.misc.customerLog(id);
}
@Get('stats/top')
@RequirePermission('statics')
topStats(@Query('days') days?: string) {
const n = Number(days);
return this.misc.topStats(Number.isFinite(n) && n > 0 ? Math.min(Math.floor(n), 3650) : 0);
}
// ── settings + brands ("مدیریت ثابت ها") ─────────────────────────
@Get('dashboard-links')
@RequirePermission('config.report')
@@ -6,6 +6,7 @@ import { SmsService } from '../sms/sms.service';
import type { TableQueryDto } from '../admin-table/table-query.dto';
import { ACCOUNTING_SETTING_PREFIX } from '../accounting-sync/accounting-config';
import { PRICING_SETTING_PREFIX } from '../pricing/wage.service';
import { TAG_LIST_KEY } from '../admin-products/admin-products.service';
/**
* The remaining admin screens — legacy views/city, views/sms, views/notify,
@@ -542,6 +543,68 @@ export class AdminMiscService {
return rows.map((c) => ({ id: c.id, name: c.fullName, mobile: c.mobile }));
}
/**
* Best-selling products, most-viewed products and most-viewed brands.
* `days` 0 means all time. Cancelled orders do not count as sales.
*/
async topStats(days: number, limit = 10) {
const since = days > 0 ? new Date(Date.now() - days * 86_400_000) : new Date(0);
const [soldTotals, viewTotals, firstView, sold, viewedProducts, viewedBrands] = await Promise.all([
this.prisma.$queryRaw<{ qty: bigint | null; orders: bigint }[]>`
SELECT SUM(oi."quantity") AS "qty", COUNT(DISTINCT o."id") AS "orders"
FROM "OrderItem" oi
JOIN "Order" o ON o."id" = oi."orderId"
WHERE o."status" <> 'CANCELLED' AND o."createdAt" >= ${since}`,
this.prisma.$queryRaw<{ views: bigint; products: bigint }[]>`
SELECT COUNT(*) AS "views", COUNT(DISTINCT pv."productId") AS "products"
FROM "ProductView" pv
WHERE pv."createdAt" >= ${since}`,
this.prisma.productView.findFirst({ orderBy: { createdAt: 'asc' }, select: { createdAt: true } }),
this.prisma.$queryRaw<{ id: string; title: string; code: string | null; qty: bigint; orders: bigint }[]>`
SELECT p."id", p."title", p."productCode" AS "code",
SUM(oi."quantity") AS "qty", COUNT(DISTINCT o."id") AS "orders"
FROM "OrderItem" oi
JOIN "Order" o ON o."id" = oi."orderId"
JOIN "ProductVariant" v ON v."id" = oi."productVariantId"
JOIN "Product" p ON p."id" = v."productId"
WHERE o."status" <> 'CANCELLED' AND o."createdAt" >= ${since}
GROUP BY p."id", p."title", p."productCode"
ORDER BY "qty" DESC
LIMIT ${limit}`,
this.prisma.$queryRaw<{ id: string; title: string; code: string | null; views: bigint }[]>`
SELECT p."id", p."title", p."productCode" AS "code", COUNT(*) AS "views"
FROM "ProductView" pv
JOIN "Product" p ON p."id" = pv."productId"
WHERE pv."createdAt" >= ${since}
GROUP BY p."id", p."title", p."productCode"
ORDER BY "views" DESC
LIMIT ${limit}`,
this.prisma.$queryRaw<{ id: string; title: string; views: bigint }[]>`
SELECT b."id", b."title", COUNT(*) AS "views"
FROM "ProductView" pv
JOIN "Product" p ON p."id" = pv."productId"
JOIN "Brand" b ON b."id" = p."brandId"
WHERE pv."createdAt" >= ${since}
GROUP BY b."id", b."title"
ORDER BY "views" DESC
LIMIT ${limit}`,
]);
return {
totals: {
soldQty: Number(soldTotals[0]?.qty ?? 0),
orders: Number(soldTotals[0]?.orders ?? 0),
views: Number(viewTotals[0]?.views ?? 0),
viewedProducts: Number(viewTotals[0]?.products ?? 0),
},
viewsTrackedSince: firstView?.createdAt ?? null,
topSoldProducts: sold.map((r) => ({ id: r.id, title: r.title, code: r.code, qty: Number(r.qty), orders: Number(r.orders) })),
topViewedProducts: viewedProducts.map((r) => ({ id: r.id, title: r.title, code: r.code, views: Number(r.views) })),
topViewedBrands: viewedBrands.map((r) => ({ id: r.id, title: r.title, views: Number(r.views) })),
};
}
/** Legacy `GET log/ago/{customerId}` — that customer's recent activity. */
async customerLog(customerId: string) {
const customer = await this.prisma.customer.findUnique({ where: { id: customerId } });
@@ -570,6 +633,7 @@ export class AdminMiscService {
/** Legacy `POST const` / `PUT const/{key}`. */
async setConst(key: string, value: unknown) {
if (key === TAG_LIST_KEY) await this.dropRemovedTags(value);
const row = await this.prisma.setting.upsert({
where: { key },
update: { value: value as Prisma.InputJsonValue },
@@ -578,6 +642,25 @@ export class AdminMiscService {
return row.value;
}
/** A label deleted from the tag list is taken off every product that carried it. */
private async dropRemovedTags(next: unknown) {
const keep = new Set(Array.isArray(next) ? next.map((t) => String(t).trim()) : []);
const before = await this.getConst(TAG_LIST_KEY);
const removed = (Array.isArray(before) ? before.map((t) => String(t).trim()) : []).filter((t) => !keep.has(t));
if (!removed.length) return;
const products = await this.prisma.product.findMany({
where: { tags: { hasSome: removed } },
select: { id: true, tags: true },
});
for (const p of products) {
await this.prisma.product.update({
where: { id: p.id },
data: { tags: { set: p.tags.filter((t) => !removed.includes(t)) } },
});
}
}
async listConsts() {
// The accounting integration's rows (connection, encrypted token, worker
// lease) have their own `config.accounting` screen and never appear here
@@ -91,6 +91,12 @@ export class AdminProductsController {
return this.products.brands();
}
@Get('tags')
@RequirePermission('product.list')
tags() {
return this.products.tagOptions();
}
/** Brands as an XTable grid — the exclusion picker needs a selectable table. */
@Post('brands-table')
@HttpCode(200)
@@ -20,6 +20,9 @@ const SHOW_TYPE_LABELS: Record<number, string> = {
3: 'سایت و گوشی',
};
/** Setting key of the «ثابت‌ها → برچسب‌ها» list. */
export const TAG_LIST_KEY = '__list__tags';
export const VARIANT_COLORS: Record<number, string> = {
0: 'سفید',
1: 'زرد',
@@ -185,6 +188,7 @@ export class AdminProductsService {
: null,
body_html: p.description,
brand_id: p.brandId,
tags: p.tags,
cats: p.categoryId ? [p.categoryId] : [],
ciny_ids: p.collections.map((c) => c.collectionId),
variants_all: p.variants.map((v) => this.variantRow(v)),
@@ -447,6 +451,7 @@ export class AdminProductsService {
const images = this.imageCreateData(data);
const productCode = (data.product_code as string) || null;
const tags = await this.normalizeTags(data.tags);
// Images are created with the product so their ids exist before variants
// are attached — a variant's `image_id` has to reference a real row.
@@ -459,6 +464,7 @@ export class AdminProductsService {
isActive: data.is_active !== undefined ? Boolean(data.is_active) : true,
brandId: (data.brand_id as string) || null,
categoryId: cats[0] ?? null,
tags,
collections: cinyIds.length
? { create: cinyIds.map((collectionId) => ({ collectionId })) }
: undefined,
@@ -518,6 +524,7 @@ export class AdminProductsService {
if (data.product_code !== undefined) patch.productCode = (data.product_code as string) || null;
if (data.show_type !== undefined) patch.showType = Number(data.show_type);
if (data.is_active !== undefined) patch.isActive = Boolean(data.is_active);
if (data.tags !== undefined) patch.tags = { set: await this.normalizeTags(data.tags) };
if (data.brand_id !== undefined) {
patch.brand = data.brand_id ? { connect: { id: String(data.brand_id) } } : { disconnect: true };
}
@@ -728,6 +735,21 @@ export class AdminProductsService {
return this.prisma.brand.findMany({ orderBy: { title: 'asc' } });
}
/** The labels defined under «ثابت‌ها → برچسب‌ها» — the product form's tag picker. */
async tagOptions(): Promise<string[]> {
const row = await this.prisma.setting.findUnique({ where: { key: TAG_LIST_KEY } });
const value = row?.value;
return Array.isArray(value) ? value.map((t) => String(t).trim()).filter(Boolean) : [];
}
/** Keeps only labels that exist in the tag list, trimmed and de-duplicated. */
private async normalizeTags(raw: unknown): Promise<string[]> {
if (!Array.isArray(raw)) return [];
const allowed = new Set(await this.tagOptions());
const picked = raw.map((t) => String(t).trim()).filter((t) => allowed.has(t));
return [...new Set(picked)];
}
/** Brands in the XTable envelope, for the promotion-exclusion picker. */
async brandsTable(query: TableQueryDto) {
this.table.assertNotExport(query);
@@ -46,6 +46,7 @@ export const ALL_RIDS = [
'config.statics.tags.edit',
'config.statics.tags.remove',
'config.wallet',
'config.cart',
'custom.config.statics.slider',
'custom.config.statics.tags.banner.ids',
'dashboard.view',
@@ -137,6 +138,7 @@ export const ALL_RIDS = [
'product.list.variants.remove',
'product.list.view',
'product.percent',
'product.survey',
'report.order.item',
'report.product',
'sms.create',
+1
View File
@@ -36,6 +36,7 @@ export const SUPER_ADMIN_RIDS = [
'offers.list.tak.edit',
'offers.list.tak.remove',
'config.wallet',
'config.cart',
] as const;
export function isSuperAdmin(mobile: string | null | undefined): boolean {
@@ -0,0 +1,21 @@
import { Body, Controller, Get, Put } from '@nestjs/common';
import { RequirePermission } from '../auth/guards';
import { CartService } from './cart.service';
import { UpdateCartExpiryConfigDto } from './cart.dto';
@Controller('admin/cart-expiry')
export class AdminCartExpiryController {
constructor(private readonly cart: CartService) {}
@Get('config')
@RequirePermission('config.cart')
getConfig() {
return this.cart.getConfig();
}
@Put('config')
@RequirePermission('config.cart')
putConfig(@Body() dto: UpdateCartExpiryConfigDto) {
return this.cart.setConfig(dto as Record<string, unknown>);
}
}
@@ -0,0 +1,124 @@
import { CartExpiryWorkerService } from './cart-expiry.worker';
import { tehranWallToUtc } from './cart-schedule';
describe('CartExpiryWorkerService', () => {
function build(opts: {
cfg?: Record<string, unknown>;
carts?: Array<Record<string, unknown>>;
}) {
const carts = opts.carts ?? [];
const updates: Array<{ customerId: string; data: Record<string, unknown> }> = [];
const sms = { send: jest.fn(async () => ({ ok: true })) };
const prisma = {
customerCart: {
findMany: jest.fn(async () => carts),
update: jest.fn(async ({ where, data }: { where: { customerId: string }; data: Record<string, unknown> }) => {
updates.push({ customerId: where.customerId, data });
return {};
}),
},
$executeRaw: jest.fn(async () => 1),
};
const cart = {
getConfig: jest.fn(async () => ({
enabled: true,
clearHour: 13,
clearMinute: 0,
sameDayDeferFromHour: 8,
sameDayDeferFromMinute: 0,
reminderEnabled: true,
reminderHoursBefore: 2,
reminderBody: 'تا %count ساعت',
worker: 'on' as const,
...opts.cfg,
})),
};
const worker = new CartExpiryWorkerService(prisma as never, cart as never, sms as never);
return { worker, sms, updates, prisma, cart };
}
it('no-ops when disabled', async () => {
const { worker, sms, prisma } = build({ cfg: { enabled: false } });
await worker.tick(tehranWallToUtc(2026, 11, 12, 12, 0, 0));
expect(sms.send).not.toHaveBeenCalled();
expect(prisma.customerCart.findMany).not.toHaveBeenCalled();
});
it('reminds carts due today (touched before 08:00), not deferred ones', async () => {
const todayClear = tehranWallToUtc(2026, 11, 12, 13, 0, 0);
const { worker, sms, updates } = build({
carts: [
{
customerId: 'early',
items: [{ pid: 'p1', vid: 'v1', qty: 1 }],
reminderSentForClearAt: null,
updatedAt: tehranWallToUtc(2026, 11, 12, 7, 0, 0),
customer: { mobile: '09120000000', isActive: true },
},
{
customerId: 'deferred',
items: [{ pid: 'p2', vid: 'v2', qty: 1 }],
reminderSentForClearAt: null,
// 10:00 → deadline tomorrow 13 — no reminder yet at 11:30 today
updatedAt: tehranWallToUtc(2026, 11, 12, 10, 0, 0),
customer: { mobile: '09121111111', isActive: true },
},
],
});
await worker.tick(tehranWallToUtc(2026, 11, 12, 11, 30, 0));
expect(sms.send).toHaveBeenCalledTimes(1);
expect(sms.send).toHaveBeenCalledWith('09120000000', expect.any(String));
expect(updates[0]?.customerId).toBe('early');
expect(updates[0]?.data.reminderSentForClearAt).toEqual(todayClear);
});
it('does not clear 08–13 carts at today 13; clears pre-08 carts', async () => {
const { worker, updates, sms } = build({
carts: [
{
customerId: 'early',
items: [{ pid: 'p1', vid: 'v1', qty: 1 }],
reminderSentForClearAt: null,
updatedAt: tehranWallToUtc(2026, 11, 12, 7, 0, 0),
customer: { mobile: '09120000000', isActive: true },
},
{
customerId: 'deferred',
items: [{ pid: 'p2', vid: 'v2', qty: 1 }],
reminderSentForClearAt: null,
updatedAt: tehranWallToUtc(2026, 11, 12, 10, 0, 0),
customer: { mobile: '09121111111', isActive: true },
},
{
customerId: 'afternoon',
items: [{ pid: 'p3', vid: 'v3', qty: 1 }],
reminderSentForClearAt: null,
updatedAt: tehranWallToUtc(2026, 11, 12, 14, 0, 0),
customer: { mobile: '09122222222', isActive: true },
},
],
});
await worker.tick(tehranWallToUtc(2026, 11, 12, 13, 5, 0));
expect(sms.send).not.toHaveBeenCalled();
expect(updates).toHaveLength(1);
expect(updates[0].customerId).toBe('early');
expect(updates[0].data.items).toEqual([]);
});
it('clears deferred (08–13) carts at tomorrow 13', async () => {
const { worker, updates } = build({
carts: [
{
customerId: 'deferred',
items: [{ pid: 'p2', vid: 'v2', qty: 1 }],
reminderSentForClearAt: null,
updatedAt: tehranWallToUtc(2026, 11, 12, 10, 0, 0),
customer: { mobile: '09121111111', isActive: true },
},
],
});
await worker.tick(tehranWallToUtc(2026, 11, 13, 13, 5, 0));
expect(updates).toHaveLength(1);
expect(updates[0].customerId).toBe('deferred');
});
});
@@ -0,0 +1,140 @@
import { Injectable, Logger, OnApplicationBootstrap, OnModuleDestroy } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { randomUUID } from 'crypto';
import { PrismaService } from '../../prisma/prisma.service';
import { SmsService } from '../sms/sms.service';
import {
CART_EXPIRY_LEASE_KEY,
cartItemsNonEmpty,
clearDeadlineForTouchedAt,
formatReminderBody,
reminderAtForDeadline,
type CartExpiryConfig,
} from './cart-schedule';
import { CartService } from './cart.service';
const TICK_MS = 60_000;
const LEASE_MS = 90_000;
/**
* Daily cart clear + reminder SMS. Same in-process lease pattern as
* AccountingWorkerService — no @nestjs/schedule.
*
* Clear/reminder deadlines are per cart from `updatedAt` (same-day defer
* window: touch in [08:00, 13:00) → clear tomorrow 13:00).
*/
@Injectable()
export class CartExpiryWorkerService implements OnApplicationBootstrap, OnModuleDestroy {
private readonly logger = new Logger('CartExpiry');
private readonly owner = randomUUID();
private timer: NodeJS.Timeout | null = null;
private running = false;
constructor(
private readonly prisma: PrismaService,
private readonly cart: CartService,
private readonly sms: SmsService,
) {}
onApplicationBootstrap() {
if (process.env.NODE_ENV === 'test') return;
this.timer = setInterval(() => void this.tick(), TICK_MS);
this.timer.unref?.();
}
onModuleDestroy() {
if (this.timer) clearInterval(this.timer);
}
/** Exposed for unit tests. */
async tick(now = new Date()): Promise<void> {
if (this.running) return;
this.running = true;
try {
const cfg = await this.cart.getConfig();
if (!cfg.enabled || cfg.worker === 'off') return;
if (!(await this.acquireLease())) return;
await this.sendReminders(cfg, now);
await this.clearDueCarts(cfg, now);
} catch (err) {
this.logger.error(
`cart expiry tick failed: ${err instanceof Error ? err.message : String(err)}`,
);
} finally {
this.running = false;
}
}
private async sendReminders(cfg: CartExpiryConfig, now: Date): Promise<void> {
if (!cfg.reminderEnabled) return;
const carts = await this.prisma.customerCart.findMany({
include: {
customer: { select: { mobile: true, isActive: true } },
},
});
const body = formatReminderBody(cfg.reminderBody, cfg.reminderHoursBefore);
for (const row of carts) {
if (!cartItemsNonEmpty(row.items)) continue;
if (!row.customer.isActive || !row.customer.mobile) continue;
const deadline = clearDeadlineForTouchedAt(row.updatedAt, cfg);
const reminderAt = reminderAtForDeadline(deadline, cfg.reminderHoursBefore);
if (now.getTime() < reminderAt.getTime()) continue;
if (now.getTime() >= deadline.getTime()) continue;
if (
row.reminderSentForClearAt &&
row.reminderSentForClearAt.getTime() === deadline.getTime()
) {
continue;
}
try {
await this.sms.send(row.customer.mobile, body);
await this.prisma.customerCart.update({
where: { customerId: row.customerId },
data: { reminderSentForClearAt: deadline },
});
} catch (err) {
this.logger.warn(
`reminder SMS failed for ${row.customerId}: ${
err instanceof Error ? err.message : String(err)
}`,
);
}
}
}
private async clearDueCarts(cfg: CartExpiryConfig, now: Date): Promise<void> {
const carts = await this.prisma.customerCart.findMany();
for (const row of carts) {
if (!cartItemsNonEmpty(row.items)) continue;
const deadline = clearDeadlineForTouchedAt(row.updatedAt, cfg);
if (now.getTime() < deadline.getTime()) continue;
await this.prisma.customerCart.update({
where: { customerId: row.customerId },
data: {
items: [] as Prisma.InputJsonValue,
clearedAt: now,
reminderSentForClearAt: null,
},
});
}
}
private async acquireLease(): Promise<boolean> {
const value = JSON.stringify({
owner: this.owner,
until: new Date(Date.now() + LEASE_MS).toISOString(),
});
await this.prisma.$executeRaw`
INSERT INTO "Setting" ("key", "value", "updatedAt") VALUES (${CART_EXPIRY_LEASE_KEY}, ${value}::jsonb, now())
ON CONFLICT ("key") DO NOTHING`;
const n = await this.prisma.$executeRaw`
UPDATE "Setting" SET "value" = ${value}::jsonb, "updatedAt" = now()
WHERE "key" = ${CART_EXPIRY_LEASE_KEY}
AND ("value"->>'owner' = ${this.owner} OR ("value"->>'until')::timestamptz < now())`;
return n > 0;
}
}
@@ -0,0 +1,100 @@
import {
DEFAULT_CART_EXPIRY,
clearDeadlineForTouchedAt,
formatReminderBody,
nextClearAt,
nextReminderAt,
parseCartExpiryConfig,
tehranParts,
tehranWallToUtc,
todayClearAt,
} from './cart-schedule';
const deferCfg = {
clearHour: 13,
clearMinute: 0,
sameDayDeferFromHour: 8,
sameDayDeferFromMinute: 0,
};
describe('cart-schedule', () => {
it('parses defaults including same-day defer from 08:00', () => {
const cfg = parseCartExpiryConfig({});
expect(cfg.enabled).toBe(false);
expect(cfg.clearHour).toBe(13);
expect(cfg.sameDayDeferFromHour).toBe(8);
expect(cfg.reminderHoursBefore).toBe(2);
});
it('clamps hour/minute and keeps custom body', () => {
const cfg = parseCartExpiryConfig({
enabled: true,
clearHour: 99,
clearMinute: -3,
reminderHoursBefore: '4',
reminderBody: 'سبد تا %count ساعت دیگر',
worker: 'off',
});
expect(cfg.enabled).toBe(true);
expect(cfg.clearHour).toBe(23);
expect(cfg.clearMinute).toBe(0);
expect(cfg.reminderHoursBefore).toBe(4);
expect(cfg.reminderBody).toContain('%count');
expect(cfg.worker).toBe('off');
});
it('todayClearAt is today at the configured Tehran wall time', () => {
const now = tehranWallToUtc(2026, 11, 12, 10, 0, 0);
const clear = todayClearAt(now, 13, 0);
expect(tehranParts(clear)).toMatchObject({
year: 2026,
month: 11,
day: 12,
hour: 13,
minute: 0,
});
});
it('nextClearAt is today before the clear, else tomorrow', () => {
const morning = tehranWallToUtc(2026, 11, 12, 11, 0, 0);
const afternoon = tehranWallToUtc(2026, 11, 12, 14, 0, 0);
expect(tehranParts(nextClearAt(morning, 13, 0))).toMatchObject({ day: 12, hour: 13 });
expect(tehranParts(nextClearAt(afternoon, 13, 0))).toMatchObject({ day: 13, hour: 13 });
});
it('nextReminderAt is clear minus reminderHoursBefore', () => {
const morning = tehranWallToUtc(2026, 11, 12, 10, 0, 0);
const reminder = nextReminderAt(morning, 13, 0, 2);
expect(tehranParts(reminder)).toMatchObject({ day: 12, hour: 11, minute: 0 });
});
it('clearDeadline: before 08:00 → same-day 13:00', () => {
const touched = tehranWallToUtc(2026, 11, 12, 7, 30, 0);
const deadline = clearDeadlineForTouchedAt(touched, deferCfg);
expect(tehranParts(deadline)).toMatchObject({ day: 12, hour: 13 });
});
it('clearDeadline: 08:00..13:00 → tomorrow 13:00', () => {
const atEight = tehranWallToUtc(2026, 11, 12, 8, 0, 0);
const atTen = tehranWallToUtc(2026, 11, 12, 10, 0, 0);
const atNoon = tehranWallToUtc(2026, 11, 12, 12, 59, 0);
for (const touched of [atEight, atTen, atNoon]) {
expect(tehranParts(clearDeadlineForTouchedAt(touched, deferCfg))).toMatchObject({
day: 13,
hour: 13,
});
}
});
it('clearDeadline: at/after 13:00 → tomorrow 13:00', () => {
const touched = tehranWallToUtc(2026, 11, 12, 14, 0, 0);
expect(tehranParts(clearDeadlineForTouchedAt(touched, deferCfg))).toMatchObject({
day: 13,
hour: 13,
});
});
it('formatReminderBody replaces %count', () => {
expect(formatReminderBody(DEFAULT_CART_EXPIRY.reminderBody, 2)).toContain('2');
});
});
+238
View File
@@ -0,0 +1,238 @@
/**
* Tehran wall-clock helpers for daily cart expiry.
* No luxon/dayjs — Intl + UTC offset correction only.
*/
export const CART_EXPIRY_SETTING_KEY = 'cart.expiry';
export const CART_EXPIRY_LEASE_KEY = 'cart.expiry.lease';
export type CartExpiryConfig = {
enabled: boolean;
clearHour: number;
clearMinute: number;
/**
* Same-day defer window start (Tehran). Items touched in
* [sameDayDeferFrom, clear) expire at tomorrow's clear, not today's.
* Default 08:00 → 13:00 same day maps to 13:00 next day.
*/
sameDayDeferFromHour: number;
sameDayDeferFromMinute: number;
reminderEnabled: boolean;
reminderHoursBefore: number;
reminderBody: string;
worker: 'on' | 'off';
};
export const DEFAULT_CART_EXPIRY: CartExpiryConfig = {
enabled: false,
clearHour: 13,
clearMinute: 0,
sameDayDeferFromHour: 8,
sameDayDeferFromMinute: 0,
reminderEnabled: true,
reminderHoursBefore: 2,
reminderBody:
'سبد خرید شما تا %count ساعت دیگر خالی می‌شود. برای ثبت سفارش همین امروز اقدام کنید.',
worker: 'on',
};
export function parseCartExpiryConfig(raw: unknown): CartExpiryConfig {
const o = raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {};
const clearHour = clampInt(o.clearHour, 0, 23, DEFAULT_CART_EXPIRY.clearHour);
const clearMinute = clampInt(o.clearMinute, 0, 59, DEFAULT_CART_EXPIRY.clearMinute);
const sameDayDeferFromHour = clampInt(
o.sameDayDeferFromHour,
0,
23,
DEFAULT_CART_EXPIRY.sameDayDeferFromHour,
);
const sameDayDeferFromMinute = clampInt(
o.sameDayDeferFromMinute,
0,
59,
DEFAULT_CART_EXPIRY.sameDayDeferFromMinute,
);
const reminderHoursBefore = Math.max(
0,
clampInt(o.reminderHoursBefore, 0, 48, DEFAULT_CART_EXPIRY.reminderHoursBefore),
);
return {
enabled: o.enabled === true,
clearHour,
clearMinute,
sameDayDeferFromHour,
sameDayDeferFromMinute,
reminderEnabled: o.reminderEnabled !== false,
reminderHoursBefore,
reminderBody:
typeof o.reminderBody === 'string' && o.reminderBody.trim()
? o.reminderBody.trim()
: DEFAULT_CART_EXPIRY.reminderBody,
worker: o.worker === 'off' ? 'off' : 'on',
};
}
function clampInt(v: unknown, min: number, max: number, fallback: number): number {
const n = typeof v === 'number' ? v : typeof v === 'string' ? Number(v) : NaN;
if (!Number.isFinite(n)) return fallback;
return Math.min(max, Math.max(min, Math.trunc(n)));
}
type TehranParts = {
year: number;
month: number;
day: number;
hour: number;
minute: number;
second: number;
};
export function tehranParts(date: Date): TehranParts {
const fmt = new Intl.DateTimeFormat('en-US', {
timeZone: 'Asia/Tehran',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hourCycle: 'h23',
});
const bag: Record<string, string> = {};
for (const p of fmt.formatToParts(date)) {
if (p.type !== 'literal') bag[p.type] = p.value;
}
return {
year: Number(bag.year),
month: Number(bag.month),
day: Number(bag.day),
hour: Number(bag.hour),
minute: Number(bag.minute),
second: Number(bag.second),
};
}
/** Instant corresponding to a Tehran civil date-time. */
export function tehranWallToUtc(
year: number,
month: number,
day: number,
hour: number,
minute: number,
second = 0,
): Date {
const utcGuess = new Date(Date.UTC(year, month - 1, day, hour, minute, second));
const shown = tehranParts(utcGuess);
const wantedMs = Date.UTC(year, month - 1, day, hour, minute, second);
const shownMs = Date.UTC(
shown.year,
shown.month - 1,
shown.day,
shown.hour,
shown.minute,
shown.second,
);
return new Date(utcGuess.getTime() + (wantedMs - shownMs));
}
export function addTehranDays(
year: number,
month: number,
day: number,
delta: number,
): {
year: number;
month: number;
day: number;
} {
// Noon UTC avoids DST edge when shifting calendar days via Date.
const noon = tehranWallToUtc(year, month, day, 12, 0, 0);
const shifted = new Date(noon.getTime() + delta * 24 * 60 * 60 * 1000);
const p = tehranParts(shifted);
return { year: p.year, month: p.month, day: p.day };
}
/** Today's clear instant in Tehran for the given hour/minute. */
export function todayClearAt(now: Date, clearHour: number, clearMinute: number): Date {
const p = tehranParts(now);
return tehranWallToUtc(p.year, p.month, p.day, clearHour, clearMinute, 0);
}
/** Nearest upcoming wall clear (ignores per-cart defer). */
export function nextClearAt(now: Date, clearHour: number, clearMinute: number): Date {
const today = todayClearAt(now, clearHour, clearMinute);
if (now.getTime() < today.getTime()) return today;
const p = tehranParts(now);
const tmr = addTehranDays(p.year, p.month, p.day, 1);
return tehranWallToUtc(tmr.year, tmr.month, tmr.day, clearHour, clearMinute, 0);
}
export function nextReminderAt(
now: Date,
clearHour: number,
clearMinute: number,
reminderHoursBefore: number,
): Date {
const clear = nextClearAt(now, clearHour, clearMinute);
return new Date(clear.getTime() - reminderHoursBefore * 60 * 60 * 1000);
}
/**
* Per-cart clear deadline from last touch time.
*
* With defer 08:00 and clear 13:00:
* - touched 07:00 → clear same day 13:00
* - touched 08:00..12:59 → clear next day 13:00
* - touched ≥ 13:00 → clear next day 13:00
*/
export function clearDeadlineForTouchedAt(
touchedAt: Date,
cfg: Pick<
CartExpiryConfig,
| 'clearHour'
| 'clearMinute'
| 'sameDayDeferFromHour'
| 'sameDayDeferFromMinute'
>,
): Date {
const p = tehranParts(touchedAt);
const dayClear = tehranWallToUtc(p.year, p.month, p.day, cfg.clearHour, cfg.clearMinute, 0);
const deferFrom = tehranWallToUtc(
p.year,
p.month,
p.day,
cfg.sameDayDeferFromHour,
cfg.sameDayDeferFromMinute,
0,
);
const tomorrowClear = (): Date => {
const tmr = addTehranDays(p.year, p.month, p.day, 1);
return tehranWallToUtc(tmr.year, tmr.month, tmr.day, cfg.clearHour, cfg.clearMinute, 0);
};
if (touchedAt.getTime() >= dayClear.getTime()) {
return tomorrowClear();
}
// In [deferFrom, clear) → skip today's clear.
if (touchedAt.getTime() >= deferFrom.getTime()) {
return tomorrowClear();
}
return dayClear;
}
export function reminderAtForDeadline(
deadline: Date,
reminderHoursBefore: number,
): Date {
return new Date(deadline.getTime() - reminderHoursBefore * 60 * 60 * 1000);
}
/** Format reminder body; `%count` → hours until clear (at least 1 when >0). */
export function formatReminderBody(template: string, reminderHoursBefore: number): string {
const count = reminderHoursBefore <= 0 ? '۰' : String(reminderHoursBefore);
return template.replace(/%count/g, count);
}
export function cartItemsNonEmpty(items: unknown): boolean {
return Array.isArray(items) && items.length > 0;
}
@@ -0,0 +1,52 @@
import { Body, Controller, Get, Put } from '@nestjs/common';
import { Public } from '../auth/guards';
import {
CustomerAuth,
CurrentCustomer,
} from '../customer-auth/customer.guards';
import type { AuthenticatedCustomer } from '../customer-auth/customer-jwt.strategy';
import { CartService } from './cart.service';
import { PutCartDto } from './cart.dto';
@Controller('cart')
export class CartController {
constructor(private readonly cart: CartService) {}
/** Safe expiry policy for guest + logged-in clients. */
@Public()
@Get('policy')
getPolicy() {
return this.cart.getPolicy();
}
@CustomerAuth()
@Get()
getMine(@CurrentCustomer() me: AuthenticatedCustomer) {
return this.cart.getCart(me.id);
}
@CustomerAuth()
@Put()
putMine(
@CurrentCustomer() me: AuthenticatedCustomer,
@Body() dto: PutCartDto,
) {
return this.cart.putCart(me.id, dto.items);
}
/** Deferred «خرید بعدی» — not cleared by cart expiry or order submit. */
@CustomerAuth()
@Get('buy-later')
getBuyLater(@CurrentCustomer() me: AuthenticatedCustomer) {
return this.cart.getBuyLater(me.id);
}
@CustomerAuth()
@Put('buy-later')
putBuyLater(
@CurrentCustomer() me: AuthenticatedCustomer,
@Body() dto: PutCartDto,
) {
return this.cart.putBuyLater(me.id, dto.items);
}
}
+70
View File
@@ -0,0 +1,70 @@
import { Type } from 'class-transformer';
import {
IsArray,
IsBoolean,
IsInt,
IsOptional,
IsString,
Max,
MaxLength,
Min,
} from 'class-validator';
export class PutCartDto {
@IsArray()
items!: unknown[];
}
export class UpdateCartExpiryConfigDto {
@IsOptional()
@IsBoolean()
enabled?: boolean;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
@Max(23)
clearHour?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
@Max(59)
clearMinute?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
@Max(23)
sameDayDeferFromHour?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
@Max(59)
sameDayDeferFromMinute?: number;
@IsOptional()
@IsBoolean()
reminderEnabled?: boolean;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
@Max(48)
reminderHoursBefore?: number;
@IsOptional()
@IsString()
@MaxLength(500)
reminderBody?: string;
@IsOptional()
@IsString()
worker?: 'on' | 'off';
}
+14
View File
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { SmsModule } from '../sms/sms.module';
import { AdminCartExpiryController } from './admin-cart-expiry.controller';
import { CartController } from './cart.controller';
import { CartExpiryWorkerService } from './cart-expiry.worker';
import { CartService } from './cart.service';
@Module({
imports: [SmsModule],
controllers: [CartController, AdminCartExpiryController],
providers: [CartService, CartExpiryWorkerService],
exports: [CartService],
})
export class CartModule {}
@@ -0,0 +1,104 @@
import { BadRequestException } from '@nestjs/common';
import { CartService } from './cart.service';
import { CART_EXPIRY_SETTING_KEY } from './cart-schedule';
describe('CartService', () => {
function build(settingValue: unknown = {}) {
const carts = new Map<string, Record<string, unknown>>();
const prisma = {
setting: {
findUnique: jest.fn(async ({ where }: { where: { key: string } }) =>
where.key === CART_EXPIRY_SETTING_KEY ? { value: settingValue } : null,
),
upsert: jest.fn(async ({ create, update }: { create: { value: unknown }; update: { value: unknown } }) => ({
value: update?.value ?? create.value,
})),
},
customerCart: {
findUnique: jest.fn(async ({ where }: { where: { customerId: string } }) =>
carts.get(where.customerId) ?? null,
),
upsert: jest.fn(async ({ where, create, update }: {
where: { customerId: string };
create: Record<string, unknown>;
update: Record<string, unknown>;
}) => {
const prev = carts.get(where.customerId);
const row = {
customerId: where.customerId,
updatedAt: new Date('2026-11-12T08:00:00.000Z'),
...(prev ?? {}),
...(prev ? update : create),
};
carts.set(where.customerId, row);
return row;
}),
},
};
const service = new CartService(prisma as never);
return { service, prisma, carts };
}
it('getPolicy exposes todayClearAt and nextClearAt when enabled', async () => {
const { service } = build({ enabled: true, clearHour: 13, clearMinute: 0 });
const policy = await service.getPolicy(new Date('2026-11-12T07:00:00.000Z'));
expect(policy.enabled).toBe(true);
expect(policy.clearHour).toBe(13);
expect(policy.todayClearAt).toBeTruthy();
expect(policy.nextClearAt).toBeTruthy();
});
it('putCart rejects non-array items', async () => {
const { service } = build();
await expect(service.putCart('c1', {} as never)).rejects.toBeInstanceOf(BadRequestException);
});
it('putCart upserts and clearForCustomer empties', async () => {
const { service, carts } = build();
await service.putCart('c1', [{ pid: 'p', vid: 'v', qty: 2 }]);
expect((carts.get('c1')?.items as unknown[]).length).toBe(1);
await service.clearForCustomer('c1');
expect(carts.get('c1')?.items).toEqual([]);
});
it('setConfig writes a parsed config', async () => {
const { service, prisma } = build();
const cfg = await service.setConfig({ enabled: true, clearHour: 14, reminderHoursBefore: 3 });
expect(cfg.enabled).toBe(true);
expect(cfg.clearHour).toBe(14);
expect(cfg.reminderHoursBefore).toBe(3);
expect(prisma.setting.upsert).toHaveBeenCalled();
});
it('buy-later upserts independently of the active cart', async () => {
const { service, prisma, carts } = build();
const buyLater = new Map<string, Record<string, unknown>>();
(prisma as any).customerBuyLater = {
findUnique: jest.fn(async ({ where }: { where: { customerId: string } }) =>
buyLater.get(where.customerId) ?? null,
),
upsert: jest.fn(async ({ where, create, update }: {
where: { customerId: string };
create: Record<string, unknown>;
update: Record<string, unknown>;
}) => {
const prev = buyLater.get(where.customerId);
const row = {
customerId: where.customerId,
updatedAt: new Date(),
...(prev ?? {}),
...(prev ? update : create),
};
buyLater.set(where.customerId, row);
return row;
}),
};
await service.putBuyLater('c1', [{ pid: 'p', vid: 'v', qty: 3 }]);
expect((buyLater.get('c1')?.items as unknown[]).length).toBe(1);
await service.putCart('c1', [{ pid: 'p2', vid: 'v2', qty: 1 }]);
// Order clear must not wipe buy-later — clearForCustomer only touches CustomerCart.
await service.clearForCustomer('c1');
expect(carts.get('c1')?.items).toEqual([]);
expect((buyLater.get('c1')?.items as unknown[]).length).toBe(1);
});
});
+180
View File
@@ -0,0 +1,180 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import {
CART_EXPIRY_SETTING_KEY,
DEFAULT_CART_EXPIRY,
nextClearAt,
nextReminderAt,
parseCartExpiryConfig,
todayClearAt,
type CartExpiryConfig,
} from './cart-schedule';
@Injectable()
export class CartService {
constructor(private readonly prisma: PrismaService) {}
async getConfig(): Promise<CartExpiryConfig> {
const row = await this.prisma.setting.findUnique({
where: { key: CART_EXPIRY_SETTING_KEY },
});
return parseCartExpiryConfig(row?.value ?? {});
}
async setConfig(raw: Record<string, unknown>): Promise<CartExpiryConfig> {
const next = parseCartExpiryConfig({ ...DEFAULT_CART_EXPIRY, ...raw });
await this.prisma.setting.upsert({
where: { key: CART_EXPIRY_SETTING_KEY },
update: { value: next as unknown as Prisma.InputJsonValue },
create: {
key: CART_EXPIRY_SETTING_KEY,
value: next as unknown as Prisma.InputJsonValue,
},
});
return next;
}
/** Public policy for the app (no secrets). */
async getPolicy(now = new Date()) {
const cfg = await this.getConfig();
const today = todayClearAt(now, cfg.clearHour, cfg.clearMinute);
const clear = nextClearAt(now, cfg.clearHour, cfg.clearMinute);
const reminder = nextReminderAt(
now,
cfg.clearHour,
cfg.clearMinute,
cfg.reminderHoursBefore,
);
return {
enabled: cfg.enabled,
clearHour: cfg.clearHour,
clearMinute: cfg.clearMinute,
sameDayDeferFromHour: cfg.sameDayDeferFromHour,
sameDayDeferFromMinute: cfg.sameDayDeferFromMinute,
reminderEnabled: cfg.reminderEnabled,
reminderHoursBefore: cfg.reminderHoursBefore,
/** Today's clear wall-time (may already be in the past). */
todayClearAt: today.toISOString(),
nextClearAt: clear.toISOString(),
nextReminderAt: reminder.toISOString(),
};
}
async getCart(customerId: string) {
const row = await this.prisma.customerCart.findUnique({
where: { customerId },
});
return {
items: (row?.items as unknown[]) ?? [],
updatedAt: row?.updatedAt?.toISOString() ?? null,
clearedAt: row?.clearedAt?.toISOString() ?? null,
};
}
async putCart(customerId: string, items: unknown) {
if (!Array.isArray(items)) {
throw new BadRequestException('items must be an array');
}
// Cap size so a buggy client cannot dump megabytes into Setting-adjacent JSON.
if (items.length > 200) {
throw new BadRequestException('cart too large');
}
const empty = items.length === 0;
const row = await this.prisma.customerCart.upsert({
where: { customerId },
create: {
customerId,
items: items as Prisma.InputJsonValue,
clearedAt: empty ? new Date() : null,
reminderSentForClearAt: null,
},
update: {
items: items as Prisma.InputJsonValue,
...(empty
? { clearedAt: new Date(), reminderSentForClearAt: null }
: { clearedAt: null }),
},
});
return {
items: (row.items as unknown[]) ?? [],
updatedAt: row.updatedAt.toISOString(),
clearedAt: row.clearedAt?.toISOString() ?? null,
};
}
/** After a successful order — mirror client cart.clear(). */
async clearForCustomer(customerId: string): Promise<void> {
await this.prisma.customerCart.upsert({
where: { customerId },
create: {
customerId,
items: [] as Prisma.InputJsonValue,
clearedAt: new Date(),
reminderSentForClearAt: null,
},
update: {
items: [] as Prisma.InputJsonValue,
clearedAt: new Date(),
reminderSentForClearAt: null,
},
});
}
/**
* «خرید بعدی» — deferred list. No stock touch, no expiry worker.
* Order clear leaves this alone on purpose.
*/
async getBuyLater(customerId: string) {
const row = await this.buyLaterTable().findUnique({
where: { customerId },
});
return {
items: (row?.items as unknown[]) ?? [],
updatedAt: row?.updatedAt?.toISOString() ?? null,
};
}
async putBuyLater(customerId: string, items: unknown) {
if (!Array.isArray(items)) {
throw new BadRequestException('items must be an array');
}
if (items.length > 200) {
throw new BadRequestException('buy-later list too large');
}
const row = await this.buyLaterTable().upsert({
where: { customerId },
create: {
customerId,
items: items as Prisma.InputJsonValue,
},
update: {
items: items as Prisma.InputJsonValue,
},
});
return {
items: (row.items as unknown[]) ?? [],
updatedAt: row.updatedAt.toISOString(),
};
}
/**
* Prisma client may lag schema until `prisma generate` (e.g. Windows file
* lock on the query engine). Access by name so TS still type-checks.
*/
private buyLaterTable() {
return (this.prisma as unknown as {
customerBuyLater: {
findUnique: (args: { where: { customerId: string } }) => Promise<{
items: unknown;
updatedAt: Date;
} | null>;
upsert: (args: {
where: { customerId: string };
create: { customerId: string; items: Prisma.InputJsonValue };
update: { items: Prisma.InputJsonValue };
}) => Promise<{ items: unknown; updatedAt: Date }>;
};
}).customerBuyLater;
}
}
@@ -1,4 +1,4 @@
import { Controller, Get, Param, Query } from '@nestjs/common';
import { Controller, Get, HttpCode, Param, Post, Query } from '@nestjs/common';
import { CatalogService } from './catalog.service';
import { Public } from '../auth/guards';
import { ListProductsDto } from './dto/list-products.dto';
@@ -29,6 +29,8 @@ export class CatalogController {
query.categoryId !== undefined ||
query.brandId !== undefined ||
query.collectionId !== undefined ||
query.tag !== undefined ||
query.tagged !== undefined ||
query.minGram !== undefined ||
query.maxGram !== undefined ||
query.sort !== undefined ||
@@ -55,4 +57,11 @@ export class CatalogController {
byId(@Param('id') id: string) {
return this.catalog.findProductById(id);
}
/** The customer app opened this product's detail screen. */
@Post(':id/view')
@HttpCode(204)
async view(@Param('id') id: string) {
await this.catalog.recordView(id);
}
}
@@ -75,6 +75,13 @@ export class CatalogService {
}
/** One product for its own screen, instead of fetching the catalogue to find it. */
/** Counts one opening of a storefront product's detail screen. Unknown or hidden ids are ignored. */
async recordView(id: string) {
const exists = await this.prisma.product.count({ where: { id, isActive: true } });
if (!exists) return;
await this.prisma.productView.create({ data: { productId: id } });
}
async findProductById(id: string) {
const [product] = await this.buildProductList({ where: { ...this.storefrontWhere({}), id } });
if (!product) throw new NotFoundException('محصول یافت نشد');
@@ -90,7 +97,7 @@ export class CatalogService {
*/
async facets() {
const where = this.storefrontWhere({});
const [categories, brands, collections, gram] = await Promise.all([
const [categories, brands, collections, gram, tagged] = await Promise.all([
this.prisma.category.findMany({
where: { products: { some: where } },
select: { id: true, title: true },
@@ -111,12 +118,17 @@ export class CatalogService {
_min: { rashWeightGrams: true },
_max: { rashWeightGrams: true },
}),
this.prisma.product.findMany({
where: { ...where, NOT: { tags: { isEmpty: true } } },
select: { tags: true },
}),
]);
return {
categories,
brands,
collections,
tags: [...new Set(tagged.flatMap((p) => p.tags))].sort((a, b) => a.localeCompare(b, 'fa')),
// Strings, like every other weight this API returns (Decimal, not float).
gramRange: {
min: gram._min.rashWeightGrams?.toString() ?? '0',
@@ -155,6 +167,8 @@ export class CatalogService {
else if (brandIds.length > 1) where.brandId = { in: brandIds };
}
if (query.collectionId) where.collections = { some: { collectionId: query.collectionId } };
if (query.tag?.trim()) where.tags = { has: query.tag.trim() };
else if (query.tagged) where.NOT = { tags: { isEmpty: true } };
// A product is in range when at least ONE of its variants is, which is what
// the app's `limit_gram` filter meant (SearchView.vue:189-195).
@@ -1,5 +1,5 @@
import { Transform, Type } from 'class-transformer';
import { ArrayMaxSize, IsArray, IsIn, IsInt, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator';
import { ArrayMaxSize, IsArray, IsBoolean, IsIn, IsInt, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator';
/** Page size when the caller asks for paging without naming one. */
export const DEFAULT_PAGE_SIZE = 20;
@@ -60,6 +60,17 @@ export class ListProductsDto {
@IsString()
collectionId?: string;
/** One label from the «برچسب‌ها» list. */
@IsOptional()
@IsString()
tag?: string;
/** `1`/`true`: only products that carry at least one label — the home section. */
@IsOptional()
@Transform(({ value }: { value: unknown }) => value === true || value === '1' || value === 'true')
@IsBoolean()
tagged?: boolean;
/**
* Rash-weight range, matched against the product's VARIANTS: a product is in
* range when at least one of its variants is, which is what the app's own
+2 -1
View File
@@ -5,9 +5,10 @@ import { CatalogModule } from '../catalog/catalog.module';
import { AccountingModule } from '../accounting/accounting.module';
import { PricingModule } from '../pricing/pricing.module';
import { SmsModule } from '../sms/sms.module';
import { CartModule } from '../cart/cart.module';
@Module({
imports: [CatalogModule, AccountingModule, PricingModule, SmsModule],
imports: [CatalogModule, AccountingModule, PricingModule, SmsModule, CartModule],
controllers: [OrdersController],
providers: [OrdersService],
exports: [OrdersService],
@@ -40,7 +40,7 @@ describe('OrdersService.createOrder — sales expert credit', () => {
const catalog = { getVariantOrThrow: jest.fn(async () => ({ id: 'v1', rashWeightGrams: '10' })) };
const accounting = { orderStatusReached: jest.fn() };
const wage = { quote: jest.fn() };
const service = new OrdersService(prisma as never, catalog as never, {} as never, accounting as never, wage as never, smsStub as never);
const service = new OrdersService(prisma as never, catalog as never, {} as never, accounting as never, wage as never, smsStub as never, { clearForCustomer: jest.fn(async () => undefined) } as never);
return { service, created };
}
@@ -90,7 +90,7 @@ describe('OrdersService.createOrder — order ban', () => {
$transaction: jest.fn(async (fn: (t: typeof tx) => unknown) => fn(tx)),
};
const catalog = { getVariantOrThrow: jest.fn(async () => ({ id: 'v1', rashWeightGrams: '10' })) };
return new OrdersService(prisma as never, catalog as never, {} as never, { orderStatusReached: jest.fn() } as never, wageStub as never, smsStub as never);
return new OrdersService(prisma as never, catalog as never, {} as never, { orderStatusReached: jest.fn() } as never, wageStub as never, smsStub as never, { clearForCustomer: jest.fn(async () => undefined) } as never);
}
const dto = { items: [{ productVariantId: 'v1', quantity: 1, wagePercent: 3 }] } as never;
@@ -131,7 +131,7 @@ describe('OrdersService.createOrder — server-side making charge', () => {
...(rialWage !== undefined ? { rialWage } : {}),
})),
};
const service = new OrdersService(prisma as never, catalog as never, {} as never, { orderStatusReached: jest.fn() } as never, wageStub as never, smsStub as never);
const service = new OrdersService(prisma as never, catalog as never, {} as never, { orderStatusReached: jest.fn() } as never, wageStub as never, smsStub as never, { clearForCustomer: jest.fn(async () => undefined) } as never);
return { service, created };
}
// A tampered request: zero making charge, a 50% discount and a rial charge.
@@ -289,6 +289,7 @@ describe('OrdersService.createOrder — channel', () => {
{ orderStatusReached: jest.fn() } as never,
wageStub as never,
smsStub as never,
{ clearForCustomer: jest.fn(async () => undefined) } as never,
);
return { service, created };
}
@@ -354,7 +355,7 @@ describe('OrdersService.createOrder — order SMS', () => {
};
const catalog = { getVariantOrThrow: jest.fn(async () => ({ id: 'v1', rashWeightGrams: '10' })) };
const sms = { send: jest.fn(async (to: string, body: string) => { sent.push({ to, body }); return { ok: true }; }) };
const service = new OrdersService(prisma as never, catalog as never, {} as never, { orderStatusReached: jest.fn() } as never, wageStub as never, sms as never);
const service = new OrdersService(prisma as never, catalog as never, {} as never, { orderStatusReached: jest.fn() } as never, wageStub as never, sms as never, { clearForCustomer: jest.fn(async () => undefined) } as never);
return { service, sent };
}
@@ -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 { CartService } from '../cart/cart.service';
import { brandName } from '../../common/brand';
interface ComputedItem {
@@ -30,6 +31,7 @@ export class OrdersService {
private readonly accounting: AccountingHooksService,
private readonly wage: WageService,
private readonly sms: SmsService,
private readonly cart: CartService,
) {}
/**
@@ -215,6 +217,17 @@ export class OrdersService {
this.notifyOrderPlaced(placed.orderNumber, customer, totalGoldWeightGrams, isSuspendedOverCreditLimit);
// Keep server cart in sync with the app's cart.clear() after checkout.
if (opts.placedBy === 'customer') {
void this.cart.clearForCustomer(customerId).catch((err) => {
this.logger.warn(
`Failed to clear server cart after order ${placed.orderNumber}: ${
err instanceof Error ? err.message : String(err)
}`,
);
});
}
return placed;
}
@@ -0,0 +1,60 @@
import { Body, Controller, Get, HttpCode, Param, Patch, Post, Query } from '@nestjs/common';
import { ProductSurveyService, type CreateSurveyInput } from './product-survey.service';
import { CurrentAdmin, RequirePermission } from '../auth/guards';
import type { AuthenticatedAdmin } from '../auth/jwt.strategy';
@Controller('admin/product-surveys')
export class AdminProductSurveyController {
constructor(private readonly surveys: ProductSurveyService) {}
@Get()
@RequirePermission('product.survey')
list(@Query('status') status?: string) {
return this.surveys.listAdmin(status);
}
/** Surveys that already have majority likes and still need a decision. */
@Get('majority-alerts')
@RequirePermission('product.survey')
majorityAlerts() {
return this.surveys.listMajorityAlerts();
}
@Post()
@HttpCode(201)
@RequirePermission('product.survey')
create(@Body() body: CreateSurveyInput, @CurrentAdmin() admin: AuthenticatedAdmin) {
return this.surveys.createByAdmin(admin.id, body ?? {});
}
@Patch(':id')
@RequirePermission('product.survey')
update(@Param('id') id: string, @Body() body: CreateSurveyInput) {
return this.surveys.update(id, body ?? {});
}
@Post(':id/publish')
@HttpCode(200)
@RequirePermission('product.survey')
publish(@Param('id') id: string) {
return this.surveys.publish(id);
}
@Post(':id/close')
@HttpCode(200)
@RequirePermission('product.survey')
close(@Param('id') id: string) {
return this.surveys.close(id);
}
@Post(':id/decide')
@HttpCode(200)
@RequirePermission('product.survey')
decide(
@Param('id') id: string,
@Body() body: { decision?: unknown; note?: unknown },
@CurrentAdmin() admin: AuthenticatedAdmin,
) {
return this.surveys.decide(id, admin.id, body ?? {});
}
}
@@ -0,0 +1,39 @@
import { Body, Controller, Get, HttpCode, Param, Post, Put } from '@nestjs/common';
import { ProductSurveyService, type CreateSurveyInput } from './product-survey.service';
import { CurrentCustomer, CustomerAuth } from '../customer-auth/customer.guards';
import type { AuthenticatedCustomer } from '../customer-auth/customer-jwt.strategy';
@Controller()
export class CustomerProductSurveyController {
constructor(private readonly surveys: ProductSurveyService) {}
/** Open surveys currently accepting votes. */
@CustomerAuth()
@Get('customer/product-surveys')
listOpen(@CurrentCustomer() me: AuthenticatedCustomer) {
return this.surveys.listOpenForCustomer(me.id);
}
@CustomerAuth()
@Put('customer/product-surveys/:id/vote')
vote(
@Param('id') id: string,
@Body() body: { value?: unknown },
@CurrentCustomer() me: AuthenticatedCustomer,
) {
return this.surveys.vote(id, me.id, body?.value);
}
@CustomerAuth()
@Get('customer/me/product-surveys')
listMine(@CurrentCustomer() me: AuthenticatedCustomer) {
return this.surveys.listMine(me.id);
}
@CustomerAuth()
@Post('customer/me/product-surveys')
@HttpCode(201)
create(@Body() body: CreateSurveyInput, @CurrentCustomer() me: AuthenticatedCustomer) {
return this.surveys.createByCustomer(me.id, body ?? {});
}
}
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { ProductSurveyService } from './product-survey.service';
import { AdminProductSurveyController } from './admin-product-survey.controller';
import { CustomerProductSurveyController } from './customer-product-survey.controller';
@Module({
controllers: [AdminProductSurveyController, CustomerProductSurveyController],
providers: [ProductSurveyService],
})
export class ProductSurveyModule {}
@@ -0,0 +1,185 @@
import { BadRequestException } from '@nestjs/common';
import { ProductSurveyStatus, ProductSurveyVoteValue } from '@prisma/client';
import { ProductSurveyService, SURVEY_IMAGE_PURPOSE } from './product-survey.service';
function makePrisma(opts: {
survey?: Record<string, unknown> | null;
votes?: { surveyId: string; value: ProductSurveyVoteValue; _count: { _all: number } }[];
file?: { url: string } | null;
upserted?: unknown[];
created?: unknown[];
updated?: unknown[];
}) {
const created = opts.created ?? [];
const updated = opts.updated ?? [];
const upserted = opts.upserted ?? [];
const survey = opts.survey ?? null;
const prisma = {
customer: {
count: jest.fn(async () => 10),
},
uploadedFile: {
findFirst: jest.fn(async () => opts.file ?? null),
},
productSurvey: {
findUnique: jest.fn(async () => survey),
findMany: jest.fn(async () => (survey ? [survey] : [])),
create: jest.fn(async ({ data }: { data: Record<string, unknown> }) => {
const row = {
id: 's1',
title: data.title ?? null,
imageUrl: data.imageUrl,
suggestedWeightGrams: data.suggestedWeightGrams,
status: data.status,
publishedAt: data.publishedAt ?? null,
decidedAt: null,
decisionNote: null,
createdAt: new Date('2026-01-01'),
updatedAt: new Date('2026-01-01'),
createdByCustomer: null,
createdByAdmin: null,
decidedByAdmin: null,
...data,
};
created.push(row);
return row;
}),
update: jest.fn(async ({ data }: { data: Record<string, unknown> }) => {
const row = { ...survey, ...data, updatedAt: new Date() };
updated.push(row);
return row;
}),
},
productSurveyVote: {
groupBy: jest.fn(async () => opts.votes ?? []),
findMany: jest.fn(async () => []),
upsert: jest.fn(async (args: unknown) => {
upserted.push(args);
return args;
}),
},
};
return {
service: new ProductSurveyService(prisma as never),
prisma,
created,
updated,
upserted,
};
}
describe('ProductSurveyService', () => {
it('admin create goes straight to OPEN', async () => {
const t = makePrisma({});
const row = await t.service.createByAdmin('a1', {
imageUrl: '/uploads/x.jpg',
suggestedWeightGrams: 12.5,
title: 'حلقه',
});
expect(t.created[0]).toMatchObject({
status: ProductSurveyStatus.OPEN,
createdByAdminId: 'a1',
imageUrl: '/uploads/x.jpg',
});
expect(row.status).toBe(ProductSurveyStatus.OPEN);
expect(row.likes).toBe(0);
});
it('customer create stays PENDING_REVIEW and requires purpose-tagged file', async () => {
const t = makePrisma({ file: { url: '/uploads/c.jpg' } });
await t.service.createByCustomer('c1', {
file_id: 'f1',
suggestedWeightGrams: 8,
});
expect(t.prisma.uploadedFile.findFirst).toHaveBeenCalledWith({
where: { id: 'f1', purpose: SURVEY_IMAGE_PURPOSE },
select: { url: true },
});
expect(t.created[0]).toMatchObject({
status: ProductSurveyStatus.PENDING_REVIEW,
createdByCustomerId: 'c1',
imageUrl: '/uploads/c.jpg',
});
});
it('publish only from PENDING_REVIEW', async () => {
const t = makePrisma({
survey: {
id: 's1',
status: ProductSurveyStatus.OPEN,
title: null,
imageUrl: '/u.jpg',
suggestedWeightGrams: 1,
publishedAt: new Date(),
decidedAt: null,
decisionNote: null,
createdAt: new Date(),
updatedAt: new Date(),
},
});
await expect(t.service.publish('s1')).rejects.toBeInstanceOf(BadRequestException);
});
it('vote upserts and rejects when not OPEN', async () => {
const closed = makePrisma({
survey: {
id: 's1',
status: ProductSurveyStatus.CLOSED,
title: null,
imageUrl: '/u.jpg',
suggestedWeightGrams: 1,
publishedAt: null,
decidedAt: null,
decisionNote: null,
createdAt: new Date(),
updatedAt: new Date(),
},
});
await expect(closed.service.vote('s1', 'c1', 'LIKE')).rejects.toBeInstanceOf(BadRequestException);
const open = makePrisma({
survey: {
id: 's1',
status: ProductSurveyStatus.OPEN,
title: null,
imageUrl: '/u.jpg',
suggestedWeightGrams: 1,
publishedAt: new Date(),
decidedAt: null,
decisionNote: null,
createdAt: new Date(),
updatedAt: new Date(),
},
votes: [{ surveyId: 's1', value: ProductSurveyVoteValue.LIKE, _count: { _all: 1 } }],
});
const r = await open.service.vote('s1', 'c1', 'like');
expect(open.upserted[0]).toMatchObject({
where: { surveyId_customerId: { surveyId: 's1', customerId: 'c1' } },
create: { value: ProductSurveyVoteValue.LIKE },
update: { value: ProductSurveyVoteValue.LIKE },
});
expect(r).toMatchObject({ myVote: 'LIKE', likes: 1, dislikes: 0 });
});
it('decide only when OPEN or CLOSED', async () => {
const t = makePrisma({
survey: {
id: 's1',
status: ProductSurveyStatus.APPROVED,
title: null,
imageUrl: '/u.jpg',
suggestedWeightGrams: 1,
publishedAt: null,
decidedAt: null,
decisionNote: null,
createdAt: new Date(),
updatedAt: new Date(),
},
});
await expect(t.service.decide('s1', 'a1', { decision: 'REJECTED' })).rejects.toBeInstanceOf(
BadRequestException,
);
});
});
@@ -0,0 +1,432 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import {
Prisma,
ProductSurveyStatus,
ProductSurveyVoteValue,
} from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
/** Tag the customer app uses when uploading a survey design photo. */
export const SURVEY_IMAGE_PURPOSE = 'product-survey';
const STATUS_LABEL: Record<ProductSurveyStatus, string> = {
PENDING_REVIEW: 'منتظر انتشار',
OPEN: 'در حال رأی‌گیری',
CLOSED: 'بسته شده',
APPROVED: 'تأیید برای ساخت',
REJECTED: 'رد شده',
};
const EDITABLE: ProductSurveyStatus[] = [
ProductSurveyStatus.PENDING_REVIEW,
ProductSurveyStatus.OPEN,
];
const DECIDABLE: ProductSurveyStatus[] = [
ProductSurveyStatus.OPEN,
ProductSurveyStatus.CLOSED,
];
export type CreateSurveyInput = {
title?: unknown;
imageUrl?: unknown;
file_id?: unknown;
suggestedWeightGrams?: unknown;
};
@Injectable()
export class ProductSurveyService {
constructor(private readonly prisma: PrismaService) {}
private text(value: unknown, label: string, max: number, required = false): string | null {
const s = value === undefined || value === null ? '' : String(value).trim();
if (!s) {
if (required) throw new BadRequestException(`${label} الزامی است`);
return null;
}
if (s.length > max) throw new BadRequestException(`${label} نباید بیشتر از ${max} نویسه باشد`);
return s;
}
private weight(value: unknown): Prisma.Decimal {
const n = Number(value);
if (!Number.isFinite(n) || n <= 0 || n > 100000) {
throw new BadRequestException('وزن پیشنهادی باید عددی مثبت (گرم) باشد');
}
return new Prisma.Decimal(n.toFixed(4));
}
private async resolveImageUrl(input: CreateSurveyInput, purpose?: string): Promise<string> {
const url = this.text(input.imageUrl, 'تصویر', 2000);
if (url) return url;
if (input.file_id !== undefined && input.file_id !== null && String(input.file_id) !== '') {
const where: Prisma.UploadedFileWhereInput = { id: String(input.file_id) };
if (purpose) where.purpose = purpose;
const file = await this.prisma.uploadedFile.findFirst({
where,
select: { url: true },
});
if (!file) throw new BadRequestException('تصویر معتبر نیست؛ دوباره بارگذاری کنید');
return file.url;
}
throw new BadRequestException('تصویر الزامی است');
}
private async voteCounts(surveyIds: string[]) {
if (surveyIds.length === 0) return new Map<string, { likes: number; dislikes: number }>();
const rows = await this.prisma.productSurveyVote.groupBy({
by: ['surveyId', 'value'],
where: { surveyId: { in: surveyIds } },
_count: { _all: true },
});
const map = new Map<string, { likes: number; dislikes: number }>();
for (const id of surveyIds) map.set(id, { likes: 0, dislikes: 0 });
for (const r of rows) {
const cur = map.get(r.surveyId)!;
if (r.value === ProductSurveyVoteValue.LIKE) cur.likes = r._count._all;
else cur.dislikes = r._count._all;
}
return map;
}
/** Active customers; majority = floor(n/2)+1 likes. */
private async majorityMeta() {
const customerCount = await this.prisma.customer.count({
where: { isActive: true, isArchived: false },
});
return {
customerCount,
majorityThreshold: Math.floor(customerCount / 2) + 1,
};
}
private toRow(
s: Prisma.ProductSurveyGetPayload<{
include?: {
createdByCustomer?: true;
createdByAdmin?: true;
decidedByAdmin?: true;
};
}> & {
createdByCustomer?: { id: string; fullName: string; mobile: string } | null;
createdByAdmin?: { id: string; fullName: string } | null;
decidedByAdmin?: { id: string; fullName: string } | null;
},
counts: { likes: number; dislikes: number },
myVote: ProductSurveyVoteValue | null = null,
majority?: { customerCount: number; majorityThreshold: number },
) {
const majorityThreshold = majority?.majorityThreshold ?? null;
const customerCount = majority?.customerCount ?? null;
const reachedMajority =
majorityThreshold !== null ? counts.likes >= majorityThreshold : false;
return {
id: s.id,
title: s.title,
imageUrl: s.imageUrl,
suggestedWeightGrams: Number(s.suggestedWeightGrams),
status: s.status,
statusLabel: STATUS_LABEL[s.status],
likes: counts.likes,
dislikes: counts.dislikes,
myVote,
customerCount,
majorityThreshold,
reachedMajority,
publishedAt: s.publishedAt,
decidedAt: s.decidedAt,
decisionNote: s.decisionNote,
createdAt: s.createdAt,
updatedAt: s.updatedAt,
createdByCustomer: s.createdByCustomer
? {
id: s.createdByCustomer.id,
fullName: s.createdByCustomer.fullName,
mobile: s.createdByCustomer.mobile,
}
: null,
createdByAdmin: s.createdByAdmin
? { id: s.createdByAdmin.id, fullName: s.createdByAdmin.fullName }
: null,
decidedByAdmin: s.decidedByAdmin
? { id: s.decidedByAdmin.id, fullName: s.decidedByAdmin.fullName }
: null,
};
}
async listAdmin(status?: string) {
const where: Prisma.ProductSurveyWhereInput = {};
if (status) {
if (!Object.values(ProductSurveyStatus).includes(status as ProductSurveyStatus)) {
throw new BadRequestException('وضعیت نامعتبر است');
}
where.status = status as ProductSurveyStatus;
}
const [majority, rows] = await Promise.all([
this.majorityMeta(),
this.prisma.productSurvey.findMany({
where,
orderBy: { createdAt: 'desc' },
take: 500,
include: {
createdByCustomer: { select: { id: true, fullName: true, mobile: true } },
createdByAdmin: { select: { id: true, fullName: true } },
decidedByAdmin: { select: { id: true, fullName: true } },
},
}),
]);
const counts = await this.voteCounts(rows.map((r) => r.id));
const mapped = rows.map((r) => this.toRow(r, counts.get(r.id)!, null, majority));
const majorityHits = mapped.filter(
(r) =>
r.reachedMajority &&
(r.status === ProductSurveyStatus.OPEN || r.status === ProductSurveyStatus.CLOSED),
);
return {
rows: mapped,
majorityHits,
customerCount: majority.customerCount,
majorityThreshold: majority.majorityThreshold,
statusOptions: Object.entries(STATUS_LABEL).map(([value, label]) => ({ value, label })),
};
}
/** Undecided surveys that already have majority likes — for dashboard alerts. */
async listMajorityAlerts() {
const majority = await this.majorityMeta();
const rows = await this.prisma.productSurvey.findMany({
where: { status: { in: [ProductSurveyStatus.OPEN, ProductSurveyStatus.CLOSED] } },
orderBy: { publishedAt: 'desc' },
take: 200,
include: {
createdByCustomer: { select: { id: true, fullName: true, mobile: true } },
createdByAdmin: { select: { id: true, fullName: true } },
decidedByAdmin: { select: { id: true, fullName: true } },
},
});
const counts = await this.voteCounts(rows.map((r) => r.id));
const mapped = rows.map((r) => this.toRow(r, counts.get(r.id)!, null, majority));
return {
rows: mapped.filter((r) => r.reachedMajority),
customerCount: majority.customerCount,
majorityThreshold: majority.majorityThreshold,
};
}
async createByAdmin(adminId: string, input: CreateSurveyInput) {
const imageUrl = await this.resolveImageUrl(input);
const created = await this.prisma.productSurvey.create({
data: {
title: this.text(input.title, 'عنوان', 200),
imageUrl,
suggestedWeightGrams: this.weight(input.suggestedWeightGrams),
status: ProductSurveyStatus.OPEN,
createdByAdminId: adminId,
publishedAt: new Date(),
},
include: {
createdByCustomer: { select: { id: true, fullName: true, mobile: true } },
createdByAdmin: { select: { id: true, fullName: true } },
decidedByAdmin: { select: { id: true, fullName: true } },
},
});
return this.toRow(created, { likes: 0, dislikes: 0 });
}
async createByCustomer(customerId: string, input: CreateSurveyInput) {
const imageUrl = await this.resolveImageUrl(input, SURVEY_IMAGE_PURPOSE);
const created = await this.prisma.productSurvey.create({
data: {
title: this.text(input.title, 'عنوان', 200),
imageUrl,
suggestedWeightGrams: this.weight(input.suggestedWeightGrams),
status: ProductSurveyStatus.PENDING_REVIEW,
createdByCustomerId: customerId,
},
});
return this.toRow(created, { likes: 0, dislikes: 0 });
}
async update(id: string, input: CreateSurveyInput) {
const existing = await this.prisma.productSurvey.findUnique({ where: { id } });
if (!existing) throw new NotFoundException('طرح پیدا نشد');
if (!EDITABLE.includes(existing.status)) {
throw new BadRequestException('این طرح دیگر قابل ویرایش نیست');
}
const data: Prisma.ProductSurveyUpdateInput = {};
if (input.title !== undefined) data.title = this.text(input.title, 'عنوان', 200);
if (input.suggestedWeightGrams !== undefined) {
data.suggestedWeightGrams = this.weight(input.suggestedWeightGrams);
}
if (input.imageUrl !== undefined || input.file_id !== undefined) {
data.imageUrl = await this.resolveImageUrl(input);
}
const updated = await this.prisma.productSurvey.update({
where: { id },
data,
include: {
createdByCustomer: { select: { id: true, fullName: true, mobile: true } },
createdByAdmin: { select: { id: true, fullName: true } },
decidedByAdmin: { select: { id: true, fullName: true } },
},
});
const counts = await this.voteCounts([id]);
return this.toRow(updated, counts.get(id)!);
}
async publish(id: string) {
const existing = await this.prisma.productSurvey.findUnique({ where: { id } });
if (!existing) throw new NotFoundException('طرح پیدا نشد');
if (existing.status !== ProductSurveyStatus.PENDING_REVIEW) {
throw new BadRequestException('فقط طرح‌های منتظر انتشار قابل انتشارند');
}
const updated = await this.prisma.productSurvey.update({
where: { id },
data: { status: ProductSurveyStatus.OPEN, publishedAt: new Date() },
include: {
createdByCustomer: { select: { id: true, fullName: true, mobile: true } },
createdByAdmin: { select: { id: true, fullName: true } },
decidedByAdmin: { select: { id: true, fullName: true } },
},
});
const counts = await this.voteCounts([id]);
return this.toRow(updated, counts.get(id)!);
}
async close(id: string) {
const existing = await this.prisma.productSurvey.findUnique({ where: { id } });
if (!existing) throw new NotFoundException('طرح پیدا نشد');
if (existing.status !== ProductSurveyStatus.OPEN) {
throw new BadRequestException('فقط طرح در حال رأی‌گیری قابل بستن است');
}
const updated = await this.prisma.productSurvey.update({
where: { id },
data: { status: ProductSurveyStatus.CLOSED },
include: {
createdByCustomer: { select: { id: true, fullName: true, mobile: true } },
createdByAdmin: { select: { id: true, fullName: true } },
decidedByAdmin: { select: { id: true, fullName: true } },
},
});
const counts = await this.voteCounts([id]);
return this.toRow(updated, counts.get(id)!);
}
async decide(id: string, adminId: string, body: { decision?: unknown; note?: unknown }) {
const decision = String(body.decision ?? '').toUpperCase();
if (decision !== 'APPROVED' && decision !== 'REJECTED') {
throw new BadRequestException('تصمیم باید APPROVED یا REJECTED باشد');
}
const existing = await this.prisma.productSurvey.findUnique({ where: { id } });
if (!existing) throw new NotFoundException('طرح پیدا نشد');
if (!DECIDABLE.includes(existing.status)) {
throw new BadRequestException('برای این طرح دیگر نمی‌توان تصمیم گرفت');
}
const updated = await this.prisma.productSurvey.update({
where: { id },
data: {
status: decision as ProductSurveyStatus,
decidedAt: new Date(),
decidedByAdminId: adminId,
decisionNote: this.text(body.note, 'یادداشت', 1000),
},
include: {
createdByCustomer: { select: { id: true, fullName: true, mobile: true } },
createdByAdmin: { select: { id: true, fullName: true } },
decidedByAdmin: { select: { id: true, fullName: true } },
},
});
const counts = await this.voteCounts([id]);
return this.toRow(updated, counts.get(id)!);
}
async listOpenForCustomer(customerId: string) {
const [majority, rows] = await Promise.all([
this.majorityMeta(),
this.prisma.productSurvey.findMany({
where: { status: ProductSurveyStatus.OPEN },
orderBy: { publishedAt: 'desc' },
take: 200,
}),
]);
const ids = rows.map((r) => r.id);
const counts = await this.voteCounts(ids);
const myVotes = await this.prisma.productSurveyVote.findMany({
where: { customerId, surveyId: { in: ids } },
select: { surveyId: true, value: true },
});
const mine = new Map(myVotes.map((v) => [v.surveyId, v.value]));
const mapped = rows.map((r) =>
this.toRow(r, counts.get(r.id)!, mine.get(r.id) ?? null, majority),
);
return {
rows: mapped,
majorityHits: mapped.filter((r) => r.reachedMajority),
customerCount: majority.customerCount,
majorityThreshold: majority.majorityThreshold,
};
}
async listMine(customerId: string) {
const [majority, rows] = await Promise.all([
this.majorityMeta(),
this.prisma.productSurvey.findMany({
where: { createdByCustomerId: customerId },
orderBy: { createdAt: 'desc' },
take: 200,
}),
]);
const counts = await this.voteCounts(rows.map((r) => r.id));
return {
rows: rows.map((r) => this.toRow(r, counts.get(r.id)!, null, majority)),
customerCount: majority.customerCount,
majorityThreshold: majority.majorityThreshold,
};
}
async vote(surveyId: string, customerId: string, rawValue: unknown) {
const value = String(rawValue ?? '').toUpperCase();
if (value !== 'LIKE' && value !== 'DISLIKE') {
throw new BadRequestException('رأی باید LIKE یا DISLIKE باشد');
}
const survey = await this.prisma.productSurvey.findUnique({ where: { id: surveyId } });
if (!survey) throw new NotFoundException('طرح پیدا نشد');
if (survey.status !== ProductSurveyStatus.OPEN) {
throw new BadRequestException('رأی‌گیری برای این طرح باز نیست');
}
await this.prisma.productSurveyVote.upsert({
where: { surveyId_customerId: { surveyId, customerId } },
create: {
surveyId,
customerId,
value: value as ProductSurveyVoteValue,
},
update: { value: value as ProductSurveyVoteValue },
});
const [counts, majority] = await Promise.all([
this.voteCounts([surveyId]),
this.majorityMeta(),
]);
const likes = counts.get(surveyId)!.likes;
const dislikes = counts.get(surveyId)!.dislikes;
return {
surveyId,
myVote: value as ProductSurveyVoteValue,
likes,
dislikes,
customerCount: majority.customerCount,
majorityThreshold: majority.majorityThreshold,
reachedMajority: likes >= majority.majorityThreshold,
};
}
}
+117
View File
@@ -135,6 +135,8 @@ export interface ProductSummary {
category: CategorySummary | null;
brand: BrandSummary | null;
collections: Array<{ collectionId: string; collection: CollectionSummary }>;
/** Labels from the admin «برچسب‌ها» list. */
tags?: string[];
}
/** Query for the paged storefront list; every field is optional. */
@@ -145,6 +147,10 @@ export interface ProductQuery {
categoryId?: string;
brandId?: string;
collectionId?: string;
/** One label. */
tag?: string;
/** Only products carrying at least one label. */
tagged?: boolean;
minGram?: number;
maxGram?: number;
sort?: 'newest' | 'oldest' | 'title';
@@ -167,6 +173,8 @@ export interface CatalogFacets {
categories: CategorySummary[];
brands: BrandSummary[];
collections: CollectionSummary[];
/** Labels in use on the storefront. */
tags?: string[];
/** Decimal strings, like every other weight this API returns. */
gramRange: { min: string; max: string };
}
@@ -394,6 +402,29 @@ export interface PreOrderInput {
description?: string;
}
/** One product-design survey card (like/dislike before manufacturing). */
export interface ProductSurveyRow {
id: string;
title: string | null;
imageUrl: string;
suggestedWeightGrams: number;
status: string;
statusLabel: string;
likes: number;
dislikes: number;
myVote: 'LIKE' | 'DISLIKE' | null;
customerCount?: number | null;
majorityThreshold?: number | null;
reachedMajority?: boolean;
createdAt: string;
}
export interface ProductSurveyInput {
title?: string;
file_id: string;
suggestedWeightGrams: number;
}
/** One row of "سفارش مشتریان". */
export interface ExpertChildOrder {
id: string;
@@ -516,6 +547,13 @@ export const api = {
*/
getProduct: (id: string) => request<ProductSummary>(`/catalog/products/${encodeURIComponent(id)}`),
/** Counts a detail-screen opening for the admin «آمار» screen. Never throws. */
recordProductView: (id: string) => {
void fetch(`${BASE}/catalog/products/${encodeURIComponent(id)}/view`, { method: 'POST', keepalive: true }).catch(
() => undefined,
);
},
/**
* Specific products by id — the cart, favourites, order items.
*
@@ -661,6 +699,26 @@ export const api = {
createPreOrder: (body: PreOrderInput) =>
request<PreOrderRow>('/customer/me/pre-orders', { method: 'POST', body: JSON.stringify(body) }),
getMyPreOrders: () => request<{ rows: PreOrderRow[] }>('/customer/me/pre-orders'),
listOpenProductSurveys: () =>
request<{
rows: ProductSurveyRow[];
majorityHits?: ProductSurveyRow[];
customerCount?: number;
majorityThreshold?: number;
}>('/customer/product-surveys'),
listMyProductSurveys: () =>
request<{ rows: ProductSurveyRow[] }>('/customer/me/product-surveys'),
createProductSurvey: (body: ProductSurveyInput) =>
request<ProductSurveyRow>('/customer/me/product-surveys', {
method: 'POST',
body: JSON.stringify(body),
}),
voteProductSurvey: (id: string, value: 'LIKE' | 'DISLIKE') =>
request<{ surveyId: string; myVote: 'LIKE' | 'DISLIKE'; likes: number; dislikes: number }>(
`/customer/product-surveys/${encodeURIComponent(id)}/vote`,
{ method: 'PUT', body: JSON.stringify({ value }) },
),
adjustExpertChildOrder: (orderId: string, items: { id: string; rash_weight: number }[], finalSubmit: boolean) =>
request<{ id: string; status: string }>(`/expert/child-orders/${encodeURIComponent(orderId)}`, {
method: 'PUT',
@@ -739,4 +797,63 @@ export const api = {
request<{ result: ReportDetailRow[]; page: number; limit: number }>(
`/customer/me/report/${encodeURIComponent(id)}`,
),
// ── cart sync / daily expiry ─────────────────────────────────────────
getCartPolicy: () =>
request<{
enabled: boolean;
clearHour: number;
clearMinute: number;
sameDayDeferFromHour: number;
sameDayDeferFromMinute: number;
reminderEnabled: boolean;
reminderHoursBefore: number;
todayClearAt: string;
nextClearAt: string;
nextReminderAt: string;
}>('/cart/policy'),
getCart: () =>
request<{
items: Array<{
pid: string;
vid: string;
qty: number;
note?: string | null;
}>;
updatedAt: string | null;
clearedAt: string | null;
}>('/cart'),
putCart: (items: Array<{ pid: string; vid: string; qty: number; note?: string | null }>) =>
request<{
items: unknown[];
updatedAt: string;
clearedAt: string | null;
}>('/cart', {
method: 'PUT',
body: JSON.stringify({ items }),
}),
getBuyLater: () =>
request<{
items: Array<{
pid: string;
vid: string;
qty: number;
note?: string | null;
}>;
updatedAt: string | null;
}>('/cart/buy-later'),
putBuyLater: (
items: Array<{ pid: string; vid: string; qty: number; note?: string | null }>,
) =>
request<{
items: unknown[];
updatedAt: string;
}>('/cart/buy-later', {
method: 'PUT',
body: JSON.stringify({ items }),
}),
};
@@ -29,10 +29,12 @@ import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useSessionStore } from '../../stores/session'
import { useCartStore } from '../../stores/cartStore'
import { useBuyLaterStore } from '../../stores/buyLaterStore'
import { toast } from '../../composables/useToast'
const session = useSessionStore()
const cart = useCartStore()
const buyLater = useBuyLaterStore()
const router = useRouter()
const leaving = ref(false)
@@ -45,6 +47,7 @@ async function leave() {
leaving.value = true
try {
cart.clear()
buyLater.clear()
session.setActingAs(null)
await router.push({ name: 'home' })
toast.info('به حساب خودتان بازگشتید')
@@ -14,6 +14,7 @@
<article class="panel" data-product-origin @click="emit('open', value)">
<div class="panel__art">
<img v-lazy="image" :alt="title" />
<ProductTags :tags="value.tags" class="panel__tags" />
</div>
<h3 class="panel__title">{{ title }}</h3>
@@ -30,6 +31,7 @@
</template>
<script setup lang="ts">
import ProductTags from '../product/ProductTags.vue'
import { useProduct } from '../../composables/useProduct'
import type { ProductSummary } from '../../api/client'
@@ -59,12 +61,20 @@ function format(v: number) {
}
.panel__art {
position: relative;
height: 148px;
display: flex;
align-items: center;
justify-content: center;
}
.panel__tags {
position: absolute;
top: 0;
right: 0;
left: 0;
}
.panel__art img {
max-width: 78%;
max-height: 100%;
@@ -59,7 +59,7 @@
type="button"
class="ci__qtyBtn ci__qtyBtn--danger"
aria-label="حذف"
@click="cart.remove(value)"
@click="removeLine"
>
<Icon icon="vuesax-linear:trash" size="16" />
</button>
@@ -68,12 +68,33 @@
</button>
</div>
<button type="button" class="ci__note" @click="emit('note', index)">
<button type="button" class="ci__note" @click="emit('note', listIndex)">
<Icon icon="vuesax-linear:note-text" size="15" />
{{ !value.note || value.note === '' ? 'توضیحات' : 'ویرایش توضیحات' }}
</button>
</div>
<div class="ci__actions">
<button
v-if="mode === 'cart'"
type="button"
class="ci__move ci__move--quiet"
@click="emit('defer')"
>
<Icon icon="vuesax-linear:clock-1" size="14" />
خرید بعدی
</button>
<button
v-else
type="button"
class="ci__move ci__move--primary"
@click="emit('toCart')"
>
<Icon icon="vuesax-linear:bag-2" size="15" />
افزودن به سبد
</button>
</div>
<p v-if="value.note" class="ci__noteText">{{ value.note }}</p>
</div>
</article>
@@ -84,28 +105,54 @@ import { computed } from 'vue'
import Icon from '../icon/Icon.vue'
import { useProduct } from '../../composables/useProduct'
import { useCartStore, type CartItem } from '../../stores/cartStore'
import { useBuyLaterStore } from '../../stores/buyLaterStore'
import { price } from '../../utils/filters'
const props = defineProps<{ value: CartItem }>()
const emit = defineEmits<{ (e: 'note', value: number): void }>()
const props = withDefaults(
defineProps<{ value: CartItem; mode?: 'cart' | 'buyLater' }>(),
{ mode: 'cart' },
)
const emit = defineEmits<{
(e: 'note', value: number): void
(e: 'defer'): void
(e: 'toCart'): void
}>()
const cart = useCartStore()
const index = cart.find_index(props.value)
const buyLater = useBuyLaterStore()
const listIndex = computed(() =>
props.mode === 'buyLater' ? buyLater.find_index(props.value) : cart.find_index(props.value),
)
// Stock ceiling, same rule as the product page: uncapped for a pre-order
// variant, otherwise limited to what is on hand. The store clamps as well.
const maxQty = computed(() => cart.maxQty(props.value))
// Active cart: stock ceiling. Buy-later: uncapped (desired qty for later).
const maxQty = computed(() => (props.mode === 'cart' ? cart.maxQty(props.value) : null))
const canIncrement = computed(() => maxQty.value === null || props.value.qty < maxQty.value)
const increment = () => {
if (!canIncrement.value) return
cart.updateFeild(props.value.qty + 1, index, props.value, 'qty')
if (props.mode === 'buyLater') {
buyLater.updateQty(props.value, props.value.qty + 1)
return
}
cart.updateFeild(props.value.qty + 1, listIndex.value, props.value, 'qty')
}
const decrement = () => {
cart.updateFeild(props.value.qty - 1, index, props.value, 'qty')
if (props.mode === 'buyLater') {
buyLater.updateQty(props.value, props.value.qty - 1)
return
}
cart.updateFeild(props.value.qty - 1, listIndex.value, props.value, 'qty')
}
const removeLine = () => {
if (props.mode === 'buyLater') buyLater.remove(props.value)
else cart.remove(props.value)
}
const { image, title, selectedVariant, wage } = useProduct(props.value.product, cart.variant(props.value))
const variant =
props.mode === 'buyLater'
? props.value.product.variants.find((v) => v.id === props.value.vid)
: cart.variant(props.value)
const { image, title, selectedVariant, wage } = useProduct(props.value.product, variant)
/** The variant's fixed rial making charge, per unit. */
const rialWage = computed(() => Number(selectedVariant.value?.rial_pay ?? 0) || 0)
@@ -269,6 +316,47 @@ html.theme-dark .ci__art img {
white-space: nowrap;
}
.ci__actions {
display: flex;
justify-content: stretch;
padding-top: 10px;
}
.ci__move {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
width: 100%;
min-height: 36px;
padding: 0 12px;
border-radius: 11px;
cursor: pointer;
font-family: inherit;
font-size: 12px;
font-weight: 700;
transition:
transform 0.15s ease,
background 0.15s ease,
color 0.15s ease;
}
.ci__move:active {
transform: scale(0.98);
}
.ci__move--quiet {
border: 1px solid @hairline;
color: @inkSoft;
background: @canvas;
}
.ci__move--primary {
border: 0;
color: #fff;
background: @wine;
}
.ci__noteText {
margin: 8px 0 0;
padding: 8px 10px;
@@ -54,6 +54,8 @@
<span v-if="!is_exist" class="pc__flag">ناموجود</span>
<span v-else-if="isPreOrder && qty === 0" class="pc__flag pc__flag--soft">پیش‌سفارش</span>
<ProductTags :tags="value.tags" class="pc__tags" />
</div>
<h3 class="pc__title">{{ title }}</h3>
@@ -70,6 +72,7 @@
<script setup lang="ts">
import { ref } from 'vue'
import Icon from '../icon/Icon.vue'
import ProductTags from './ProductTags.vue'
import { useProduct } from '../../composables/useProduct'
import { openProduct } from '../../composables/useProductMorph'
import { useFavoritesStore } from '../../stores/favoritesStore'
@@ -190,6 +193,13 @@ html.theme-dark .pc__art img {
background: @wineSoft;
}
.pc__tags {
position: absolute;
bottom: 0;
right: 0;
left: 0;
}
.pc__title {
margin: 12px 0 0;
font-size: 12px;
@@ -51,7 +51,12 @@
import { nextTick, onBeforeUnmount, ref, shallowRef, watch } from 'vue'
import { useRoute } from 'vue-router'
import SingleProduct from './SingleProduct.vue'
import { productMorph, type ProductMorphRequest } from '../../composables/useProductMorph'
import {
discardMorphHistory,
productMorph,
releaseMorphHistory,
type ProductMorphRequest,
} from '../../composables/useProductMorph'
const route = useRoute()
@@ -442,7 +447,7 @@ async function closeAnimations(): Promise<Animation[]> {
return anims
}
function teardown() {
function teardown(opts: { history?: 'release' | 'discard' | 'none' } = { history: 'release' }) {
stopRunning()
unhideAll()
ghostSrc.value = null
@@ -452,6 +457,11 @@ function teardown() {
current.value = null
lockScroll(false)
if (productMorph.value) productMorph.value = null
// Phone/browser back already popped history; the in-overlay back button still
// needs to drop the extra same-URL entry we pushed on open. Route changes
// discard the marker without an extra history.back().
if (opts.history === 'release') releaseMorphHistory()
else if (opts.history === 'discard') discardMorphHistory()
}
// ── Wiring ────────────────────────────────────────────────────
@@ -467,7 +477,7 @@ watch(
() => {
if (!current.value) return
++gen
teardown()
teardown({ history: 'discard' })
},
)
@@ -0,0 +1,50 @@
<!--
The admin-picked labels (برچسب‌ها) of a product, as small pills overlaid on a
card's artwork. The parent positions the root; at most two are shown so the
pills never cover the piece, with a "+N" for the rest.
-->
<template>
<div v-if="shown.length" class="tags">
<span v-for="t in shown" :key="t" class="tags__pill">{{ t }}</span>
<span v-if="extra > 0" class="tags__pill tags__pill--more">+{{ extra.toLocaleString('fa-IR') }}</span>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const MAX_SHOWN = 2
const props = defineProps<{ tags?: string[] | null }>()
const shown = computed(() => (props.tags ?? []).slice(0, MAX_SHOWN))
const extra = computed(() => Math.max(0, (props.tags?.length ?? 0) - MAX_SHOWN))
</script>
<style scoped lang="less">
.tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
pointer-events: none;
}
.tags__pill {
max-width: 96px;
padding: 3px 8px;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
line-height: 1.5;
color: #fff;
background: @wine;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tags__pill--more {
color: @wine;
background: @wineWash;
}
</style>
@@ -334,7 +334,7 @@ import { useFavoritesStore } from '../../stores/favoritesStore'
import { useFiltersStore } from '../../stores/filtersStore'
import { useCartStore, type CartItem } from '../../stores/cartStore'
import { isUserLoggedIn } from '../../utils/checkAccess'
import type { ProductSummary } from '../../api/client'
import { api, type ProductSummary } from '../../api/client'
// `initial`: the card's own ProductSummary when opened from a list (see
// ProductMorph.vue) — lets the page render whole on its first frame.
@@ -530,6 +530,7 @@ const goCategory = () => {
}
onBeforeMount(async () => {
api.recordProductView(props.id)
await productDetail.load()
})
@@ -12,6 +12,10 @@
// to thread an element through. A caller that does hold the element can pass
// it; with neither (keyboard activation), the overlay falls back to a plain
// rise-and-fade instead of a morph.
//
// History: opening pushes a same-URL history entry so the phone/browser back
// button closes the overlay first (like a modal), instead of leaving the list
// page underneath. Closing via the in-overlay back control pops that entry.
import { shallowRef } from 'vue'
import type { ProductSummary } from '../api/client'
@@ -22,6 +26,12 @@ export interface ProductMorphRequest {
export const productMorph = shallowRef<ProductMorphRequest | null>(null)
/** Marker on `history.state` while the product overlay owns the top entry. */
export const PRODUCT_MORPH_HISTORY_KEY = '__productMorph'
/** True when the current close was triggered by popstate (phone/browser back). */
let closingFromPopstate = false
let lastPointerTarget: Element | null = null
if (typeof document !== 'undefined') {
document.addEventListener(
@@ -31,11 +41,74 @@ if (typeof document !== 'undefined') {
},
{ capture: true, passive: true },
)
window.addEventListener('popstate', () => {
if (!productMorph.value) return
closingFromPopstate = true
productMorph.value = null
})
}
type RouterHistoryState = Record<string, unknown> & {
position?: number
current?: string
back?: string | null
forward?: string | null
}
function pushMorphHistory() {
if (typeof window === 'undefined') return
const st = (window.history.state ?? {}) as RouterHistoryState
if (st[PRODUCT_MORPH_HISTORY_KEY]) return
const current = typeof st.current === 'string' ? st.current : window.location.pathname
const position = typeof st.position === 'number' ? st.position + 1 : 1
window.history.pushState(
{
...st,
[PRODUCT_MORPH_HISTORY_KEY]: true,
position,
back: current,
current,
forward: null,
replaced: false,
},
'',
window.location.href,
)
}
/**
* After the overlay finishes closing via the UI (not phone back), drop the
* extra history entry so the next back leaves the underlying page.
*/
export function releaseMorphHistory() {
if (typeof window === 'undefined') return
if (closingFromPopstate) {
closingFromPopstate = false
return
}
const st = (window.history.state ?? {}) as RouterHistoryState
if (!st[PRODUCT_MORPH_HISTORY_KEY]) return
window.history.back()
}
/** Strip the morph marker without navigating — used when the route itself changes. */
export function discardMorphHistory() {
if (typeof window === 'undefined') return
closingFromPopstate = false
const st = (window.history.state ?? {}) as RouterHistoryState
if (!st[PRODUCT_MORPH_HISTORY_KEY]) return
const next = { ...st }
delete next[PRODUCT_MORPH_HISTORY_KEY]
window.history.replaceState(next, '', window.location.href)
}
export function openProduct(product: ProductSummary, origin?: HTMLElement | null) {
const fromTap = lastPointerTarget?.closest<HTMLElement>('[data-product-origin]') ?? null
const alreadyOpen = Boolean(productMorph.value)
productMorph.value = { product, origin: origin ?? fromTap }
// Switching product while open keeps the same history entry.
if (!alreadyOpen) pushMorphHistory()
}
export function closeProduct() {
+2
View File
@@ -44,6 +44,8 @@ export const router = createRouter({
// Reference paths and names kept (henza_pwa router/index.ts:113-128).
{ path: '/makeOrder', name: 'makeOrderList', component: () => import('../views/user/MakeOrderView.vue'), meta: { requiresAuth: true } },
{ path: '/makeOrder_status', name: 'makeOrderStatus', component: () => import('../views/user/MakeOrderStatusView.vue'), meta: { requiresAuth: true } },
{ path: '/surveys', name: 'surveyFeed', component: () => import('../views/user/SurveyFeedView.vue'), meta: { requiresAuth: true } },
{ path: '/surveys/mine', name: 'surveyMine', component: () => import('../views/user/SurveyMineView.vue'), meta: { requiresAuth: true } },
// Theme + reduced motion. Device preferences, so no requiresAuth.
{ path: '/settings/appearance', name: 'appearanceSettings', component: () => import('../views/user/AppearanceView.vue') },
// D21 — the sales expert's two screens. Reference paths and names kept
@@ -0,0 +1,214 @@
/**
* «سبد خرید بعدی» — deferred purchase list.
*
* Scenarios covered:
* - Move from cart → here: removes from active cart; stock is untouched
* (cart never reserved stock; this list also never reserves).
* - Move here → cart: uses cart.addToCart stock guards; on failure the line
* stays here so an out-of-stock deferral is not lost.
* - Qty/note editable here without stock ceiling (desired qty for later).
* - Survives daily cart expiry and successful order (active cart only clears).
* - Guest: localStorage; logged-in: debounced PUT /cart/buy-later.
* - Acting-as / logout: list is cleared in memory like the active cart.
*/
import { defineStore } from 'pinia'
import { api, type ProductSummary } from '../api/client'
import { useSessionStore } from './session'
import { useCartStore, type CartItem } from './cartStore'
const STORAGE_KEY = 'appBuyLater'
const SYNC_DEBOUNCE_MS = 600
type Stored = { items: CartItem[] }
function loadStored(): CartItem[] {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return []
const parsed = JSON.parse(raw)
if (Array.isArray(parsed)) return parsed
return Array.isArray(parsed?.items) ? parsed.items : []
} catch {
return []
}
}
function persist(items: CartItem[]) {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ items } satisfies Stored))
}
function toServerItems(items: CartItem[]) {
return items.map((i) => ({
pid: i.pid,
vid: i.vid,
qty: i.qty,
note: i.note ?? null,
}))
}
export const useBuyLaterStore = defineStore('buyLater', {
state: () => ({
items: loadStored() as CartItem[],
_syncTimer: null as ReturnType<typeof setTimeout> | null,
_hydrating: false,
}),
actions: {
async init() {
await this.syncFromServer()
if (this.items.length === 0) return
try {
const ids = [...new Set(this.items.map((i) => i.pid))]
const products = await api.getProductsByIds(ids)
const byId = new Map(products.map((p) => [p.id, p]))
const refreshed: CartItem[] = []
for (const item of this.items) {
const product = byId.get(item.pid)
const variant = product?.variants.find((v) => v.id === item.vid)
// Keep OOS / missing-from-storefront lines with a stale product
// snapshot so the person can still see what they deferred.
if (product && variant) refreshed.push({ ...item, product })
else refreshed.push(item)
}
this.items = refreshed
this.writeLocal()
} catch (e) {
console.log(e)
}
},
async syncFromServer() {
const session = useSessionStore()
if (!session.isLoggedIn) return
try {
this._hydrating = true
const remote = await api.getBuyLater()
const remoteItems = Array.isArray(remote.items) ? remote.items : []
if (remoteItems.length > 0) {
await this.hydrateFromServer(remoteItems)
} else if (this.items.length > 0) {
await api.putBuyLater(toServerItems(this.items))
}
} catch (e) {
console.log(e)
} finally {
this._hydrating = false
}
},
async hydrateFromServer(
lines: Array<{ pid: string; vid: string; qty: number; note?: string | null }>,
) {
const ids = [...new Set(lines.map((l) => l.pid))]
let byId = new Map<string, ProductSummary>()
try {
const products = await api.getProductsByIds(ids)
byId = new Map(products.map((p) => [p.id, p]))
} catch {
/* keep whatever local product blobs we can */
}
const next: CartItem[] = []
for (const line of lines) {
const product = byId.get(line.pid)
const local = this.items.find((i) => i.pid === line.pid && i.vid === line.vid)
const blob = product ?? local?.product
if (!blob) continue
next.push({
pid: line.pid,
vid: line.vid,
qty: Math.max(1, Math.floor(line.qty) || 1),
note: line.note ?? null,
product: blob,
})
}
this.items = next
this.writeLocal()
},
writeLocal() {
persist(this.items)
if (!this._hydrating) this.scheduleServerSync()
},
scheduleServerSync() {
const session = useSessionStore()
if (!session.isLoggedIn) return
if (this._syncTimer) clearTimeout(this._syncTimer)
this._syncTimer = setTimeout(() => {
this._syncTimer = null
void api.putBuyLater(toServerItems(this.items)).catch((e) => console.log(e))
}, SYNC_DEBOUNCE_MS)
},
find_index(item: Pick<CartItem, 'pid' | 'vid'>) {
return this.items.findIndex((i) => i.pid === item.pid && i.vid === item.vid)
},
/** Upsert a line without any stock check. */
upsert(item: CartItem) {
const qty = Math.max(1, Math.floor(item.qty) || 1)
const i = this.find_index(item)
if (i === -1) {
this.items.push({ ...item, qty })
} else {
this.items[i] = {
...this.items[i],
product: item.product,
qty,
note: item.note ?? this.items[i].note,
}
}
this.writeLocal()
},
remove(item: Pick<CartItem, 'pid' | 'vid'>) {
const i = this.find_index(item)
if (i === -1) return
this.items.splice(i, 1)
this.writeLocal()
},
updateQty(item: Pick<CartItem, 'pid' | 'vid'>, qty: number) {
const i = this.find_index(item)
if (i === -1) return
this.items[i].qty = Math.max(1, Math.floor(qty) || 1)
this.writeLocal()
},
updateNote(item: Pick<CartItem, 'pid' | 'vid'>, note: string | null | undefined) {
const i = this.find_index(item)
if (i === -1) return
this.items[i].note = note ?? null
this.writeLocal()
},
/**
* Cart → buy later. Stock unchanged. Removes from active cart.
*/
moveFromCart(item: CartItem) {
const cart = useCartStore()
const live = cart.items[cart.find_index(item)]
const snapshot = live ? { ...live } : { ...item }
this.upsert(snapshot)
cart.remove(snapshot)
},
/**
* Buy later → cart. Stock guards apply only now.
* @returns false when cart refused the add (e.g. out of stock).
*/
moveToCart(item: CartItem): boolean {
const cart = useCartStore()
const live = this.items[this.find_index(item)]
const snapshot = live ? { ...live } : { ...item }
const ok = cart.addToCart(snapshot)
if (ok) this.remove(snapshot)
return ok
},
clear() {
this.items = []
persist(this.items)
if (!this._hydrating) this.scheduleServerSync()
},
},
})
+161 -26
View File
@@ -1,8 +1,13 @@
// Ported from legacy henza_pwa/src/store/cartStore.ts, adapted to our string
// ids. Persisted the same way (a single localStorage key), just via Pinia's own
// state instead of @vueuse's useStorage wrapping a hand-rolled class.
//
// When logged in, lines are also mirrored to PUT /cart so the API can SMS a
// reminder and clear the basket at the configured Tehran time.
import { defineStore } from 'pinia'
import { api, type ProductSummary } from '../api/client'
import { clearDeadlineForTouchedAt } from '../utils/cartExpiry'
import { useSessionStore } from './session'
export interface CartItem {
pid: string
@@ -12,41 +17,77 @@ export interface CartItem {
note?: string | null
}
const STORAGE_KEY = 'appCart'
type StoredCart = {
items: CartItem[]
/** Last local mutation — used for guest daily-expiry. */
touchedAt: string | null
}
function loadFromStorage(): CartItem[] {
const STORAGE_KEY = 'appCart'
const SYNC_DEBOUNCE_MS = 600
function loadStored(): StoredCart {
try {
const raw = localStorage.getItem(STORAGE_KEY)
return raw ? JSON.parse(raw) : []
if (!raw) return { items: [], touchedAt: null }
const parsed = JSON.parse(raw)
// Legacy shape was a bare array.
if (Array.isArray(parsed)) {
return { items: parsed, touchedAt: null }
}
return {
items: Array.isArray(parsed?.items) ? parsed.items : [],
touchedAt: typeof parsed?.touchedAt === 'string' ? parsed.touchedAt : null,
}
} catch {
return []
return { items: [], touchedAt: null }
}
}
function persist(items: CartItem[]) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(items))
function persist(items: CartItem[], touchedAt: string | null) {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ items, touchedAt }))
}
function toServerItems(items: CartItem[]) {
return items.map((i) => ({
pid: i.pid,
vid: i.vid,
qty: i.qty,
note: i.note ?? null,
}))
}
export const useCartStore = defineStore('cart', {
state: () => ({
items: loadFromStorage() as CartItem[],
}),
state: () => {
const stored = loadStored()
return {
items: stored.items as CartItem[],
touchedAt: stored.touchedAt as string | null,
policy: null as null | {
enabled: boolean
clearHour: number
clearMinute: number
sameDayDeferFromHour: number
sameDayDeferFromMinute: number
todayClearAt: string
nextClearAt: string
},
_syncTimer: null as ReturnType<typeof setTimeout> | null,
_hydrating: false,
}
},
actions: {
/**
* Re-syncs cart entries against fresh product data (e.g. price/weight
* changes since the item was added), dropping entries whose
* product/variant no longer exists — same intent as legacy's init().
*
* Asks for exactly the products in the cart. It used to load the entire
* catalogue to look up two or three of them, which with ~16k products meant
* downloading all of them every time the app opened.
*
* A failed request leaves the cart alone: it is the person's own data, held
* in localStorage, and dropping items because the network blinked would be
* worse than showing a slightly stale price.
* Also loads expiry policy + server cart (when logged in) so a clear that
* happened while the app was closed is reflected locally.
*/
async init(force = false) {
void force // kept for callers; every call now goes to the server anyway
void force
await this.applyExpiryAndSync()
if (this.items.length === 0) return
try {
const ids = [...new Set(this.items.map((i) => i.pid))]
@@ -56,19 +97,107 @@ export const useCartStore = defineStore('cart', {
for (const item of this.items) {
const product = byId.get(item.pid)
const variant = product?.variants.find((v) => v.id === item.vid)
// A product the server no longer lists (deactivated, or its photo was
// removed) comes back missing, which is the same signal the catalogue
// lookup gave before: drop the line.
if (product && variant) {
refreshed.push({ ...item, product })
}
}
this.items = refreshed
persist(this.items)
this.writeLocal(false)
} catch (e) {
console.log(e)
}
},
async applyExpiryAndSync() {
try {
const policy = await api.getCartPolicy()
this.policy = {
enabled: policy.enabled,
clearHour: policy.clearHour,
clearMinute: policy.clearMinute,
sameDayDeferFromHour: policy.sameDayDeferFromHour ?? 8,
sameDayDeferFromMinute: policy.sameDayDeferFromMinute ?? 0,
todayClearAt: policy.todayClearAt,
nextClearAt: policy.nextClearAt,
}
if (policy.enabled && this.touchedAt) {
const touched = new Date(this.touchedAt)
if (!Number.isNaN(touched.getTime())) {
const deadline = clearDeadlineForTouchedAt(touched, {
clearHour: this.policy.clearHour,
clearMinute: this.policy.clearMinute,
sameDayDeferFromHour: this.policy.sameDayDeferFromHour,
sameDayDeferFromMinute: this.policy.sameDayDeferFromMinute,
})
if (Date.now() >= deadline.getTime()) this.clear(false)
}
}
} catch (e) {
console.log(e)
}
const session = useSessionStore()
if (!session.isLoggedIn) return
try {
this._hydrating = true
const remote = await api.getCart()
const remoteItems = Array.isArray(remote.items) ? remote.items : []
if (remoteItems.length > 0) {
await this.hydrateFromServer(remoteItems)
} else if (remote.clearedAt) {
this.clear(false)
} else if (this.items.length > 0) {
// First sync: push local guest/offline lines up.
await api.putCart(toServerItems(this.items))
}
} catch (e) {
console.log(e)
} finally {
this._hydrating = false
}
},
async hydrateFromServer(
lines: Array<{ pid: string; vid: string; qty: number; note?: string | null }>,
) {
const ids = [...new Set(lines.map((l) => l.pid))]
const products = await api.getProductsByIds(ids)
const byId = new Map(products.map((p) => [p.id, p]))
const next: CartItem[] = []
for (const line of lines) {
const product = byId.get(line.pid)
const variant = product?.variants.find((v) => v.id === line.vid)
if (!product || !variant) continue
next.push({
pid: line.pid,
vid: line.vid,
qty: line.qty,
note: line.note ?? null,
product,
})
}
this.items = next
this.touchedAt = new Date().toISOString()
this.writeLocal(false)
},
writeLocal(touch: boolean) {
if (touch) this.touchedAt = new Date().toISOString()
persist(this.items, this.touchedAt)
if (!this._hydrating) this.scheduleServerSync()
},
scheduleServerSync() {
const session = useSessionStore()
if (!session.isLoggedIn) return
if (this._syncTimer) clearTimeout(this._syncTimer)
this._syncTimer = setTimeout(() => {
this._syncTimer = null
void api.putCart(toServerItems(this.items)).catch((e) => console.log(e))
}, SYNC_DEBOUNCE_MS)
},
addToCart(data: CartItem) {
// Refuse a variant that cannot be ordered — out of stock and not flagged
// pre-order. The same guard the admin cart applies (stores/cart.ts), kept
@@ -83,7 +212,7 @@ export const useCartStore = defineStore('cart', {
this.items[index].product = data.product
this.items[index].qty = this.capQty(data.qty, target)
}
persist(this.items)
this.writeLocal(true)
return true
},
@@ -110,7 +239,7 @@ export const useCartStore = defineStore('cart', {
remove(item: CartItem) {
const index = this.find_index(item)
if (index > -1) this.items.splice(index, 1)
persist(this.items)
this.writeLocal(true)
},
// Legacy's exact name (typo'd "Feild"), kept for consistency with the rest
// of this faithfully-ported store's method names.
@@ -125,7 +254,7 @@ export const useCartStore = defineStore('cart', {
value = v ? this.capQty(Number(value), v) : Math.max(1, Number(value) || 1)
}
;(this.items[i] as any)[field] = value
persist(this.items)
this.writeLocal(true)
return true
},
find_index(item: Pick<CartItem, 'pid' | 'vid'>) {
@@ -157,9 +286,15 @@ export const useCartStore = defineStore('cart', {
return sum + (v?.rial_pay ? Number(v.rial_pay) * item.qty : 0)
}, 0)
},
clear() {
/**
* @param syncServer when false, only wipe local (used while applying a
* server-side clear so we do not bounce an empty PUT that races).
*/
clear(syncServer = true) {
this.items = []
persist(this.items)
this.touchedAt = new Date().toISOString()
persist(this.items, this.touchedAt)
if (syncServer && !this._hydrating) this.scheduleServerSync()
},
},
})
+92
View File
@@ -0,0 +1,92 @@
/**
* Mirrors apps/api cart-schedule clearDeadlineForTouchedAt for guest/local expiry.
* Keep in step with the server rule (same-day defer → tomorrow's clear).
*/
type Parts = { year: number; month: number; day: number; hour: number; minute: number; second: number }
function tehranParts(date: Date): Parts {
const fmt = new Intl.DateTimeFormat('en-US', {
timeZone: 'Asia/Tehran',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hourCycle: 'h23',
})
const bag: Record<string, string> = {}
for (const p of fmt.formatToParts(date)) {
if (p.type !== 'literal') bag[p.type] = p.value
}
return {
year: Number(bag.year),
month: Number(bag.month),
day: Number(bag.day),
hour: Number(bag.hour),
minute: Number(bag.minute),
second: Number(bag.second),
}
}
function tehranWallToUtc(
year: number,
month: number,
day: number,
hour: number,
minute: number,
second = 0,
): Date {
const utcGuess = new Date(Date.UTC(year, month - 1, day, hour, minute, second))
const shown = tehranParts(utcGuess)
const wantedMs = Date.UTC(year, month - 1, day, hour, minute, second)
const shownMs = Date.UTC(
shown.year,
shown.month - 1,
shown.day,
shown.hour,
shown.minute,
shown.second,
)
return new Date(utcGuess.getTime() + (wantedMs - shownMs))
}
function addTehranDays(year: number, month: number, day: number, delta: number) {
const noon = tehranWallToUtc(year, month, day, 12, 0, 0)
const shifted = new Date(noon.getTime() + delta * 24 * 60 * 60 * 1000)
const p = tehranParts(shifted)
return { year: p.year, month: p.month, day: p.day }
}
export type CartExpiryPolicyFields = {
clearHour: number
clearMinute: number
sameDayDeferFromHour: number
sameDayDeferFromMinute: number
}
/** Deadline when a cart last touched at `touchedAt` should be emptied. */
export function clearDeadlineForTouchedAt(
touchedAt: Date,
cfg: CartExpiryPolicyFields,
): Date {
const p = tehranParts(touchedAt)
const dayClear = tehranWallToUtc(p.year, p.month, p.day, cfg.clearHour, cfg.clearMinute, 0)
const deferFrom = tehranWallToUtc(
p.year,
p.month,
p.day,
cfg.sameDayDeferFromHour,
cfg.sameDayDeferFromMinute,
0,
)
const tomorrowClear = () => {
const tmr = addTehranDays(p.year, p.month, p.day, 1)
return tehranWallToUtc(tmr.year, tmr.month, tmr.day, cfg.clearHour, cfg.clearMinute, 0)
}
if (touchedAt.getTime() >= dayClear.getTime()) return tomorrowClear()
if (touchedAt.getTime() >= deferFrom.getTime()) return tomorrowClear()
return dayClear
}
+236 -31
View File
@@ -27,37 +27,122 @@
-->
<template>
<div class="cart">
<PageBar title="سبد خرید" back-to="home" />
<PageBar title="سبد خرید" back-to="home">
<template v-if="cartData.step === 1" #below>
<div class="cart__tabs" role="tablist" aria-label="نوع سبد">
<button
type="button"
role="tab"
class="cart__tab"
:class="{ 'is-on': activeTab === 'cart' }"
:aria-selected="activeTab === 'cart'"
@click="activeTab = 'cart'"
>
<span class="cart__tabLabel">سبد خرید</span>
<span v-if="cart.items.length" class="cart__tabCount">{{ cart.items.length }}</span>
</button>
<button
type="button"
role="tab"
class="cart__tab"
:class="{ 'is-on': activeTab === 'later' }"
:aria-selected="activeTab === 'later'"
@click="activeTab = 'later'"
>
<span class="cart__tabLabel">خرید بعدی</span>
<span v-if="buyLater.items.length" class="cart__tabCount">{{ buyLater.items.length }}</span>
</button>
</div>
</template>
</PageBar>
<Spinner :spinning="cartData.loadingPage" tip="درحال دریافت اطلاعات">
<container class="cart__body">
<!-- Step 1: the basket -->
<container class="cart__body" :class="{ 'cart__body--tabs': cartData.step === 1 }">
<!-- Step 1: active cart OR buy-later, one at a time -->
<AppPullRefresh v-if="cartData.step === 1" v-model="refreshing" @refresh="onRefresh">
<template v-if="cart.items.length">
<div class="cart__items" :class="{ 'is-revealed': revealed }">
<CartItem
v-for="(item, i) in cart.items"
:key="item.pid + item.vid"
:value="item"
class="cart__row"
:style="revealDelay(i)"
@note="
cartData.tempRow = item;
cartData.showPopUp = true;
cartData.tempRowIndex = $event;
formModel.note = item.note ?? undefined;
"
/>
</div>
<!-- Active cart tab -->
<template v-if="activeTab === 'cart'">
<template v-if="cart.items.length">
<div class="cart__items" :class="{ 'is-revealed': revealed }">
<CartItem
v-for="(item, i) in cart.items"
:key="'c-' + item.pid + item.vid"
:value="item"
mode="cart"
class="cart__row"
:style="revealDelay(i)"
@defer="moveToBuyLater(item)"
@note="
cartData.noteTarget = 'cart';
cartData.tempRow = item;
cartData.showPopUp = true;
cartData.tempRowIndex = $event;
formModel.note = item.note ?? undefined;
"
/>
</div>
</template>
<section v-else class="cart__empty">
<Icon icon="vuesax-linear:bag-2" size="26" />
<p>سبد خرید خالی است.</p>
<p v-if="buyLater.items.length" class="cart__emptyHint">
{{ buyLater.items.length }} مورد در «خرید بعدی» دارید.
</p>
<button
v-if="buyLater.items.length"
type="button"
class="cart__emptyCta cart__emptyCta--ghost"
@click="activeTab = 'later'"
>
رفتن به خرید بعدی
</button>
<button type="button" class="cart__emptyCta" @click="router.push({ name: 'home' })">
دیدن محصولات
</button>
</section>
</template>
<section v-else class="cart__empty">
<Icon icon="vuesax-linear:bag-2" size="26" />
<p>سبد خرید شما خالی است.</p>
<button type="button" class="cart__emptyCta" @click="router.push({ name: 'home' })">
دیدن محصولات
</button>
</section>
<!-- Buy-later tab -->
<template v-else>
<p v-if="buyLater.items.length" class="cart__laterHint">
از موجودی کم نمی‌شود؛ هر وقت آماده بودید به سبد برگردانید.
</p>
<template v-if="buyLater.items.length">
<div class="cart__items is-revealed">
<CartItem
v-for="item in buyLater.items"
:key="'l-' + item.pid + item.vid"
:value="item"
mode="buyLater"
class="cart__row is-static"
@to-cart="moveBuyLaterToCart(item)"
@note="
cartData.noteTarget = 'buyLater';
cartData.tempRow = item;
cartData.showPopUp = true;
cartData.tempRowIndex = buyLater.find_index(item);
formModel.note = item.note ?? undefined;
"
/>
</div>
</template>
<section v-else class="cart__empty">
<Icon icon="vuesax-linear:clock-1" size="26" />
<p>خرید بعدی خالی است.</p>
<p class="cart__emptyHint">از سبد خرید، «خرید بعدی» را بزنید تا اینجا نگه داشته شود.</p>
<button
v-if="cart.items.length"
type="button"
class="cart__emptyCta cart__emptyCta--ghost"
@click="activeTab = 'cart'"
>
بازگشت به سبد
</button>
<button type="button" class="cart__emptyCta" @click="router.push({ name: 'home' })">
دیدن محصولات
</button>
</section>
</template>
</AppPullRefresh>
<!-- Step 2: submitted -->
@@ -76,8 +161,8 @@
</container>
</Spinner>
<!-- Sticky summary + submit -->
<div v-if="cart.items.length > 0 && cartData.step === 1" class="cart__bar">
<!-- Sticky summary + submit — only on the active cart tab -->
<div v-if="activeTab === 'cart' && cart.items.length > 0 && cartData.step === 1" class="cart__bar">
<div class="cart__barInner">
<div class="cart__totals">
<div class="cart__total">
@@ -145,6 +230,8 @@
<script setup lang="ts">
import { nextTick, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
type CartTab = 'cart' | 'later'
import { Drawer, Form, FormItem, Textarea } from 'ant-design-vue'
import AppPullRefresh from '../components/ui/AppPullRefresh.vue'
import Icon from '../components/icon/Icon.vue'
@@ -153,9 +240,11 @@ import Spinner from '../components/Spinner.vue'
import IslandSheet from '../components/ui/IslandSheet.vue'
import CartItem from '../components/product/CartItem.vue'
import { useCartStore, type CartItem as CartItemType } from '../stores/cartStore'
import { useBuyLaterStore } from '../stores/buyLaterStore'
import { useSessionStore } from '../stores/session'
import { api } from '../api/client'
import { price } from '../utils/filters'
import { toast } from '../composables/useToast'
import { useCatalogStore } from '../stores/catalogStore'
import { KEYS, useQueryCache } from '../stores/queryCache'
@@ -168,10 +257,12 @@ let hasRevealed = false
const router = useRouter()
const cart = useCartStore()
const buyLater = useBuyLaterStore()
const session = useSessionStore()
const loginNotify = ref(false)
const refreshing = ref(false)
const activeTab = ref<CartTab>('cart')
// First-paint entrance for the basket rows. Flipped once, after the cart has
// loaded; pull-to-refresh does not replay it.
@@ -193,17 +284,39 @@ const cartData = reactive({
notify: false,
orderID: '',
tempRowIndex: -1,
noteTarget: 'cart' as 'cart' | 'buyLater',
tempRow: { product: undefined } as Partial<CartItemType>,
})
const formModel = reactive<{ note: string | undefined }>({ note: undefined })
const setNote = () => {
cart.updateFeild(formModel.note, cartData.tempRowIndex, cartData.tempRow as CartItemType, 'note')
const row = cartData.tempRow as CartItemType
if (cartData.noteTarget === 'buyLater') {
buyLater.updateNote(row, formModel.note)
} else {
cart.updateFeild(formModel.note, cartData.tempRowIndex, row, 'note')
}
cartData.showPopUp = false
formModel.note = undefined
cartData.tempRow = { product: undefined }
}
function moveToBuyLater(item: CartItemType) {
buyLater.moveFromCart(item)
activeTab.value = 'later'
toast.success('به خرید بعدی منتقل شد')
}
function moveBuyLaterToCart(item: CartItemType) {
const ok = buyLater.moveToCart(item)
if (ok) {
activeTab.value = 'cart'
toast.success('به سبد خرید اضافه شد')
} else {
toast.error('موجودی کافی نیست؛ در خرید بعدی ماند')
}
}
// legacy routed to `home` because this port had no orders list yet; it exists
// now, so the success step links to the real one.
const goOrders = async () => {
@@ -251,14 +364,14 @@ const submitOrder = async () => {
async function onRefresh() {
try {
await cart.init(true)
await Promise.all([cart.init(true), buyLater.init()])
} finally {
refreshing.value = false
}
}
onMounted(async () => {
await cart.init()
await Promise.all([cart.init(), buyLater.init()])
// Wait for the rows the load just populated to be in the DOM, then let one
// frame paint them in their "before" state — otherwise the browser coalesces
// both states into one and there is nothing to animate.
@@ -282,6 +395,75 @@ onMounted(async () => {
padding-bottom: 150px;
}
.cart__body--tabs {
padding-top: 118px;
}
.cart__tabs {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px;
margin: 0 16px 10px;
padding: 4px;
border-radius: 14px;
background: @hairlineSoft;
border: 1px solid @hairline;
}
.cart__tab {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 36px;
padding: 0 10px;
border: 0;
border-radius: 11px;
cursor: pointer;
font-family: inherit;
font-size: 12.5px;
font-weight: 600;
color: @inkSoft;
background: transparent;
transition:
background 0.18s ease,
color 0.18s ease,
box-shadow 0.18s ease;
}
.cart__tab.is-on {
color: @ink;
background: @canvas;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
}
.cart__tabLabel {
line-height: 1;
}
.cart__tabCount {
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 9px;
display: inline-grid;
place-items: center;
font-size: 10.5px;
font-weight: 800;
font-variant-numeric: tabular-nums;
color: @inkSoft;
background: #e5e7eb; // gray-200
}
html.theme-dark .cart__tabCount {
background: @hairlineSoft;
}
.cart__tab.is-on .cart__tabCount {
color: #fff;
background: @wine;
}
// One continuous list rather than a stack of boxes: the rows are borderless
// and a hairline divider separates each from the next. No gap — the rows carry
// their own vertical padding, so the divider sits centred between them.
@@ -295,6 +477,18 @@ onMounted(async () => {
border-top: 1px solid @hairline;
}
.cart__laterHint {
margin: 4px 2px 2px;
font-size: 11.5px;
line-height: 1.65;
color: @inkFaint;
}
.cart__row.is-static {
opacity: 1;
transform: none;
}
// First-paint entrance, the same choreography HomeView uses: each row rises a
// little, scales up from just under full size and fades in, one beat after the
// one above it.
@@ -344,6 +538,11 @@ onMounted(async () => {
font-size: 12.5px;
}
.cart__emptyHint {
font-size: 11.5px !important;
color: @inkSoft;
}
.cart__emptyCta {
margin-top: 4px;
padding: 9px 18px;
@@ -357,6 +556,12 @@ onMounted(async () => {
background: @wine;
}
.cart__emptyCta--ghost {
color: @ink;
background: @canvas;
border: 1px solid @hairline;
}
.cart__done {
margin-top: 20px;
padding: 34px 22px;
+91 -1
View File
@@ -41,6 +41,36 @@
<CategoryGrid :categories="memory.cats" :active-id="null" @select="onCategory" />
</section>
<!-- Every product an admin has labelled (برچسب‌ها), filterable by label. -->
<section v-if="taggedProducts.length" class="home__section home__reveal" :style="revealDelay(2)">
<h2 class="home__heading">ویژه‌ها</h2>
<div v-if="tagChips.length > 1" class="home__chips no-scrollbar">
<button
type="button"
class="home__chip"
:class="{ 'is-on': activeTag === null }"
@click="activeTag = null"
>
همه
</button>
<button
v-for="t in tagChips"
:key="t"
type="button"
class="home__chip"
:class="{ 'is-on': activeTag === t }"
@click="activeTag = t"
>
{{ t }}
</button>
</div>
<div class="home__rail no-scrollbar">
<ProductCard v-for="item in visibleTagged" :key="item.id" :value="item" class="home__railCard" />
</div>
</section>
<section v-if="newestProducts.length" class="home__section home__reveal" :style="revealDelay(2)">
<h2 class="home__heading">محصولات جدید</h2>
@@ -86,6 +116,7 @@ import type { HeroSlide } from '../components/home/HeroCarousel.vue'
import CategoryGrid from '../components/home/CategoryGrid.vue'
import ProductPanel from '../components/home/ProductPanel.vue'
import ProductCarousel from '../components/home/ProductCarousel.vue'
import ProductCard from '../components/product/ProductCard.vue'
import { useConfigStore } from '../stores/configStore'
import type { HomeCategory } from '../stores/configStore'
import { useFiltersStore } from '../stores/filtersStore'
@@ -140,6 +171,15 @@ function revealDelay(i: number) {
const REEL_LIMIT = 6
const LIST_LIMIT = 4
// «ویژه‌ها»: every labelled product, up to the API's page ceiling.
const TAGGED_LIMIT = 100
const taggedProducts = ref<ProductSummary[]>([])
const activeTag = ref<string | null>(null)
const tagChips = computed(() => [...new Set(taggedProducts.value.flatMap((p) => p.tags ?? []))])
const visibleTagged = computed(() =>
activeTag.value ? taggedProducts.value.filter((p) => p.tags?.includes(activeTag.value!)) : taggedProducts.value,
)
const newestProducts = computed(() => products.value)
const reelProducts = computed(() => products.value.slice(0, REEL_LIMIT))
const listProducts = computed(() => products.value.slice(0, LIST_LIMIT))
@@ -172,7 +212,7 @@ function goNewest() {
// The three reads are independent, so they run side by side. Each one is
// cached until pull-to-refresh asks the server again.
async function loadData(force = false) {
const [catalogResult, catsResult, priceResult, slidesResult] = await Promise.allSettled([
const [catalogResult, catsResult, priceResult, slidesResult, taggedResult] = await Promise.allSettled([
// STUB-03: rails need a small newest page. Pull-to-refresh must drop the
// cached catalogue first (same rule as before paging) or the screen keeps
// showing the previous slice while gold/slides refresh.
@@ -188,8 +228,15 @@ async function loadData(force = false) {
memory.init(force),
reads.goldPrice({ force }),
reads.homeSlider({ force }),
api.listProductsPaged({ page: 1, limit: TAGGED_LIMIT, tagged: true, sort: 'newest' }),
])
if (taggedResult.status === 'fulfilled') {
taggedProducts.value = taggedResult.value.rows
if (activeTag.value && !tagChips.value.includes(activeTag.value)) activeTag.value = null
} else {
console.log(taggedResult.reason)
}
if (catalogResult.status === 'rejected') console.log(catalogResult.reason)
if (catsResult.status === 'rejected') console.log(catsResult.reason)
goldPrice.value = priceResult.status === 'fulfilled' ? priceResult.value : null
@@ -350,6 +397,49 @@ onActivated(async () => {
margin-top: 14px;
}
.home__chips {
display: flex;
gap: 8px;
margin: -4px -16px 12px;
padding: 0 16px;
overflow-x: auto;
}
.home__chip {
flex: 0 0 auto;
padding: 6px 14px;
border: 1px solid @hairline;
border-radius: 999px;
font-family: inherit;
font-size: 12px;
font-weight: 500;
color: @inkSoft;
background: @canvas;
cursor: pointer;
transition: background 0.18s ease, color 0.18s ease, border-color 0.18s ease;
}
.home__chip.is-on {
color: #fff;
border-color: @wine;
background: @wine;
}
// Bleeds past the container gutter like the hero, so cards run off the edge
// and the rail reads as scrollable.
.home__rail {
display: flex;
gap: 12px;
margin-inline: -16px;
padding: 0 16px 4px;
overflow-x: auto;
scroll-snap-type: x proximity;
}
.home__railCard {
flex: 0 0 156px;
scroll-snap-align: start;
}
.home__empty {
margin-top: 28px;
padding: 34px 20px;
@@ -190,11 +190,13 @@ import SearchInput from '../../components/search/SearchInput.vue'
import { api, type ExpertCustomer } from '../../api/client'
import { useSessionStore } from '../../stores/session'
import { useCartStore } from '../../stores/cartStore'
import { useBuyLaterStore } from '../../stores/buyLaterStore'
import { toast } from '../../composables/useToast'
const router = useRouter()
const session = useSessionStore()
const cart = useCartStore()
const buyLater = useBuyLaterStore()
const entering = ref(false)
const rows = ref<ExpertCustomer[]>([])
@@ -261,6 +263,7 @@ async function enterAccount(item: ExpertCustomer) {
entering.value = true
try {
cart.clear()
buyLater.clear()
session.setActingAs({ id: item.id, name: item.name ?? item.fullName ?? item.mobile ?? '' })
detailOpen.value = false
// Same destination as the reference, which returned to the dashboard.
+190 -18
View File
@@ -122,6 +122,44 @@
</button>
</section>
<!-- Admin-managed banner (ثابت‌ها → دکمه سفارشی): row height, banner look. -->
<button
v-if="showCustomButton"
type="button"
class="dash__banner dash__reveal"
:style="revealDelay(1)"
@click="openCustomButton"
>
<span class="dash__bannerIcon" aria-hidden="true">
<Icon icon="vuesax-linear:link-2" size="18" />
</span>
<span class="dash__bannerText">
<b>{{ customButton!.title.trim() }}</b>
</span>
<span class="dash__bannerChevron" aria-hidden="true">
<Icon icon="vuesax-linear:arrow-left-1" size="16" />
</span>
</button>
<button
v-if="session.isLoggedIn && surveyMajorityHits.length"
type="button"
class="dash__banner dash__banner--survey dash__reveal"
:style="revealDelay(1)"
@click="goToDetails(8)"
>
<span class="dash__bannerIcon" aria-hidden="true">
<Icon icon="vuesax-linear:like-1" size="18" />
</span>
<span class="dash__bannerText">
<b>{{ surveyMajorityHits.length }} طرح به حد اکثریت رسید</b>
<small>{{ surveyMajorityHits[0]?.title || 'بدون عنوان' }}{{ surveyMajorityHits.length > 1 ? ' و …' : '' }}</small>
</span>
<span class="dash__bannerChevron" aria-hidden="true">
<Icon icon="vuesax-linear:arrow-left-1" size="16" />
</span>
</button>
<!-- Settings-style rows: every destination the hero no longer carries
as its own quick-action row. -->
<section class="dash__rows dash__reveal" :style="revealDelay(1)">
@@ -149,23 +187,6 @@
</span>
</button>
<button
v-if="showCustomButton"
type="button"
class="dash__row"
@click="openCustomButton"
>
<span class="dash__rowIcon" aria-hidden="true">
<Icon icon="vuesax-linear:link-2" size="18" />
</span>
<span class="dash__rowText">
<b>{{ customButton!.title.trim() }}</b>
</span>
<span class="dash__rowChevron" aria-hidden="true">
<Icon icon="vuesax-linear:arrow-left-1" size="16" />
</span>
</button>
<button type="button" class="dash__row" @click="goToDetails(2)">
<span class="dash__rowIcon" aria-hidden="true">
<Icon icon="vuesax-linear:user" size="18" />
@@ -207,6 +228,18 @@
</span>
</button>
<button type="button" class="dash__row" @click="goToDetails(8)">
<span class="dash__rowIcon" aria-hidden="true">
<Icon icon="vuesax-linear:like-1" size="18" />
</span>
<span class="dash__rowText">
<b>نظرسنجی طرح</b>
</span>
<span class="dash__rowChevron" aria-hidden="true">
<Icon icon="vuesax-linear:arrow-left-1" size="16" />
</span>
</button>
<button type="button" class="dash__row" @click="goToDetails(6)">
<span class="dash__rowIcon" aria-hidden="true">
<Icon icon="vuesax-linear:chart-2" size="18" />
@@ -299,6 +332,13 @@
<button type="button" class="dash__choiceBtn is-quiet" @click="chooseMakeOrder('makeOrderStatus')">مشاهده سفارش</button>
</div>
</Drawer>
<Drawer v-model:visible="surveyChoice" placement="bottom" height="auto" :closable="false" :body-style="{ padding: '0' }">
<div class="dash__choice">
<button type="button" class="dash__choiceBtn" @click="chooseSurvey('surveyFeed')">رأی به طرح‌ها</button>
<button type="button" class="dash__choiceBtn is-quiet" @click="chooseSurvey('surveyMine')">ثبت / طرح‌های من</button>
</div>
</Drawer>
</div>
</template>
@@ -314,7 +354,7 @@ import RateStrip from '../../components/home/RateStrip.vue'
import IslandSheet from '../../components/ui/IslandSheet.vue'
import { price } from '../../utils/filters'
import { useSessionStore } from '../../stores/session'
import { api, type CustomButton, type CustomerDetail, type GoldPriceQuote } from '../../api/client'
import { api, type CustomButton, type CustomerDetail, type GoldPriceQuote, type ProductSurveyRow } from '../../api/client'
import { useCachedReads } from '../../api/cachedReads'
import { KEYS } from '../../stores/queryCache'
@@ -325,6 +365,7 @@ const reads = useCachedReads()
const customer = ref<CustomerDetail | null>(reads.peek<CustomerDetail>(KEYS.profile) ?? null)
const goldPrice = ref<GoldPriceQuote | null>(reads.peek<GoldPriceQuote>(KEYS.goldPrice) ?? null)
const customButton = ref<CustomButton | null>(reads.peek<CustomButton>(KEYS.customButton) ?? null)
const surveyMajorityHits = ref<ProductSurveyRow[]>([])
const refreshing = ref(false)
const profileError = ref(false)
@@ -366,6 +407,7 @@ function goToDetails(id: number) {
if (id === 5) router.push({ name: 'customerChoice' })
if (id === 6) router.push({ name: 'report' })
if (id === 7) makeOrderChoice.value = true
if (id === 8) surveyChoice.value = true
}
// «سفارش ساخت»: the reference opened a two-button dialog — place a request, or
@@ -376,6 +418,20 @@ function chooseMakeOrder(name: 'makeOrderList' | 'makeOrderStatus') {
router.push({ name })
}
const surveyChoice = ref(false)
function chooseSurvey(name: 'surveyFeed' | 'surveyMine') {
surveyChoice.value = false
const path = name === 'surveyFeed' ? '/surveys' : '/surveys/mine'
// After the drawer closes, push on the next tick so ant-design's focus
// restore does not cancel the navigation.
nextTick(() => {
router.push(path).catch(() => {
// Route missing until a full reload picks up the new router entries.
window.location.assign(path)
})
})
}
async function logOut() {
data.notify = false
try {
@@ -425,6 +481,17 @@ async function loadDashboard() {
}
goldPrice.value = price.status === 'fulfilled' ? price.value : null
if (btn.status === 'fulfilled') customButton.value = btn.value
if (session.isLoggedIn) {
try {
const surveys = await api.listOpenProductSurveys()
surveyMajorityHits.value = surveys.majorityHits ?? surveys.rows.filter((r) => r.reachedMajority)
} catch {
surveyMajorityHits.value = []
}
} else {
surveyMajorityHits.value = []
}
}
async function onRefresh() {
@@ -828,6 +895,111 @@ onMounted(async () => {
color: @inkFaint;
}
// ── Custom banner ─────────────────────────────────────────────────────
// Same box as .dash__row (padding + 36px icon) so its height matches the rows.
.dash__banner {
position: relative;
isolation: isolate;
overflow: hidden;
display: flex;
align-items: center;
gap: 12px;
width: 100%;
margin-bottom: 14px;
padding: 12px 14px;
border: 1px solid transparent;
border-radius: 16px;
cursor: pointer;
color: #fff;
background: linear-gradient(115deg, @wine 0%, @wineDeep 100%);
box-shadow: 0 10px 24px -16px alpha(@wine, 85%);
font-family: inherit;
text-align: start;
-webkit-tap-highlight-color: transparent;
transition: transform 0.18s ease;
}
.dash__banner::before {
content: '';
position: absolute;
inset: 0;
z-index: -1;
pointer-events: none;
// Pair of fine diagonals at different angles — reads as a woven line field,
// not the old single-hatch stripes.
background-image:
repeating-linear-gradient(
118deg,
transparent 0,
transparent 11px,
rgba(255, 255, 255, 0.14) 11px,
rgba(255, 255, 255, 0.14) 12px
),
repeating-linear-gradient(
62deg,
transparent 0,
transparent 17px,
rgba(255, 255, 255, 0.07) 17px,
rgba(255, 255, 255, 0.07) 18px
);
// Physical left → fade out toward the right.
-webkit-mask-image: linear-gradient(to right, #000 0%, #000 30%, transparent 90%);
mask-image: linear-gradient(to right, #000 0%, #000 30%, transparent 90%);
}
.dash__banner:active {
transform: scale(0.985);
}
.dash__bannerIcon {
flex: 0 0 auto;
width: 36px;
height: 36px;
border-radius: 11px;
display: grid;
place-items: center;
color: #fff;
background: rgba(255, 255, 255, 0.18);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.dash__bannerText {
flex: 1;
min-width: 0;
}
.dash__bannerText b {
display: block;
font-size: 13px;
font-weight: 700;
color: #fff;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dash__bannerText small {
display: block;
margin-top: 2px;
font-size: 11.5px;
color: rgba(255, 255, 255, 0.78);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dash__banner--survey {
background: linear-gradient(115deg, @green 0%, #1f7a4d 100%);
box-shadow: 0 10px 24px -16px alpha(@green, 70%);
}
.dash__bannerChevron {
flex: 0 0 auto;
width: 28px;
height: 28px;
border-radius: 9px;
display: grid;
place-items: center;
color: #fff;
background: rgba(255, 255, 255, 0.12);
}
// ── Rate row ───────────────────────────────────────────────────────────
.dash__rate {
margin-top: 12px;
@@ -27,6 +27,16 @@ onMounted(async () => {
try {
const result = await api.ssoCallback(params.get('code') as string, params.get('state') as string)
session.login(result.token, result.customer.id, result.customer.mobile)
try {
const { useCartStore } = await import('../../stores/cartStore')
const { useBuyLaterStore } = await import('../../stores/buyLaterStore')
await Promise.all([
useCartStore().applyExpiryAndSync(),
useBuyLaterStore().syncFromServer(),
])
} catch {
/* non-fatal */
}
try {
const who = await api.getExpertIdentity()
session.setSalesExpert(who.isSalesExpert)
@@ -189,6 +189,17 @@ const sendToken = async (otpCode: string) => {
window.location.assign(verified.iamBootstrapUrl)
return
}
// Mirror local cart to the server (and pick up a server-side clear).
try {
const { useCartStore } = await import('../../stores/cartStore')
const { useBuyLaterStore } = await import('../../stores/buyLaterStore')
await Promise.all([
useCartStore().applyExpiryAndSync(),
useBuyLaterStore().syncFromServer(),
])
} catch {
/* non-fatal */
}
// D21 — ask the server which kind of principal this token is, so the
// expert-only entries appear for a sales expert. Failure is non-fatal:
// the entries simply stay hidden, and the server refuses those routes
@@ -0,0 +1,226 @@
<template>
<div class="svf">
<PageBar title="نظرسنجی طرح" back-to="userPage::hasSubRoute">
<template #trailing>
<button type="button" class="svf__mine" @click="router.push({ name: 'surveyMine' })">طرح‌های من</button>
</template>
</PageBar>
<Spinner :spinning="loading && !rows.length" tip="درحال دریافت اطلاعات">
<AppPullRefresh v-model="refreshing" @refresh="onRefresh">
<container class="svf__body">
<article v-for="item in rows" :key="item.id" class="svf__card">
<img class="svf__photo" :src="item.imageUrl" alt="" />
<div class="svf__meta">
<b v-if="item.title">{{ item.title }}</b>
<span>وزن پیشنهادی: {{ item.suggestedWeightGrams }} گرم</span>
<span v-if="item.reachedMajority" class="svf__majority">به حد اکثریت رسیده</span>
<span v-else-if="item.majorityThreshold" class="svf__prog">
▲ {{ item.likes }} از {{ item.majorityThreshold }} لایک لازم
</span>
</div>
<div class="svf__votes">
<button
type="button"
class="svf__vote"
:class="{ 'is-on': item.myVote === 'LIKE' }"
:disabled="votingId === item.id"
@click="vote(item, 'LIKE')"
>
پسندیدم · {{ item.likes }}
</button>
<button
type="button"
class="svf__vote is-down"
:class="{ 'is-on': item.myVote === 'DISLIKE' }"
:disabled="votingId === item.id"
@click="vote(item, 'DISLIKE')"
>
نپسندیدم · {{ item.dislikes }}
</button>
</div>
</article>
<section v-if="!rows.length && !loading && !failed" class="svf__empty">
<Icon icon="vuesax-linear:like-1" size="26" />
<p>الان طرح فعالی برای رأی‌گیری نیست.</p>
<button type="button" class="svf__cta" @click="router.push({ name: 'surveyMine' })">ثبت طرح خودم</button>
</section>
<section v-if="failed" class="svf__empty">
<Icon icon="vuesax-linear:danger" size="26" />
<p>دریافت اطلاعات با خطا روبه‌رو شد.</p>
<button type="button" class="svf__cta" @click="load">تلاش دوباره</button>
</section>
</container>
</AppPullRefresh>
</Spinner>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import AppPullRefresh from '../../components/ui/AppPullRefresh.vue'
import Icon from '../../components/icon/Icon.vue'
import PageBar from '../../components/ui/PageBar.vue'
import Spinner from '../../components/Spinner.vue'
import { toast } from '../../composables/useToast'
import { api, type ProductSurveyRow } from '../../api/client'
const router = useRouter()
const rows = ref<ProductSurveyRow[]>([])
const loading = ref(false)
const refreshing = ref(false)
const failed = ref(false)
const votingId = ref<string | null>(null)
async function load() {
loading.value = true
failed.value = false
try {
rows.value = (await api.listOpenProductSurveys()).rows
} catch {
failed.value = true
} finally {
loading.value = false
}
}
async function onRefresh() {
try {
rows.value = (await api.listOpenProductSurveys()).rows
failed.value = false
} catch {
failed.value = true
} finally {
refreshing.value = false
}
}
async function vote(item: ProductSurveyRow, value: 'LIKE' | 'DISLIKE') {
votingId.value = item.id
try {
const r = await api.voteProductSurvey(item.id, value)
item.myVote = r.myVote
item.likes = r.likes
item.dislikes = r.dislikes
} catch {
toast.error('ثبت رأی ممکن نشد')
} finally {
votingId.value = null
}
}
onMounted(load)
</script>
<style scoped lang="less">
.svf__mine {
border: none;
background: transparent;
color: @wine;
font-family: inherit;
font-size: 13px;
font-weight: 700;
cursor: pointer;
}
.svf__body {
padding-top: 84px;
padding-bottom: 110px;
display: flex;
flex-direction: column;
gap: 14px;
}
.svf__card {
border: 1px solid @hairline;
border-radius: 16px;
overflow: hidden;
}
.svf__photo {
display: block;
width: 100%;
aspect-ratio: 4 / 3;
object-fit: cover;
background: @hairline;
}
.svf__meta {
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px 14px 4px;
font-size: 13px;
color: @inkSoft;
}
.svf__meta b {
color: @ink;
font-size: 14px;
}
.svf__majority {
color: @green;
font-weight: 700;
}
.svf__prog {
font-size: 12px;
color: @inkMuted;
}
.svf__votes {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
padding: 10px 12px 14px;
}
.svf__vote {
padding: 11px 8px;
border: 1px solid @hairline;
border-radius: 999px;
background: transparent;
color: @ink;
font-family: inherit;
font-size: 13px;
font-weight: 700;
cursor: pointer;
}
.svf__vote.is-on {
border-color: @wine;
background: @wineWash;
color: @wine;
}
.svf__vote.is-down.is-on {
border-color: @red;
color: @red;
background: transparent;
}
.svf__empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 48px 16px;
color: @inkMuted;
text-align: center;
}
.svf__cta {
margin-top: 6px;
padding: 10px 18px;
border: none;
border-radius: 999px;
background: @wine;
color: #fff;
font-family: inherit;
font-size: 13px;
font-weight: 700;
cursor: pointer;
}
</style>
@@ -0,0 +1,211 @@
<template>
<div class="svm">
<PageBar title="طرح‌های من" back-to="surveyFeed" />
<Spinner :spinning="submitting" tip="در حال ثبت">
<container class="svm__body">
<form class="svm__form" novalidate @submit.prevent="submit">
<p class="svm__lead">طرح جدید برای نظرسنجی</p>
<SingleImageUploader
upload-text="بارگذاری تصویر طرح"
purpose="product-survey"
@done="(id: string) => (form.file_id = id)"
/>
<label class="svm__field">
<span>وزن پیشنهادی (گرم)</span>
<input v-model.number="form.suggestedWeightGrams" type="number" inputmode="decimal" min="0.01" step="0.1" />
</label>
<label class="svm__field">
<span>عنوان (اختیاری)</span>
<input v-model.trim="form.title" type="text" maxlength="200" />
</label>
<p v-if="error" class="svm__error">{{ error }}</p>
<button type="submit" class="svm__submit" :disabled="submitting">ارسال برای بررسی</button>
</form>
<section class="svm__list">
<h2 class="svm__listTitle">ارسال‌های قبلی</h2>
<article v-for="item in rows" :key="item.id" class="svm__card">
<img class="svm__photo" :src="item.imageUrl" alt="" />
<div class="svm__meta">
<b>{{ item.title || 'بدون عنوان' }}</b>
<span>{{ item.suggestedWeightGrams }} گرم</span>
<span>{{ item.statusLabel }}</span>
<span v-if="item.status === 'OPEN' || item.status === 'CLOSED' || item.status === 'APPROVED' || item.status === 'REJECTED'">
▲ {{ item.likes }} · ▼ {{ item.dislikes }}
</span>
</div>
</article>
<p v-if="!rows.length && !loading" class="svm__empty">هنوز طرحی نفرستاده‌اید.</p>
<p v-if="failed" class="svm__empty">بارگذاری لیست ناموفق بود.</p>
</section>
</container>
</Spinner>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref, watch } from 'vue'
import PageBar from '../../components/ui/PageBar.vue'
import Spinner from '../../components/Spinner.vue'
import SingleImageUploader from '../../components/upload/SingleImageUploader.vue'
import { toast } from '../../composables/useToast'
import { api, type ProductSurveyRow } from '../../api/client'
const form = reactive({
file_id: null as string | null,
suggestedWeightGrams: null as number | null,
title: '',
})
const submitting = ref(false)
const error = ref<string | null>(null)
const rows = ref<ProductSurveyRow[]>([])
const loading = ref(false)
const failed = ref(false)
watch(form, () => (error.value = null))
async function load() {
loading.value = true
failed.value = false
try {
rows.value = (await api.listMyProductSurveys()).rows
} catch {
failed.value = true
} finally {
loading.value = false
}
}
async function submit() {
if (!form.file_id) {
error.value = 'لطفا تصویر طرح را بارگذاری کنید'
return
}
if (!form.suggestedWeightGrams || form.suggestedWeightGrams <= 0) {
error.value = 'وزن پیشنهادی را وارد کنید'
return
}
submitting.value = true
try {
await api.createProductSurvey({
file_id: form.file_id,
suggestedWeightGrams: form.suggestedWeightGrams,
title: form.title || undefined,
})
toast.success('طرح ثبت شد؛ منتظر تأیید ادمین بمانید')
form.file_id = null
form.suggestedWeightGrams = null
form.title = ''
await load()
} catch {
error.value = 'ثبت طرح ممکن نشد'
} finally {
submitting.value = false
}
}
onMounted(load)
</script>
<style scoped lang="less">
.svm__body {
padding-top: 84px;
padding-bottom: 40px;
}
.svm__lead {
margin: 0 2px 12px;
font-size: 13px;
font-weight: 700;
color: @inkMuted;
}
.svm__field {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px 0;
border-bottom: 1px solid @hairline;
font-size: 12.5px;
color: @inkSoft;
}
.svm__field input {
width: 100%;
padding: 11px 12px;
border: 1px solid @hairline;
border-radius: 12px;
background: transparent;
color: @ink;
font-family: inherit;
font-size: 14px;
}
.svm__error {
margin: 12px 2px 0;
font-size: 12.5px;
color: @red;
}
.svm__submit {
margin-top: 16px;
width: 100%;
padding: 14px;
border: none;
border-radius: 999px;
background: @wine;
color: #fff;
font-family: inherit;
font-size: 15px;
font-weight: 700;
cursor: pointer;
}
.svm__list {
margin-top: 28px;
}
.svm__listTitle {
margin: 0 2px 12px;
font-size: 14px;
font-weight: 700;
color: @ink;
}
.svm__card {
display: grid;
grid-template-columns: 72px 1fr;
gap: 12px;
padding: 10px 0;
border-bottom: 1px solid @hairline;
}
.svm__photo {
width: 72px;
height: 72px;
object-fit: cover;
border-radius: 12px;
background: @hairline;
}
.svm__meta {
display: flex;
flex-direction: column;
gap: 3px;
font-size: 12.5px;
color: @inkSoft;
}
.svm__meta b {
color: @ink;
font-size: 13.5px;
}
.svm__empty {
padding: 18px 2px;
color: @inkMuted;
font-size: 13px;
}
</style>
+3
View File
@@ -35,6 +35,9 @@ importers:
axios:
specifier: ^1.7.7
version: 1.20.0
dayjs:
specifier: ^1.11.23
version: 1.11.23
lodash:
specifier: ^4.17.21
version: 4.18.1