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:
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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()
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
Generated
+3
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user