From 7dd74ff4f7c5abbc422d00afd538411fc7a07595 Mon Sep 17 00:00:00 2001 From: mahmood Date: Thu, 12 Nov 2026 17:55:15 +0330 Subject: [PATCH] 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 --- apps/admin-panel/package.json | 1 + .../accounting/AccountingConnection.vue | 9 +- apps/admin-panel/src/layouts/navMenuItems.ts | 18 +- apps/admin-panel/src/router/admin.ts | 50 +- apps/admin-panel/src/views/Dashboard.vue | 48 + .../src/views/apps/product/AddProduct.vue | 1177 +++++++---------- .../src/views/apps/product/ProductSurvey.vue | 355 +++++ .../views/apps/product/VariantRowEditor.vue | 627 +++++++++ .../src/views/apps/product/variant.ts | 136 ++ .../src/views/manage/accounting.vue | 36 +- .../src/views/manage/cart-expiry.vue | 173 +++ apps/admin-panel/src/views/statics/index.vue | 400 ++++-- .../src/views/user/permissionConst.ts | 4 + .../20261111120000_product_view/migration.sql | 13 + .../migration.sql | 8 + .../migration.sql | 13 + .../migration.sql | 12 + .../migration.sql | 49 + apps/api/prisma/schema.prisma | 121 ++ apps/api/src/app.module.ts | 4 + .../accounting-sync/accounting-debug-log.ts | 62 + .../accounting-hooks.service.ts | 16 +- .../accounting-sync.service.ts | 2 + .../accounting-worker.service.ts | 11 +- .../providers/zarrin.provider.ts | 25 +- .../admin-misc/admin-misc.controller.ts | 8 + .../modules/admin-misc/admin-misc.service.ts | 83 ++ .../admin-products.controller.ts | 6 + .../admin-products/admin-products.service.ts | 22 + apps/api/src/modules/auth/acl.constants.ts | 2 + apps/api/src/modules/auth/super-admin.ts | 1 + .../cart/admin-cart-expiry.controller.ts | 21 + .../modules/cart/cart-expiry.worker.spec.ts | 124 ++ .../src/modules/cart/cart-expiry.worker.ts | 140 ++ .../src/modules/cart/cart-schedule.spec.ts | 100 ++ apps/api/src/modules/cart/cart-schedule.ts | 238 ++++ apps/api/src/modules/cart/cart.controller.ts | 52 + apps/api/src/modules/cart/cart.dto.ts | 70 + apps/api/src/modules/cart/cart.module.ts | 14 + .../api/src/modules/cart/cart.service.spec.ts | 104 ++ apps/api/src/modules/cart/cart.service.ts | 180 +++ .../src/modules/catalog/catalog.controller.ts | 11 +- .../src/modules/catalog/catalog.service.ts | 16 +- .../modules/catalog/dto/list-products.dto.ts | 13 +- apps/api/src/modules/orders/orders.module.ts | 3 +- .../src/modules/orders/orders.service.spec.ts | 9 +- apps/api/src/modules/orders/orders.service.ts | 13 + .../admin-product-survey.controller.ts | 60 + .../customer-product-survey.controller.ts | 39 + .../product-survey/product-survey.module.ts | 10 + .../product-survey.service.spec.ts | 185 +++ .../product-survey/product-survey.service.ts | 432 ++++++ apps/user-app-v2/src/api/client.ts | 117 ++ .../src/components/expert/ActingAsBar.vue | 3 + .../src/components/home/ProductPanel.vue | 10 + .../src/components/product/CartItem.vue | 110 +- .../src/components/product/ProductCard.vue | 10 + .../src/components/product/ProductMorph.vue | 16 +- .../src/components/product/ProductTags.vue | 50 + .../src/components/product/SingleProduct.vue | 3 +- .../src/composables/useProductMorph.ts | 73 + apps/user-app-v2/src/router/index.ts | 2 + apps/user-app-v2/src/stores/buyLaterStore.ts | 214 +++ apps/user-app-v2/src/stores/cartStore.ts | 187 ++- apps/user-app-v2/src/utils/cartExpiry.ts | 92 ++ apps/user-app-v2/src/views/CartView.vue | 267 +++- apps/user-app-v2/src/views/HomeView.vue | 92 +- .../src/views/expert/CustomerChoiceView.vue | 3 + .../src/views/user/DashboardView.vue | 208 ++- .../src/views/user/IamCallback.vue | 10 + apps/user-app-v2/src/views/user/LoginView.vue | 11 + .../src/views/user/SurveyFeedView.vue | 226 ++++ .../src/views/user/SurveyMineView.vue | 211 +++ pnpm-lock.yaml | 3 + 74 files changed, 6351 insertions(+), 893 deletions(-) create mode 100644 apps/admin-panel/src/views/apps/product/ProductSurvey.vue create mode 100644 apps/admin-panel/src/views/apps/product/VariantRowEditor.vue create mode 100644 apps/admin-panel/src/views/apps/product/variant.ts create mode 100644 apps/admin-panel/src/views/manage/cart-expiry.vue create mode 100644 apps/api/prisma/migrations/20261111120000_product_view/migration.sql create mode 100644 apps/api/prisma/migrations/20261111130000_product_tags_column/migration.sql create mode 100644 apps/api/prisma/migrations/20261112130000_customer_cart_expiry/migration.sql create mode 100644 apps/api/prisma/migrations/20261112140000_customer_buy_later/migration.sql create mode 100644 apps/api/prisma/migrations/20261112150000_product_survey/migration.sql create mode 100644 apps/api/src/modules/accounting-sync/accounting-debug-log.ts create mode 100644 apps/api/src/modules/cart/admin-cart-expiry.controller.ts create mode 100644 apps/api/src/modules/cart/cart-expiry.worker.spec.ts create mode 100644 apps/api/src/modules/cart/cart-expiry.worker.ts create mode 100644 apps/api/src/modules/cart/cart-schedule.spec.ts create mode 100644 apps/api/src/modules/cart/cart-schedule.ts create mode 100644 apps/api/src/modules/cart/cart.controller.ts create mode 100644 apps/api/src/modules/cart/cart.dto.ts create mode 100644 apps/api/src/modules/cart/cart.module.ts create mode 100644 apps/api/src/modules/cart/cart.service.spec.ts create mode 100644 apps/api/src/modules/cart/cart.service.ts create mode 100644 apps/api/src/modules/product-survey/admin-product-survey.controller.ts create mode 100644 apps/api/src/modules/product-survey/customer-product-survey.controller.ts create mode 100644 apps/api/src/modules/product-survey/product-survey.module.ts create mode 100644 apps/api/src/modules/product-survey/product-survey.service.spec.ts create mode 100644 apps/api/src/modules/product-survey/product-survey.service.ts create mode 100644 apps/user-app-v2/src/components/product/ProductTags.vue create mode 100644 apps/user-app-v2/src/stores/buyLaterStore.ts create mode 100644 apps/user-app-v2/src/utils/cartExpiry.ts create mode 100644 apps/user-app-v2/src/views/user/SurveyFeedView.vue create mode 100644 apps/user-app-v2/src/views/user/SurveyMineView.vue diff --git a/apps/admin-panel/package.json b/apps/admin-panel/package.json index ae8298d..74cbe30 100644 --- a/apps/admin-panel/package.json +++ b/apps/admin-panel/package.json @@ -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", diff --git a/apps/admin-panel/src/components/accounting/AccountingConnection.vue b/apps/admin-panel/src/components/accounting/AccountingConnection.vue index 57cf181..34a3e93 100644 --- a/apps/admin-panel/src/components/accounting/AccountingConnection.vue +++ b/apps/admin-panel/src/components/accounting/AccountingConnection.vue @@ -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; diff --git a/apps/admin-panel/src/layouts/navMenuItems.ts b/apps/admin-panel/src/layouts/navMenuItems.ts index 92c5f6e..b45ad80 100644 --- a/apps/admin-panel/src/layouts/navMenuItems.ts +++ b/apps/admin-panel/src/layouts/navMenuItems.ts @@ -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', diff --git a/apps/admin-panel/src/router/admin.ts b/apps/admin-panel/src/router/admin.ts index 2bbd30b..9f06542 100644 --- a/apps/admin-panel/src/router/admin.ts +++ b/apps/admin-panel/src/router/admin.ts @@ -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' } } diff --git a/apps/admin-panel/src/views/Dashboard.vue b/apps/admin-panel/src/views/Dashboard.vue index 8217c7b..2845ddf 100644 --- a/apps/admin-panel/src/views/Dashboard.vue +++ b/apps/admin-panel/src/views/Dashboard.vue @@ -22,6 +22,26 @@ + + + + +
@@ -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 = []; + } + } }); @@ -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; diff --git a/apps/admin-panel/src/views/apps/product/AddProduct.vue b/apps/admin-panel/src/views/apps/product/AddProduct.vue index e35ad3e..daf858d 100644 --- a/apps/admin-panel/src/views/apps/product/AddProduct.vue +++ b/apps/admin-panel/src/views/apps/product/AddProduct.vue @@ -4,13 +4,14 @@ Serves BOTH create and edit — EditProduct.vue is only a wrapper around this component in legacy, and the same is true here. - Preserved: the Farsi labels, the show_type map, the inline variant table with - its add/edit modal, and the permission gates on the variant actions. + Preserved: the Farsi labels, the show_type map and the permission gates on + the variant actions. Variants are entered and edited inline (VariantRowEditor), + not in a modal: required figures up front, the rest behind «موارد بیشتر». Fields with no column in the redesigned schema are NOT rendered rather than collected and silently dropped — listed in docs/ADMIN-PORT-PLAN.md: delivery_type, message_long_delivery, offer_day, has_warranty, is_exist, - tags, and the variant colour/rial-wage fields. + and the variant colour/rial-wage fields. The image uploaders ARE rendered (unlike the note above once claimed): Product.images is a real ProductImage[] relation and `file/create` is a real, @@ -38,7 +39,6 @@

{{ id ? 'ویرایش محصول' : 'افزودن محصول' }}

-

اطلاعات، تنوع‌ها و تصاویر محصول را تکمیل کنید.

{{ form.is_active ? 'فعال' : 'غیر فعال' }} @@ -49,12 +49,11 @@
-
+

اطلاعات پایه

-

نام، کد و توضیحات محصول

@@ -69,6 +68,8 @@ -
+

تنوع ها

-

وزن، اجرت و موجودی هر مدل از محصول

افزودن تنوع
- - - - افزودن اولین تنوع - - +
+ -
- - + + diff --git a/apps/admin-panel/src/views/apps/product/VariantRowEditor.vue b/apps/admin-panel/src/views/apps/product/VariantRowEditor.vue new file mode 100644 index 0000000..7fb9b15 --- /dev/null +++ b/apps/admin-panel/src/views/apps/product/VariantRowEditor.vue @@ -0,0 +1,627 @@ + + + + + diff --git a/apps/admin-panel/src/views/apps/product/variant.ts b/apps/admin-panel/src/views/apps/product/variant.ts new file mode 100644 index 0000000..c838206 --- /dev/null +++ b/apps/admin-panel/src/views/apps/product/variant.ts @@ -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>; + +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( + '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, + ); +} diff --git a/apps/admin-panel/src/views/manage/accounting.vue b/apps/admin-panel/src/views/manage/accounting.vue index a579328..59d0ed6 100644 --- a/apps/admin-panel/src/views/manage/accounting.vue +++ b/apps/admin-panel/src/views/manage/accounting.vue @@ -37,7 +37,7 @@ show-icon class="mb-4" message="سینک مشتری اجرا نمی‌شود" - description="هیچ گروه مشتری برای ورود انتخاب نشده است؛ در تب «نگاشت» گروه‌هایی را که واقعاً مشتری هستند انتخاب و ذخیره کنید." + description="در تب «نگاشت» مشخص کنید کدام گروه‌های حسابداری مشتری‌اند؛ اگر خالی باشد کسی وصل نمی‌شود." /> @@ -139,21 +139,21 @@
  1. تنظیمات تب «اتصال» را ذخیره کنید، بعد «دریافت گروه‌ها از حسابداری» را بزنید تا فهرست‌ها از حسابداری پر شوند.
  2. - گروه‌های حسابی که مشتری {{ brand.name }} حساب می‌شوند: در حسابداری صندوق، کارمند و سرمایه‌گذار هم «حساب» هستند. فقط گروه‌هایی را انتخاب کنید که - واقعاً مشتری‌اند؛ سینک مشتری فقط حساب‌های این گروه‌ها را با شماره موبایل به کاربران اپ مشتری وصل می‌کند. اگر خالی بماند سینک مشتری اجرا نمی‌شود. + کدام گروه‌های حسابداری مشتری‌اند؟ این‌ها گروه‌های خودِ نرم‌افزار حسابداری‌اند (نه {{ brand.name }}). صندوق، کارمند و سرمایه‌گذار را نزنید؛ + فقط گروه‌های واقعی مشتری. سینک فقط همین‌ها را با موبایل به کاربران اپ وصل می‌کند. خالی = سینک مشتری اجرا نمی‌شود.
  3. - گروه مشتری جدید: وقتی کاربری در اپ مشتری احراز هویت شود و در حسابداری حساب نداشته باشد، حسابش در این گروه ساخته می‌شود. - اگر تعیین نشود، ساخت حساب در «لاگ ارسال» با وضعیت «نیاز به بررسی» می‌ماند. + حساب جدید اپ در کدام گروه ساخته شود؟ اگر کسی در اپ مشتری احراز هویت کند و در حسابداری هنوز حساب نداشته باشد، حسابش اینجا ساخته می‌شود. + خالی = ساخت حساب در «لاگ ارسال» می‌ماند تا خودتان تعیین کنید.
  4. عیار پایه: عیار وزن‌هایی که در {{ brand.name }} ثبت می‌شود (وزن محصول‌ها و سفارش‌ها؛ معمولاً ۷۵۰ = ۱۸ عیار). در سند سفارش، وزن با همین عیار به حسابداری می‌رود؛ در ته‌حساب اجرت هم روی آن اعمال می‌شود (مثلاً ۷۵۰ با اجرت ۱۰٪ می‌شود ۸۲۵).
  5. - کارساخته (فقط زرین): وقتی در پنل مدیریت {{ brand.name }} محصول جدیدی تعریف شود، برایش یک «کارساخته» در زرین ساخته می‌شود. «نوع» تعیین می‌کند - گرمی است یا تعدادی، و «گروه ساخته» جای آن در زرین است. در جدول پایین برای هر دسته‌بندی محصولِ {{ brand.name }} می‌توانید گروه جدا بدهید؛ دسته‌ای که - «پیش‌فرض» بماند در «گروه ساخته‌ی پیش‌فرض» می‌رود. اگر نوع یا گروه خالی باشد، ساخت کارساخته در «لاگ ارسال» منتظر بررسی می‌ماند. + کارساخته (فقط زرین): وقتی در پنل مدیریت {{ brand.name }} محصول جدیدی تعریف شود، برایش یک «کارساخته» در زرین ساخته می‌شود. + نوع (گرمی/تعدادی) و گروه ساخته را اینجا مشخص کنید. در جدول برای هر دسته می‌توانید گروه جدا بدهید؛ بقیه به گروه پیش‌فرض می‌روند. + اگر نوع یا گروه خالی باشد، ساخت کارساخته در «لاگ ارسال» منتظر بررسی می‌ماند.

@@ -167,25 +167,23 @@ حساب مشتری‌ها - +

- + @@ -205,7 +203,7 @@
ستون «دسته‌بندی محصول در {{ brand.name }}» همان دسته‌بندی‌هایی است که در پنل مدیریت برای محصولات تعریف کرده‌اید. مثال: اگر روبه‌روی «انگشتر» گروه «انگشتر» زرین را بگذارید، هر محصول تازه در دسته‌ی انگشتر {{ brand.name }}، در گروه انگشترِ زرین ساخته می‌شود. - دسته‌هایی که «پیش‌فرض» بمانند در «گروه ساخته‌ی پیش‌فرض» می‌روند. + دسته‌هایی که در جدول «پیش‌فرض» بمانند، به همان گروه پیش‌فرض پایین می‌روند.
اگر نوع یا گروه خالی باشد، محصول در {{ brand.name }} ساخته می‌شود ولی ساخت کارساخته در «لاگ ارسال» منتظر می‌ماند تا آن را تعیین کنید و «تلاش دوباره» بزنید. @@ -214,10 +212,10 @@
- + - +
diff --git a/apps/admin-panel/src/views/manage/cart-expiry.vue b/apps/admin-panel/src/views/manage/cart-expiry.vue new file mode 100644 index 0000000..cee2e8f --- /dev/null +++ b/apps/admin-panel/src/views/manage/cart-expiry.vue @@ -0,0 +1,173 @@ + + + + + diff --git a/apps/admin-panel/src/views/statics/index.vue b/apps/admin-panel/src/views/statics/index.vue index 23ccfa4..9c0cd05 100644 --- a/apps/admin-panel/src/views/statics/index.vue +++ b/apps/admin-panel/src/views/statics/index.vue @@ -1,91 +1,339 @@ + + diff --git a/apps/user-app-v2/src/components/product/SingleProduct.vue b/apps/user-app-v2/src/components/product/SingleProduct.vue index 1aba3d7..3787153 100644 --- a/apps/user-app-v2/src/components/product/SingleProduct.vue +++ b/apps/user-app-v2/src/components/product/SingleProduct.vue @@ -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() }) diff --git a/apps/user-app-v2/src/composables/useProductMorph.ts b/apps/user-app-v2/src/composables/useProductMorph.ts index 6f3f0b6..9913601 100644 --- a/apps/user-app-v2/src/composables/useProductMorph.ts +++ b/apps/user-app-v2/src/composables/useProductMorph.ts @@ -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(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 & { + 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('[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() { diff --git a/apps/user-app-v2/src/router/index.ts b/apps/user-app-v2/src/router/index.ts index d7dae43..78fdca6 100644 --- a/apps/user-app-v2/src/router/index.ts +++ b/apps/user-app-v2/src/router/index.ts @@ -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 diff --git a/apps/user-app-v2/src/stores/buyLaterStore.ts b/apps/user-app-v2/src/stores/buyLaterStore.ts new file mode 100644 index 0000000..2185b60 --- /dev/null +++ b/apps/user-app-v2/src/stores/buyLaterStore.ts @@ -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 | 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() + 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) { + 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) { + const i = this.find_index(item) + if (i === -1) return + this.items.splice(i, 1) + this.writeLocal() + }, + + updateQty(item: Pick, 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, 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() + }, + }, +}) diff --git a/apps/user-app-v2/src/stores/cartStore.ts b/apps/user-app-v2/src/stores/cartStore.ts index db65961..de32a01 100644 --- a/apps/user-app-v2/src/stores/cartStore.ts +++ b/apps/user-app-v2/src/stores/cartStore.ts @@ -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 | 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) { @@ -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() }, }, }) diff --git a/apps/user-app-v2/src/utils/cartExpiry.ts b/apps/user-app-v2/src/utils/cartExpiry.ts new file mode 100644 index 0000000..efcbc37 --- /dev/null +++ b/apps/user-app-v2/src/utils/cartExpiry.ts @@ -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 = {} + 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 +} diff --git a/apps/user-app-v2/src/views/CartView.vue b/apps/user-app-v2/src/views/CartView.vue index 14dbd42..34a07bb 100644 --- a/apps/user-app-v2/src/views/CartView.vue +++ b/apps/user-app-v2/src/views/CartView.vue @@ -27,37 +27,122 @@ -->