feat: order revision, rial wage from variant, product tags; drop user-app-v2

Admin order edit with category chips and timeline; customer checkout charges variant rialWage; product tags; retire the unused user-app-v2 tree.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
saeed
2026-10-28 14:29:12 +03:30
co-authored by Cursor
parent 4e09a66fbf
commit feb5727c0e
429 changed files with 3942 additions and 30302 deletions
+17
View File
@@ -81,6 +81,23 @@ body::-webkit-scrollbar {
-webkit-user-select: none;
-ms-user-select: none;
user-select: none;
// Centred phone column on desktop (width only). Do NOT put transform /
// filter / perspective here — that would make position:fixed bars
// (tabbar, PageBar) stick to #app and scroll away with the page.
max-width: var(--app-max-width);
margin-inline: auto;
min-height: 100dvh;
min-height: 100svh;
background: @page;
position: relative;
}
@media (min-width: 431px) {
#app {
box-shadow:
0 0 0 1px @hairline,
0 28px 64px -28px rgba(20, 22, 26, 0.32);
}
}
.slideDown-enter-active,
+58
View File
@@ -105,6 +105,10 @@ export interface ProductVariantSummary {
/// can read them.
qty?: number;
isPreOrder?: boolean;
/// Fixed Rial making charge (اجرت ریالی) on the variant. Catalog maps
/// ProductVariant.rialWage → rial_pay; the cart shows it and the server
/// charges it as OrderItem.flatRialCharge on checkout.
rial_pay?: number | null;
/// The gallery image chosen for this variant in the admin panel. It is one of
/// the product's OWN images — the same ProductImage row, not a copy — so
/// several variants (e.g. different weights of one design) may share it.
@@ -138,6 +142,8 @@ export interface ProductSummary {
/// Legacy Product.product_code — the human-facing code. Already returned by
/// GET /catalog/products.
productCode?: string | null;
/// Labels assigned in admin (from `__list__tags`). Shown on list cards only.
tags?: string[];
variants: ProductVariantSummary[];
/// Returned sorted by sortOrder, so images[0] really is the product's main
/// photo rather than whichever row the database happened to hand back.
@@ -237,6 +243,8 @@ export interface OrderItemDetail {
productVariant?: {
id: string;
title: string;
/** Admin-picked gallery image for this variant, when set. */
image?: { url: string } | null;
product: { id: string; title: string; images: Array<{ url: string }> };
} | null;
}
@@ -249,10 +257,33 @@ export interface OrderStatusEvent {
createdAt: string;
}
/** One row of GET /customer/me/notifications. */
export interface CustomerNotification {
id: string;
notificationId: string;
title: string;
body: string;
createdAt: string;
readAt: string | null;
isRead: boolean;
}
export interface OrderPendingRevision {
id: string;
items: unknown;
totalGoldWeightGrams: string;
wageWeightGrams: string;
flatRialCharge: string;
note: string | null;
customerDecision: string | null;
createdAt: string;
}
export interface OrderDetail {
id: string;
orderNumber: string;
status: string;
revisionStatus?: 'NONE' | 'AWAITING_CUSTOMER';
totalGoldWeightGrams: string;
wageWeightGrams: string;
flatRialCharge: string;
@@ -262,6 +293,8 @@ export interface OrderDetail {
items: OrderItemDetail[];
/** Chronological status changes (oldest first). May be empty on older rows. */
statusEvents?: OrderStatusEvent[];
/** Admin-proposed edit awaiting confirm/reject. */
pendingRevision?: OrderPendingRevision | null;
}
/** One page of GET /customer/me/orders — same envelope as the report endpoints. */
@@ -561,7 +594,32 @@ export const api = {
const qs = params.toString();
return request<OrdersPage>(`/customer/me/orders${qs ? `?${qs}` : ''}`);
},
confirmOrderRevision: (orderId: string) =>
request(`/customer/me/orders/${encodeURIComponent(orderId)}/revision/confirm`, {
method: 'POST',
body: '{}',
}),
rejectOrderRevision: (orderId: string) =>
request(`/customer/me/orders/${encodeURIComponent(orderId)}/revision/reject`, {
method: 'POST',
body: '{}',
}),
getMyLedgerEntries: () => request<LedgerEntrySummary[]>('/customer/me/ledger-entries'),
/** Admin messages addressed to this customer (broadcast or direct). */
getMyNotifications: () => request<CustomerNotification[]>('/customer/me/notifications'),
getMyUnreadNotificationCount: () =>
request<{ count: number }>('/customer/me/notifications/unread-count'),
markNotificationRead: (id: string) =>
request(`/customer/me/notifications/${encodeURIComponent(id)}/read`, {
method: 'POST',
body: '{}',
}),
markAllNotificationsRead: () =>
request<{ updated: number }>('/customer/me/notifications/read-all', {
method: 'POST',
body: '{}',
}),
/** The making charge the server will charge for these variants (MON-08). */
getWageQuotes: (variantIds: string[]) =>
request<WageQuote[]>('/customer/me/wage-quote', { method: 'POST', body: JSON.stringify({ variantIds }) }),
@@ -343,8 +343,36 @@ p {
margin-bottom: 10px;
}
// Phone column width. On desktop the shell centres at this size so the app
// does not stretch into a loose website layout. Tab bar already sat near 420.
:root {
--app-max-width: 430px;
}
html, body {
overscroll-behavior-y: contain;
background: @page;
}
@media (min-width: 431px) {
html, body {
// Side gutters behind the centred phone column (#app max-width).
background: @canvasSoft !important;
}
}
// Fixed overlays teleported to <body> (outside #app) — pin them to the same
// column. Below --app-max-width, max(0, …) collapses to full bleed.
.app-frame-x {
left: max(0px, calc(50vw - (var(--app-max-width) / 2)));
right: max(0px, calc(50vw - (var(--app-max-width) / 2)));
width: auto;
}
// Ant bottom drawers (order detail, status sheet) — same phone width, centred.
.ant-drawer-bottom > .ant-drawer-content-wrapper {
max-width: var(--app-max-width);
margin-inline: auto;
}
.overflow-x-auto {
@@ -57,8 +57,8 @@ async function leave() {
.aab {
position: fixed;
top: 0;
left: 0;
right: 0;
left: max(0px, calc(50vw - (var(--app-max-width) / 2)));
right: max(0px, calc(50vw - (var(--app-max-width) / 2)));
z-index: 2000;
display: flex;
align-items: center;
@@ -4,9 +4,8 @@
hairline borders, wine accent.
Ported originally from legacy henza_pwa/src/components/product/cartItem.vue.
Dropped there and still absent: the "اجرت ریالی" (Rial wage) block, which
displayed `selectedVariant.rial_pay` — no such per-variant Rial field exists
anywhere in our domain model, so there is nothing honest to show.
Shows اجرت ریالی from selectedVariant.rial_pay when the variant has one;
the server charges the same figure as OrderItem.flatRialCharge on checkout.
All cart behaviour is unchanged: quantity increment/decrement, remove at
qty 1, and the note affordance that opens the parent's drawer.
@@ -27,6 +26,7 @@
<span class="ci__weight">{{ selectedVariant?.rashWeightGrams }} گرم</span>
<span v-if="wage != null" class="ci__wage">اجرت {{ wageLabel }}٪</span>
</div>
<div v-if="rialPay > 0" class="ci__rial">اجرت ریالی {{ price(rialPay) }} ریال</div>
<div class="ci__row">
<div class="ci__qty">
@@ -70,6 +70,7 @@ import { computed } from 'vue'
import Icon from '../icon/Icon.vue'
import { useProduct } from '../../composables/useProduct'
import { useCartStore, type CartItem } from '../../stores/cartStore'
import { price } from '../../utils/filters'
const props = defineProps<{ value: CartItem }>()
const emit = defineEmits<{ (e: 'note', value: number): void }>()
@@ -97,6 +98,11 @@ const wageLabel = computed(() => {
if (w == null) return ''
return w % 1 === 0 ? String(w) : w.toFixed(2)
})
const rialPay = computed(() => {
const n = selectedVariant.value?.rial_pay
return n != null && Number(n) > 0 ? Number(n) : 0
})
</script>
<style scoped lang="less">
@@ -181,6 +187,13 @@ html.theme-dark .ci__art img {
border-inline-start: 1px solid @hairline;
}
.ci__rial {
padding-top: 4px;
font-size: 10.5px;
font-weight: 700;
color: @inkSoft;
}
.ci__row {
display: flex;
align-items: center;
@@ -21,7 +21,13 @@
<div class="oc__main">
<div class="oc__head">
<h3 class="oc__id">{{ value.orderNumber }}</h3>
<span class="oc__status" :style="{ background: color?.background, color: color?.font }">
<span
v-if="value.revisionStatus === 'AWAITING_CUSTOMER'"
class="oc__status oc__status--rev"
>
منتظر تأیید ویرایش
</span>
<span v-else class="oc__status" :style="{ background: color?.background, color: color?.font }">
{{ statusLabel }}
</span>
</div>
@@ -35,6 +41,10 @@
{{ parseFloat(value.totalGoldWeightGrams).toFixed(2) }}
<i>گرم</i>
</span>
<span v-if="rialCharge > 0" class="oc__figure">
{{ price(rialCharge) }}
<i>ریال اجرت</i>
</span>
</div>
<div class="oc__foot">
@@ -52,7 +62,7 @@
import { computed } from 'vue'
import Icon from '../icon/Icon.vue'
import { ORDER_STATUS_COLORS, ORDER_STATUS_LABELS } from '../../utils/orderStatus'
import { jdate } from '../../utils/filters'
import { jdate, price } from '../../utils/filters'
import type { OrderDetail } from '../../api/client'
const props = defineProps<{ value: OrderDetail }>()
@@ -60,6 +70,10 @@ const emit = defineEmits<{ (e: 'view', value: OrderDetail): void }>()
const statusLabel = computed(() => ORDER_STATUS_LABELS[props.value.status] ?? props.value.status)
const color = computed(() => ORDER_STATUS_COLORS[props.value.status])
const rialCharge = computed(() => {
const n = Number(props.value.flatRialCharge)
return Number.isFinite(n) && n > 0 ? n : 0
})
</script>
<style scoped lang="less">
@@ -122,6 +136,11 @@ const color = computed(() => ORDER_STATUS_COLORS[props.value.status])
line-height: 1.2;
}
.oc__status--rev {
color: @wine;
background: @wineWash;
}
.oc__figures {
display: flex;
gap: 14px;
@@ -1,17 +1,6 @@
<!--
One line of an order's contents, restyled into the redesigned design
language (see components/home/* and HomeView). Ported originally from
legacy henza_pwa/src/components/product/orderItem.vue.
The product/variant title and photo come WITH the order (the server includes
each item's variant and product). They used to be looked up in the browser
against the whole catalogue, which downloaded ~16k products to label a few
rows and left the row blank for a product that had since left the storefront.
DROPPED the "توضیحات: -" row the previous version rendered: our OrderItem
schema has no note field (legacy's per-item note was a real column there;
ours isn't), so it could only ever print a dash. An always-empty row is
noise, not information.
One line of an order's contents. Product/variant title and photo come WITH
the order (variant image preferred, else product cover). Tap photo to preview.
-->
<template>
<article class="oi">
@@ -24,39 +13,48 @@
>
<img v-lazy="lookup.imageUrl" :alt="lookup?.productTitle ?? ''" />
</button>
<div v-else class="oi__art">
<div class="oi__artEmpty" aria-hidden="true">
<Icon icon="vuesax-linear:diamonds" size="18" />
<div v-else class="oi__art" aria-hidden="true">
<div class="oi__artEmpty">
<Icon icon="vuesax-linear:diamonds" size="22" />
</div>
</div>
<div class="oi__main">
<div class="oi__head">
<h4 class="oi__title">{{ lookup?.productTitle ?? '—' }}</h4>
<h4 class="oi__title">{{ lookup?.productTitle ?? 'محصول' }}</h4>
<span v-if="lookup?.variantTitle" class="oi__variant">{{ lookup.variantTitle }}</span>
</div>
<div class="oi__figures">
<span class="oi__figure">
{{ value.quantity }}
<i>عدد</i>
<div class="oi__chips">
<span class="oi__chip">
<b>{{ value.quantity }}</b>
عدد
</span>
<span class="oi__figure">
{{ value.computedWeightGrams }}
<i>گرم</i>
<span class="oi__chip">
<b>{{ weightLabel }}</b>
گرم
</span>
<span class="oi__figure">
{{ value.wagePercentSnapshot }}
<i>٪ اجرت</i>
<span class="oi__chip">
<b>{{ wageLabel }}</b>
٪ اجرت
</span>
<span v-if="rialCharge > 0" class="oi__chip oi__chip--accent">
<b>{{ price(rialCharge) }}</b>
ریال
</span>
</div>
</div>
<!-- Above the orders drawer (z-index 2000); teleported so overflow:hidden
on the drawer body cannot clip the lightbox. -->
<Teleport to="body">
<Transition name="oi-preview">
<div v-if="previewOpen && lookup?.imageUrl" class="oi__preview" @click.self="closePreview">
<div
v-if="previewOpen && lookup?.imageUrl"
class="oi__preview"
role="dialog"
aria-modal="true"
aria-label="پیش‌نمایش تصویر"
@click.self="closePreview"
>
<button type="button" class="oi__previewClose" aria-label="بستن" @click="closePreview">
<Icon icon="vuesax-linear:add" size="20" />
</button>
@@ -77,13 +75,24 @@ import { computed, onBeforeUnmount, ref } from 'vue'
import Icon from '../icon/Icon.vue'
import { orderItemLookup } from '../../composables/useOrderItemProduct'
import type { OrderItemDetail } from '../../api/client'
import { price } from '../../utils/filters'
const props = defineProps<{ value: OrderItemDetail }>()
// The server sends these with the order, so there is nothing to fetch and
// nothing to wait for — the row renders complete on its first frame instead of
// filling in after a catalogue download.
const lookup = computed(() => orderItemLookup(props.value))
const rialCharge = computed(() => {
const n = Number(props.value.flatRialCharge)
return Number.isFinite(n) && n > 0 ? n : 0
})
const weightLabel = computed(() => {
const n = parseFloat(String(props.value.computedWeightGrams))
return Number.isFinite(n) ? n.toFixed(2) : String(props.value.computedWeightGrams)
})
const wageLabel = computed(() => {
const n = parseFloat(String(props.value.wagePercentSnapshot))
if (!Number.isFinite(n)) return String(props.value.wagePercentSnapshot)
return n % 1 === 0 ? String(n) : n.toFixed(2)
})
const previewOpen = ref(false)
let lockedBody = false
@@ -109,42 +118,48 @@ onBeforeUnmount(closePreview)
<style scoped lang="less">
.oi {
display: flex;
gap: 11px;
padding: 11px;
gap: 14px;
padding: 12px;
background: @canvas;
border: 1px solid @hairline;
border-radius: 16px;
border-radius: 18px;
}
.oi__art {
flex: 0 0 62px;
width: 62px;
height: 62px;
flex: 0 0 92px;
width: 92px;
height: 92px;
padding: 0;
border: 0;
border-radius: 12px;
border-radius: 14px;
overflow: hidden;
background: @hairlineSoft;
display: flex;
align-items: center;
justify-content: center;
}
.oi__art--preview {
cursor: zoom-in;
transition: transform 0.18s ease;
}
.oi__art--preview:active {
transform: scale(0.97);
}
.oi__art img {
width: 100%;
height: 100%;
object-fit: cover;
object-fit: contain;
object-position: center;
display: block;
pointer-events: none;
background: #fff;
}
// Dark mode: product photos are shot on white, so they sit on a light mat
// instead of showing as white boxes on a dark card (@photoMat — transparent,
// i.e. no change, in light mode).
html.theme-dark .oi__art img {
background: @photoMat;
border-radius: 12px;
}
.oi__preview {
@@ -191,7 +206,7 @@ html.theme-dark .oi__art img {
.oi-preview-enter-active,
.oi-preview-leave-active {
transition: opacity 0.2s ease;
transition: opacity 0.22s ease;
}
.oi-preview-enter-from,
.oi-preview-leave-to {
@@ -209,48 +224,66 @@ html.theme-dark .oi__art img {
.oi__main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
gap: 10px;
}
.oi__head {
display: flex;
align-items: baseline;
gap: 7px;
min-width: 0;
}
.oi__title {
margin: 0;
font-size: 12.5px;
font-weight: 700;
font-size: 13.5px;
font-weight: 800;
color: @ink;
white-space: nowrap;
line-height: 1.45;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
.oi__variant {
flex: 0 0 auto;
display: inline-block;
margin-top: 3px;
font-size: 11px;
color: @inkFaint;
}
.oi__chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.oi__chip {
display: inline-flex;
align-items: baseline;
gap: 4px;
padding: 5px 9px;
border-radius: 999px;
background: @hairlineSoft;
border: 1px solid @hairline;
font-size: 10.5px;
color: @inkFaint;
}
.oi__figures {
display: flex;
flex-wrap: wrap;
gap: 14px;
padding-top: 9px;
}
.oi__figure {
.oi__chip b {
font-size: 12px;
font-weight: 700;
font-weight: 800;
color: @inkSoft;
}
.oi__figure i {
font-style: normal;
font-size: 10px;
font-weight: 600;
color: @inkFaint;
padding-inline-start: 2px;
.oi__chip--accent {
background: @wineWash;
border-color: color-mix(in srgb, @wine 18%, @hairline);
color: @wine;
}
.oi__chip--accent b {
color: @wine;
}
</style>
@@ -44,6 +44,9 @@
<div class="pc__body">
<h3 class="pc__title">{{ title }}</h3>
<div v-if="tags.length" class="pc__tags">
<span v-for="tag in tags" :key="tag" class="pc__tag">{{ tag }}</span>
</div>
<div class="pc__meta">
<span class="pc__weight">{{ min_gram }} گرم</span>
<span v-if="wage != null" class="pc__wage">اجرت {{ wageLabel }}٪</span>
@@ -68,6 +71,7 @@ const props = defineProps<{
const favorites = useFavoritesStore()
const { image, id, title, min_gram, wage, is_exist, qty, isPreOrder } = useProduct(props.value)
const tags = computed(() => (props.value.tags ?? []).filter(Boolean))
// The detail opens as a morph out of this card — see ProductMorph.vue.
const root = ref<HTMLElement | null>(null)
@@ -211,6 +215,27 @@ html.theme-dark .pc__art img {
text-overflow: ellipsis;
}
.pc__tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
padding-top: 6px;
}
.pc__tag {
padding: 2px 8px;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
color: @wine;
background: alpha(@wine, 10%);
line-height: 1.4;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pc__meta {
display: flex;
align-items: center;
@@ -481,7 +481,14 @@ onBeforeUnmount(() => {
<style scoped lang="less">
.pm {
position: fixed;
inset: 0;
top: 0;
bottom: 0;
// Stay in the phone column on desktop (same as #app / .app-frame-x).
left: max(0px, calc(50vw - (var(--app-max-width) / 2)));
right: max(0px, calc(50vw - (var(--app-max-width) / 2)));
width: auto;
// Fixed bars inside SingleProduct (action bar, …) stay in this column.
transform: translateZ(0);
// Same layer the Drawer it replaces sat on; SingleProduct's own full-screen
// image preview is 2000 and must stay above this.
z-index: 1000;
@@ -26,6 +26,9 @@
<div class="pr__body">
<h3 class="pr__title">{{ title }}</h3>
<div v-if="tags.length" class="pr__tags">
<span v-for="tag in tags" :key="tag" class="pr__tag">{{ tag }}</span>
</div>
<div class="pr__meta">
<span class="pr__weight">{{ min_gram }} گرم</span>
<span v-if="wage != null" class="pr__wage">اجرت {{ wageLabel }}٪</span>
@@ -49,6 +52,7 @@ const props = defineProps<{ value: ProductSummary }>()
const favorites = useFavoritesStore()
const { image, id, title, min_gram, wage, is_exist, qty, isPreOrder } = useProduct(props.value)
const tags = computed(() => (props.value.tags ?? []).filter(Boolean))
const root = ref<HTMLElement | null>(null)
function open() {
@@ -173,6 +177,26 @@ html.theme-dark .pr__art img {
text-overflow: ellipsis;
}
.pr__tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.pr__tag {
padding: 2px 7px;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
color: @wine;
background: alpha(@wine, 10%);
line-height: 1.4;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pr__meta {
display: flex;
align-items: center;
@@ -1,19 +1,19 @@
<!--
Product detail — redesigned to match the new home-screen design language
Product detail — redesigned to match the new home-screen design language
(components/home/*): LESS colour tokens, BEM class names, ink surfaces with a
wine accent, hairline borders and soft long shadows.
WHAT IS SHOWN IS REAL. Every figure comes from data the API genuinely
returns today:
- title, productCode, description, category, brand
- the selected variant's rash weight (گرم) and making charge (اجرت ٪)
- the selected variant's rash weight (گرم) and making charge (اجرت ٪)
- stock, from ProductVariant.qty / isPreOrder
No rial price is printed: a finished piece's price is the live gold rate ×
No rial price is printed: a finished piece's price is the live gold rate ×
weight plus wage, computed at order time and not stored on the product, so
printing one here would be invented. Karat and clasp type are likewise absent
from the schema and therefore absent from the page.
FIXED — a real bug, not a restyle. The previous version ran, at module scope:
FIXED — a real bug, not a restyle. The previous version ran, at module scope:
history.pushState(null, location.href)
window.onpopstate = () => history.go(1)
Because ant-design-vue's Drawer mounts its slot content eagerly, this fired
@@ -25,18 +25,18 @@
drawer and the /product/:id route both already rely on.
-->
<template>
<Spinner :spinning="loading" tip="درحال دریافت اطلاعات" v-if="flag && productDetail.productRef.value">
<Spinner :spinning="loading" tip="درحال دریافت اطلاعات" v-if="flag && productDetail.productRef.value">
<article class="pd">
<!-- Gallery -->
<div class="pd__gallery">
<button type="button" class="pd__round pd__round--back" aria-label="بازگشت" @click="close">
<button type="button" class="pd__round pd__round--back" aria-label="بازگشت" @click="close">
<Icon icon="vuesax-linear:arrow-right-1" size="19" />
</button>
<button
type="button"
class="pd__round pd__round--fav"
:class="{ 'is-on': favorites.isFavorite(id) }"
aria-label="علاقه‌مندی"
aria-label="علاقه‌مندی"
@click="toggleFav"
>
<Icon :icon="favorites.isFavorite(id) ? 'vuesax-linear:heart-fill' : 'vuesax-linear:heart'" size="19" />
@@ -59,7 +59,7 @@
type="button"
class="pd__dot"
:class="{ 'is-on': i === index }"
:aria-label="`تصویر ${i + 1}`"
:aria-label="`تصویر ${i + 1}`"
@click.stop="go(i)"
/>
</div>
@@ -77,7 +77,7 @@
<h1 class="pd__title">{{ title }}</h1>
<div class="pd__sub">
<span v-if="productCode" class="pd__code">{{ productCode }}</span>
<span v-if="categoryTitle" class="pd__dotSep">·</span>
<span v-if="categoryTitle" class="pd__dotSep">·</span>
<span v-if="categoryTitle">{{ categoryTitle }}</span>
</div>
</div>
@@ -87,31 +87,31 @@
<!-- Headline figures -->
<div class="pd__figures">
<div class="pd__figure">
<span class="pd__figureLabel">وزن تقریبی</span>
<span class="pd__figureLabel">وزن تقریبی</span>
<span class="pd__figureValue">
{{ min_gram }}
<span class="pd__figureUnit">گرم</span>
<span class="pd__figureUnit">گرم</span>
</span>
</div>
<div v-if="wage != null" class="pd__figure">
<span class="pd__figureLabel">اجرت</span>
<span class="pd__figureLabel">اجرت</span>
<span class="pd__figureValue">
{{ wageLabel }}
<span class="pd__figureUnit">درصد</span>
<span class="pd__figureUnit">درصد</span>
</span>
</div>
</div>
<!--
Compact variant chips: weight/title as pills. A tiny thumb only when
the variant has its OWN photo (not the product fallback) — otherwise
the variant has its OWN photo (not the product fallback) — otherwise
every chip repeated the same large image and ate the fold.
-->
<section v-if="variants && variants.length" class="pd__block pd__block--variants">
<div class="pd__blockHead">
<h2 class="pd__blockTitle">انتخاب تنوع</h2>
<h2 class="pd__blockTitle">انتخاب تنوع</h2>
<span v-if="selectedVariant" class="pd__variantsHint">
{{ Number(selectedVariant.rashWeightGrams) }} گرم
{{ Number(selectedVariant.rashWeightGrams) }} گرم
</span>
</div>
<div class="pd__variants">
@@ -137,7 +137,7 @@
/>
<span class="pd__variantText">
<span class="pd__variantTitle">{{ item.title }}</span>
<span class="pd__variantWeight">{{ Number(item.rashWeightGrams) }} گرم</span>
<span class="pd__variantWeight">{{ Number(item.rashWeightGrams) }} گرم</span>
</span>
<span class="pd__variantStock" :class="stockChipClass(item)">
{{ stockChipShort(item) }}
@@ -150,32 +150,32 @@
class="pd__variantsMore"
@click="showAllVariants = !showAllVariants"
>
{{ showAllVariants ? 'نمایش کمتر' : `+${variants.length - VARIANT_PREVIEW_COUNT} تنوع دیگر` }}
{{ showAllVariants ? 'نمایش کمتر' : `+${variants.length - VARIANT_PREVIEW_COUNT} تنوع دیگر` }}
</button>
</section>
<!-- Specs -->
<section class="pd__block">
<h2 class="pd__blockTitle">مشخصات</h2>
<h2 class="pd__blockTitle">مشخصات</h2>
<dl class="pd__specs">
<div class="pd__spec">
<dt><Icon icon="vuesax-linear:weight" size="15" /> وزن</dt>
<dd>{{ min_gram }} گرم</dd>
<dt><Icon icon="vuesax-linear:weight" size="15" /> وزن</dt>
<dd>{{ min_gram }} گرم</dd>
</div>
<div v-if="wage != null" class="pd__spec">
<dt><Icon icon="vuesax-linear:percentage-square" size="15" /> اجرت</dt>
<dd>{{ wageLabel }}٪</dd>
<dt><Icon icon="vuesax-linear:percentage-square" size="15" /> اجرت</dt>
<dd>{{ wageLabel }}Ùª</dd>
</div>
<div v-if="selectedVariant" class="pd__spec">
<dt><Icon icon="vuesax-linear:diamonds" size="15" /> تنوع</dt>
<dt><Icon icon="vuesax-linear:diamonds" size="15" /> تنوع</dt>
<dd>{{ selectedVariant.title }}</dd>
</div>
<div v-if="categoryTitle" class="pd__spec">
<dt><Icon icon="vuesax-linear:category" size="15" /> دسته‌بندی</dt>
<dt><Icon icon="vuesax-linear:category" size="15" /> دسته‌بندی</dt>
<dd>{{ categoryTitle }}</dd>
</div>
<div v-if="brandTitle" class="pd__spec">
<dt><Icon icon="vuesax-linear:shop" size="15" /> برند</dt>
<dt><Icon icon="vuesax-linear:shop" size="15" /> برند</dt>
<dd>{{ brandTitle }}</dd>
</div>
</dl>
@@ -183,17 +183,17 @@
<!-- Description -->
<section v-if="body" class="pd__block">
<h2 class="pd__blockTitle">توضیحات</h2>
<h2 class="pd__blockTitle">توضیحات</h2>
<p class="pd__body" :class="{ 'is-clamped': !bodyOpen }">{{ body }}</p>
<button v-if="bodyIsLong" type="button" class="pd__more" @click="bodyOpen = !bodyOpen">
{{ bodyOpen ? 'بستن' : 'ادامه مطلب' }}
{{ bodyOpen ? 'بستن' : 'ادامه مطلب' }}
</button>
</section>
<!-- Trust -->
<div class="pd__trust">
<span><Icon icon="vuesax-linear:shield-tick" size="16" /> خرید امن</span>
<span><Icon icon="vuesax-linear:verify" size="16" /> اصالت کالا</span>
<span><Icon icon="vuesax-linear:shield-tick" size="16" /> خرید امن</span>
<span><Icon icon="vuesax-linear:verify" size="16" /> اصالت کالا</span>
</div>
</div>
@@ -203,7 +203,7 @@
<button
type="button"
class="pd__qtyBtn"
aria-label="افزایش"
aria-label="افزایش"
:disabled="!canIncrement"
@click="increment"
>
@@ -213,7 +213,7 @@
<button
type="button"
class="pd__qtyBtn"
:aria-label="cartQty === 1 ? 'حذف' : 'کاهش'"
:aria-label="cartQty === 1 ? 'حذف' : 'کاهش'"
@click="decrement"
>
<Icon :icon="cartQty === 1 ? 'vuesax-linear:trash' : 'vuesax-linear:minus-square'" size="16" />
@@ -227,11 +227,11 @@
:disabled="!is_exist"
@click="isUserLoggedIn() ? addToCart() : (loginNotify = true)"
>
{{ is_exist ? 'افزودن به سفارش' : 'ناموجود' }}
{{ is_exist ? 'افزودن به سفارش' : 'ناموجود' }}
<Icon v-if="is_exist" icon="vuesax-linear:arrow-left-2" size="17" />
</button>
<router-link v-else class="pd__cta pd__cta--ghost" :to="{ name: 'cart' }">
مشاهده سبد خرید
مشاهده سبد خرید
<Icon icon="vuesax-linear:arrow-left-2" size="17" />
</router-link>
</div>
@@ -243,7 +243,7 @@
<Teleport to="body">
<Transition name="pd-preview">
<div v-if="previewOpen" class="pd__preview" @click.self="closePreview">
<button type="button" class="pd__previewClose" aria-label="بستن" @click="closePreview">
<button type="button" class="pd__previewClose" aria-label="بستن" @click="closePreview">
<Icon icon="vuesax-linear:add" size="20" />
</button>
@@ -264,7 +264,7 @@
type="button"
class="pd__previewThumb"
:class="{ 'is-on': i === imageIndex }"
:aria-label="`تصویر ${i + 1}`"
:aria-label="`تصویر ${i + 1}`"
@click="imageIndex = i"
>
<img :src="s" :alt="`${title} ${i + 1}`" draggable="false" />
@@ -280,12 +280,12 @@
</template>
<template v-slot:title>
<div class="p-8 text-justify">
برای دسترسی به تمامی امکانات نرم افزار {{ brand.name }}، ابتدا گزینه ورود/ثبت نام را لمس کنید و در صورت نداشتن حساب کاربری، ثبت نام نمایید.
برای دسترسی به تمامی امکانات نرم افزار {{ brand.name }}، ابتدا گزینه ورود/ثبت نام را لمس کنید و در صورت نداشتن حساب کاربری، ثبت نام نمایید.
</div>
</template>
<template v-slot:button>
<div class="text-center font-bold" @click="router.push({ name: 'login' })">ورود/ثبت نام</div>
<div class="text-center" @click="loginNotify = false">بیخیال</div>
<div class="text-center font-bold" @click="router.push({ name: 'login' })">ورود/ثبت نام</div>
<div class="text-center" @click="loginNotify = false">بیخیال</div>
</template>
</DialogVant>
</Spinner>
@@ -309,7 +309,7 @@ import { isUserLoggedIn } from '../../utils/checkAccess'
import loginDialogImage from '../../assets/img/empty/dialogLogin.svg'
// `initial`: the card's own ProductSummary when opened from a list (see
// ProductMorph.vue) — lets the page render whole on its first frame.
// ProductMorph.vue) — lets the page render whole on its first frame.
const props = defineProps<{ id: string; initial?: ProductSummary | null }>()
const emit = defineEmits<{ (e: 'closeDrawer'): void }>()
@@ -362,7 +362,7 @@ const qty = computed(() => productAdapter.value.qty.value)
const isPreOrder = computed(() => productAdapter.value.isPreOrder.value)
const is_exist = computed(() => productAdapter.value.is_exist.value)
// ── Variant chips ───────────────────────────────────────────────
// ── Variant chips ───────────────────────────────────────────────
// Compact pills; extras behind a short toggle so weight lists don't dominate.
const VARIANT_PREVIEW_COUNT = 8
const showAllVariants = ref(false)
@@ -373,13 +373,13 @@ const visibleVariants = computed(() =>
const isOrderable = (v: ProductVariantSummary) => isVariantOrderable(v)
const stockChipLabel = (v: ProductVariantSummary) => {
if ((v.qty ?? 0) > 0) return 'موجود'
return v.isPreOrder ? 'پیش‌سفارش' : 'ناموجود'
if ((v.qty ?? 0) > 0) return 'موجود'
return v.isPreOrder ? 'پیش‌سفارش' : 'ناموجود'
}
/** One short glyph/word so the chip stays one line. */
const stockChipShort = (v: ProductVariantSummary) => {
if ((v.qty ?? 0) > 0) return 'موجود'
return v.isPreOrder ? 'پیش' : '—'
if ((v.qty ?? 0) > 0) return 'موجود'
return v.isPreOrder ? 'پیش' : '—'
}
const stockChipClass = (v: ProductVariantSummary) => {
if ((v.qty ?? 0) > 0) return 'is-in'
@@ -408,8 +408,8 @@ watch(selectedVariant, (v) => {
if (i > -1) imageIndex.value = i
})
// ── Full-screen preview ─────────────────────────────────────────
// Tapping the inline gallery (a plain tap, not a swipe — GallerySlider tells
// ── Full-screen preview ─────────────────────────────────────────
// Tapping the inline gallery (a plain tap, not a swipe — GallerySlider tells
// the two apart itself and only emits `tap` for the former) opens a
// full-screen viewer teleported to <body>, with its own GallerySlider kept in
// lockstep via the shared `imageIndex` v-model.
@@ -442,8 +442,8 @@ const wageLabel = computed(() => {
})
const stockLabel = computed(() => {
if (qty.value > 0) return `${qty.value} عدد موجود`
return isPreOrder.value ? 'پیش‌سفارش' : 'ناموجود'
if (qty.value > 0) return `${qty.value} عدد موجود`
return isPreOrder.value ? 'پیش‌سفارش' : 'ناموجود'
})
const stockClass = computed(() => {
if (qty.value > 0) return 'is-in'
@@ -465,7 +465,7 @@ const cartIndex = computed(() => cart.find_index(storeProduct.value))
const cartQty = computed(() => (cartIndex.value > -1 ? cart.items[cartIndex.value].qty : 0))
// Stock ceiling: a pre-order variant may exceed what is on hand, anything else
// stops at its qty. The store clamps too — this just keeps the button honest.
// stops at its qty. The store clamps too — this just keeps the button honest.
const cartMaxQty = computed(() => cart.maxQty(storeProduct.value))
const canIncrement = computed(() => cartMaxQty.value === null || cartQty.value < cartMaxQty.value)
const increment = () => {
@@ -485,7 +485,7 @@ const addToCart = () => {
// Previously the "added to cart" toast fired either way, telling the customer
// something had been added when nothing had.
if (!cart.addToCart(storeProduct.value)) return
toast.success('محصول با موفقیت به سبد خرید افزوده شد', { title: 'ثبت سفارش' })
toast.success('محصول با موفقیت به سبد خرید افزوده شد', { title: 'ثبت سفارش' })
}
const toggleFav = (e: Event) => {
@@ -497,8 +497,8 @@ onBeforeMount(async () => {
await productDetail.load()
})
// If the preview is left open when this instance goes away — the drawer is
// closed, or the routed page is navigated away from — the body scroll lock it
// If the preview is left open when this instance goes away — the drawer is
// closed, or the routed page is navigated away from — the body scroll lock it
// set would otherwise outlive it and leave the rest of the app unscrollable.
onBeforeUnmount(() => {
if (lockedBody) document.body.style.overflow = ''
@@ -515,12 +515,12 @@ onBeforeUnmount(() => {
min-height: 100vh;
min-height: 100dvh;
background: @canvas;
/* Room for the fixed action bar — keep in sync with .pd__bar height. */
/* Room for the fixed action bar — keep in sync with .pd__bar height. */
padding-bottom: 104px;
box-sizing: border-box;
}
/* ── Gallery ─────────────────────────────────────────────── */
/* ── Gallery ─────────────────────────────────────────────── */
.pd__gallery {
position: relative;
z-index: 0;
@@ -614,7 +614,7 @@ onBeforeUnmount(() => {
background: #fff;
}
/* ── Sheet ───────────────────────────────────────────────── */
/* ── Sheet ───────────────────────────────────────────────── */
.pd__sheet {
position: relative;
z-index: 1;
@@ -681,7 +681,7 @@ onBeforeUnmount(() => {
background: @hairlineSoft;
}
/* ── Figures ─────────────────────────────────────────────── */
/* ── Figures ─────────────────────────────────────────────── */
.pd__figures {
display: flex;
gap: 10px;
@@ -714,7 +714,7 @@ onBeforeUnmount(() => {
padding-inline-start: 3px;
}
/* ── Blocks ──────────────────────────────────────────────── */
/* ── Blocks ──────────────────────────────────────────────── */
.pd__block {
margin-top: 22px;
}
@@ -741,7 +741,7 @@ onBeforeUnmount(() => {
white-space: nowrap;
}
/* Compact wrapping pills — weight lists stay on-screen without card tiles. */
/* Compact wrapping pills — weight lists stay on-screen without card tiles. */
.pd__variants {
display: flex;
flex-wrap: wrap;
@@ -916,17 +916,16 @@ onBeforeUnmount(() => {
color: @inkFaint;
}
/* ── Action bar ──────────────────────────────────────────── */
/* ── Action bar ──────────────────────────────────────────── */
.pd__bar {
position: fixed;
bottom: 0;
inset-inline: 0;
left: max(0px, calc(50vw - (var(--app-max-width) / 2)));
right: max(0px, calc(50vw - (var(--app-max-width) / 2)));
z-index: 20;
display: flex;
align-items: center;
gap: 10px;
max-width: 768px;
margin: 0 auto;
padding: 12px 16px calc(12px + env(safe-area-inset-bottom, 0px));
background: @canvas;
border-top: 1px solid @hairline;
@@ -992,8 +991,8 @@ onBeforeUnmount(() => {
border: 1px solid @primary;
}
/* ── Full-screen preview ─────────────────────────────────── */
/* Teleported to <body>, so it sits outside .pd entirely — see the template
/* ── Full-screen preview ─────────────────────────────────── */
/* Teleported to <body>, so it sits outside .pd entirely — see the template
comment on why. z-index cleared well above ant-design-vue's own Drawer
layer (@zindex-modal) so the preview is unconditionally topmost regardless
of which Teleport happens to mount last. */
@@ -157,7 +157,11 @@ const ringFillStyle = computed(() => {
.app-ptr__overlay {
position: fixed;
inset: 0;
// Stay in the phone column on desktop (same as #app / .app-frame-x).
top: 0;
bottom: 0;
left: max(0px, calc(50vw - (var(--app-max-width) / 2)));
right: max(0px, calc(50vw - (var(--app-max-width) / 2)));
z-index: 1900;
display: grid;
place-items: center;
+13 -2
View File
@@ -1,6 +1,7 @@
<!-- Ported from legacy henza_pwa/src/components/ui/container.vue -->
<!-- Ported from legacy henza_pwa/src/components/ui/container.vue.
Width follows the phone column (#app / --app-max-width), not screen-md. -->
<template>
<main class="px-[16px] max-w-screen-md m-auto relative no-scrollbar">
<main class="app-container relative no-scrollbar">
<slot />
</main>
</template>
@@ -11,3 +12,13 @@ interface Props {
}
withDefaults(defineProps<Props>(), {})
</script>
<style scoped>
.app-container {
width: 100%;
max-width: var(--app-max-width);
margin-inline: auto;
padding-inline: 16px;
position: relative;
}
</style>
@@ -9,7 +9,7 @@
so it needs no measurement of the container width.
-->
<template>
<nav class="tabbar" v-show="nav.showNav">
<nav class="tabbar app-frame-x" v-show="nav.showNav">
<div class="tabbar__inner" :style="{ '--count': nav.visibleNavBarRoutes.length }">
<div
class="tabbar__indicator"
@@ -47,8 +47,8 @@ const indicatorOffset = computed(() => -(nav.activeLink - 1) * 100)
<style scoped lang="less">
.tabbar {
position: fixed;
left: 0;
right: 0;
// left/right from global .app-frame-x — stays viewport-fixed vertically,
// phone-column width on desktop.
bottom: 0;
z-index: 10;
padding: 10px 14px calc(10px + env(safe-area-inset-bottom, 0px));
+2 -3
View File
@@ -8,7 +8,7 @@
the bar's height, blur, hairline and icon buttons stay consistent app-wide.
-->
<template>
<header class="bar">
<header class="bar app-frame-x">
<div class="bar__inner">
<button v-if="back" type="button" class="bar__btn" aria-label="بازگشت" @click="onBack">
<Icon icon="vuesax-linear:arrow-right-1" size="20" />
@@ -80,8 +80,7 @@ function onBack() {
.bar {
position: fixed;
top: var(--aab-h, 0px);
left: 0;
right: 0;
// left/right from global .app-frame-x
z-index: 20;
background: alpha(@canvas, 88%);
backdrop-filter: blur(14px);
@@ -10,7 +10,7 @@
-->
<template>
<main
class="flex flex-col flex-1 overflow-auto no-scrollbar w-[100vw] max-w-screen-md m-auto"
class="app-scroll-fetch flex flex-col flex-1 overflow-auto no-scrollbar"
:style="{ paddingTop: padding || transition.headerHeight + 'px' }"
ref="scrollArea"
>
@@ -48,3 +48,11 @@ useInfiniteScroll(
{ distance: 50 },
)
</script>
<style scoped>
.app-scroll-fetch {
width: 100%;
max-width: var(--app-max-width);
margin-inline: auto;
}
</style>
@@ -24,8 +24,9 @@ const { toasts, dismiss } = useToast()
.toast-host {
position: fixed;
top: var(--aab-h, 0px);
left: 0;
right: 0;
left: max(0px, calc(50vw - (var(--app-max-width) / 2)));
right: max(0px, calc(50vw - (var(--app-max-width) / 2)));
width: auto;
z-index: 3000;
display: flex;
flex-direction: column;
@@ -23,7 +23,8 @@ export function orderItemLookup(item: OrderItemDetail): VariantLookup | undefine
return {
productTitle: variant.product.title,
variantTitle: variant.title,
imageUrl: variant.product.images[0]?.url,
// Same preference as the product page: variant photo, else product cover.
imageUrl: variant.image?.url ?? variant.product.images[0]?.url,
}
}
+1 -1
View File
@@ -21,7 +21,7 @@
</transition>
</router-view>
<Navigation />
<div class="bg-primary top-[-2rem] fixed h-[2rem] w-full z-10" />
<div class="bg-primary top-[-2rem] fixed h-[2rem] z-10 app-frame-x" />
</section>
</template>
+2
View File
@@ -63,3 +63,5 @@ app.use(VueLazyLoad, {
});
app.component('container', Container);
app.mount('#app');
// Boot splash (index.html) matched the phone column; drop it once the app paints.
document.getElementById('app-splash')?.remove();
+6 -1
View File
@@ -87,7 +87,12 @@ export const router = createRouter({
component: () => import('../views/report/ReportView.vue'),
meta: { title: 'جزئیات حساب', layout: 'FullPage', requiresAuth: true },
},
{ path: '/notifications', name: 'notifications', component: () => import('../views/user/NotificationsView.vue') },
{
path: '/notifications',
name: 'notifications',
component: () => import('../views/user/NotificationsView.vue'),
meta: { title: 'پیام‌ها' },
},
{ path: '/search', name: 'search', component: () => import('../views/search/SearchView.vue') },
{ path: '/collection/:id?', name: 'collection', component: () => import('../views/search/CollectionView.vue') },
// legacy redirects any unmatched path (including '/') to the home route by name.
+8
View File
@@ -145,6 +145,14 @@ export const useCartStore = defineStore('cart', {
}, 0)
.toFixed(2)
},
/** Σ variant.rial_pay × qty — the flat Rial making charge preview. */
totalRial() {
return this.items.reduce((sum, item) => {
const v = this.variant(item)
const rial = v?.rial_pay != null ? Number(v.rial_pay) : 0
return sum + (Number.isFinite(rial) ? rial * item.qty : 0)
}, 0)
},
clear() {
this.items = []
persist(this.items)
@@ -0,0 +1,72 @@
import { defineStore } from 'pinia'
import { api } from '../api/client'
import { useSessionStore } from './session'
/** localStorage: ISO time of last visit to the notifications screen. */
const SEEN_KEY = 'gold-bank:notificationsSeenAt'
export const useNotificationsStore = defineStore('notifications', {
state: () => ({
/** Unread admin messages from the server. */
adminUnread: 0,
/** Fresh order-status events since last visit (client-side). */
statusFresh: 0,
loading: false,
}),
getters: {
badgeTotal(): number {
return this.adminUnread + this.statusFresh
},
},
actions: {
seenAt(): string | null {
try {
return localStorage.getItem(SEEN_KEY)
} catch {
return null
}
},
markSeenNow() {
try {
localStorage.setItem(SEEN_KEY, new Date().toISOString())
} catch {
/* ignore quota */
}
this.statusFresh = 0
},
async refreshBadge() {
const session = useSessionStore()
if (!session.isLoggedIn) {
this.adminUnread = 0
this.statusFresh = 0
return
}
this.loading = true
try {
const [unread, ordersPage] = await Promise.all([
api.getMyUnreadNotificationCount(),
api.getMyOrders({ page: 1, limit: 20 }),
])
this.adminUnread = unread.count ?? 0
// First visit: do not badge the whole order history — only changes after a prior open.
const since = this.seenAt()
if (!since) {
this.statusFresh = 0
} else {
const sinceMs = Date.parse(since)
let fresh = 0
for (const order of ordersPage.result ?? []) {
for (const ev of order.statusEvents ?? []) {
if (Date.parse(ev.createdAt) > sinceMs) fresh += 1
}
}
this.statusFresh = fresh
}
} catch (e) {
console.log(e)
} finally {
this.loading = false
}
},
},
})
+2 -2
View File
@@ -21,10 +21,10 @@ export function jdate(val: string | undefined | null): string {
return val as any
}
/** Compact Jalali stamp for the order-detail timeline (day + month name + time). */
/** Timeline stamp: date then time, LTR — e.g. 1404/08/16 - 17:22 */
export function jtimelineWhen(val: string | undefined | null): string {
if (val) {
return moment(val).format('jD jMMMM، HH:mm')
return moment(val).format('jYYYY/jMM/jDD - HH:mm')
}
return val as any
}
@@ -0,0 +1,33 @@
/**
* Second-person copy for order-status updates in the inbox.
* Short, clear, no internal jargon — what the customer needs to know next.
*/
export const ORDER_STATUS_NOTIFY_TITLE: Record<string, string> = {
SUSPENDED: 'سفارش معلق شد',
PLACED: 'سفارش ثبت شد',
ADMIN_APPROVED: 'سفارش تأیید شد',
SOURCING: 'تأمین کالا شروع شد',
PREPARING: 'آماده‌سازی سفارش',
DELIVERED_TO_AGENT: 'تحویل به نماینده',
DELIVERED_TO_CUSTOMER: 'سفارش تحویل شد',
CANCELLED: 'سفارش لغو شد',
}
export const ORDER_STATUS_NOTIFY_BODY: Record<string, string> = {
SUSPENDED: 'به‌دلیل سقف اعتبار، سفارش شما موقتاً متوقف شده است. با پشتیبانی هماهنگ کنید.',
PLACED: 'سفارش شما دریافت شد و در صف بررسی قرار گرفت.',
ADMIN_APPROVED: 'سفارش شما تأیید شد و وارد مراحل بعدی می‌شود.',
SOURCING: 'کالای سفارش شما در حال تأمین است.',
PREPARING: 'سفارش شما در حال آماده‌سازی برای ارسال است.',
DELIVERED_TO_AGENT: 'سفارش به نماینده فروش تحویل داده شد.',
DELIVERED_TO_CUSTOMER: 'سفارش به شما تحویل داده شد. از خریدتان سپاسگزاریم.',
CANCELLED: 'این سفارش لغو شده است. اگر پرسشی دارید با پشتیبانی در تماس باشید.',
}
export function orderNotifyTitle(status: string) {
return ORDER_STATUS_NOTIFY_TITLE[status] ?? 'به‌روزرسانی سفارش'
}
export function orderNotifyBody(status: string) {
return ORDER_STATUS_NOTIFY_BODY[status] ?? 'وضعیت سفارش شما تغییر کرده است.'
}
+27 -28
View File
@@ -2,8 +2,8 @@
Ported from legacy henza_pwa/src/views/cart.vue, restyled into the
redesigned design language (see components/home/* and HomeView).
EVERY REAL BEHAVIOUR IS CARRIED OVER UNCHANGED — only the chrome changes:
- "ثبت نهایی سفارش" still posts to the real POST /orders endpoint. Legacy's
EVERY REAL BEHAVIOUR IS CARRIED OVER UNCHANGED — only the chrome changes:
- "ثبت نهایی سفارش" still posts to the real POST /orders endpoint. Legacy's
own `/set-order` payload had two hardcoded placeholder numbers with the
original author's comment "how to get?!"; the real Orders API computes
gold weight server-side from productVariantId + wagePercent, so
@@ -19,7 +19,7 @@
computed at order time. The honest total the cart can show is total weight,
which is what it shows.
DROPPED: the old unscoped `.rtl { direction: ltr }` rule, which was dead —
DROPPED: the old unscoped `.rtl { direction: ltr }` rule, which was dead —
the global `.rtl` in style.less carries !important, so a non-!important rule
could never override it. It was previously kept "for fidelity" while the
file was a faithful port; now that the file is deliberately redesigned,
@@ -27,9 +27,9 @@
-->
<template>
<div class="cart">
<PageBar title="سبد خرید" back-to="home" />
<PageBar title="سبد خرید" back-to="home" />
<Spinner :spinning="cartData.loadingPage" tip="درحال دریافت اطلاعات">
<Spinner :spinning="cartData.loadingPage" tip="درحال دریافت اطلاعات">
<container class="cart__body">
<!-- Step 1: the basket -->
<AppPullRefresh v-if="cartData.step === 1" v-model="refreshing" @refresh="onRefresh">
@@ -53,9 +53,9 @@
<section v-else class="cart__empty">
<Icon icon="vuesax-linear:bag-2" size="26" />
<p>سبد خرید شما خالی است.</p>
<p>سبد خرید شما خالی است.</p>
<button type="button" class="cart__emptyCta" @click="router.push({ name: 'home' })">
دیدن محصولات
دیدن محصولات
</button>
</section>
</AppPullRefresh>
@@ -65,32 +65,32 @@
<div class="cart__doneIcon" aria-hidden="true">
<Icon icon="vuesax-linear:tick-circle" size="30" />
</div>
<h2 class="cart__doneTitle">سفارش شما ثبت شد</h2>
<h2 class="cart__doneTitle">سفارش شما ثبت شد</h2>
<p class="cart__doneSub">
شماره سفارش
شماره سفارش
<b>{{ cartData.orderID }}</b>
</p>
<p class="cart__doneHint">برای پیگیری وضعیت، به فهرست سفارش‌ها مراجعه کنید.</p>
<button type="button" class="cart__doneCta" @click="goOrders">فهرست سفارش‌ها</button>
<p class="cart__doneHint">برای پیگیری وضعیت، به فهرست سفارش‌ها مراجعه کنید.</p>
<button type="button" class="cart__doneCta" @click="goOrders">فهرست سفارش‌ها</button>
</section>
</container>
</Spinner>
<!-- Sticky summary + submit -->
<div v-if="cart.items.length > 0 && cartData.step === 1" class="cart__bar">
<div v-if="cart.items.length > 0 && cartData.step === 1" class="cart__bar app-frame-x">
<div class="cart__barInner">
<div class="cart__total">
<span class="cart__totalLabel">جمع وزن</span>
<span class="cart__totalLabel">جمع وزن</span>
<span class="cart__totalValue">
{{ cart.total() }}
<i>گرم</i>
<i>گرم</i>
</span>
</div>
<button type="button" class="cart__submit" @click="cartData.notify = true">ثبت نهایی سفارش</button>
<button type="button" class="cart__submit" @click="cartData.notify = true">ثبت نهایی سفارش</button>
</div>
</div>
<!-- Per-item note — floating island, same language as the tab bar -->
<!-- Per-item note — floating island, same language as the tab bar -->
<Drawer
v-model:visible="cartData.showPopUp"
placement="bottom"
@@ -104,17 +104,17 @@
>
<div class="sheet">
<div class="sheet__handle" aria-hidden="true" />
<h3 class="sheet__title">توضیح برای {{ cartData.tempRow.product.title }}</h3>
<h3 class="sheet__title">توضیح برای {{ cartData.tempRow.product.title }}</h3>
<Form :model="formModel" name="noteForm" layout="vertical" autocomplete="off" @finish="setNote">
<FormItem has-feedback name="note" class="sheet__field">
<Textarea
v-model:value="formModel.note"
:rows="3"
placeholder="مثلاً سایز یا جزئیات سفارش"
placeholder="مثلاً سایز یا جزئیات سفارش"
class="sheet__input"
/>
</FormItem>
<button type="submit" class="sheet__cta">ثبت توضیحات</button>
<button type="submit" class="sheet__cta">ثبت توضیحات</button>
</Form>
</div>
</Drawer>
@@ -123,10 +123,10 @@
<template v-slot:img>
<img :src="dialogEmpty4" alt="" class="opacity-70" />
</template>
<template v-slot:title>آیا مطمئن به ثبت سفارش هستید ؟</template>
<template v-slot:title>آیا مطمئن به ثبت سفارش هستید ؟</template>
<template v-slot:button>
<div class="text-center text-[color:var(--c-primary)]" @click="submitOrder">بله</div>
<div class="text-center text-[color:var(--c-inkMuted)]" @click="cartData.notify = false">بی خیال</div>
<div class="text-center text-[color:var(--c-primary)]" @click="submitOrder">بله</div>
<div class="text-center text-[color:var(--c-inkMuted)]" @click="cartData.notify = false">بی خیال</div>
</template>
</DialogVant>
@@ -135,11 +135,11 @@
<img :src="loginDialogImage" alt="" />
</template>
<template v-slot:title>
<div class="p-8 text-justify">برای ثبت سفارش ابتدا باید وارد حساب کاربری خود شوید.</div>
<div class="p-8 text-justify">برای ثبت سفارش ابتدا باید وارد حساب کاربری خود شوید.</div>
</template>
<template v-slot:button>
<div class="text-center text-[color:var(--c-primary)]" @click="router.push({ name: 'login' })">ورود/ثبت نام</div>
<div class="text-center text-danger" @click="loginNotify = false">بیخیال</div>
<div class="text-center text-[color:var(--c-primary)]" @click="router.push({ name: 'login' })">ورود/ثبت نام</div>
<div class="text-center text-danger" @click="loginNotify = false">بیخیال</div>
</template>
</DialogVant>
</div>
@@ -355,8 +355,7 @@ onMounted(async () => {
.cart__bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
// left/right from global .app-frame-x
z-index: 5;
background: alpha(@canvas, 92%);
backdrop-filter: blur(14px);
@@ -464,7 +463,7 @@ onMounted(async () => {
}
</style>
<!-- Drawer teleports to body — island chrome must live outside scoped CSS. -->
<!-- Drawer teleports to body — island chrome must live outside scoped CSS. -->
<style lang="less">
.cart-note-island.ant-drawer-bottom .ant-drawer-content-wrapper {
left: 14px !important;
+27 -8
View File
@@ -1,8 +1,8 @@
<!--
Redesigned home screen (replaces the previous port of legacy home/index.vue).
Layout intent — shop first, account second:
1. a slim brand bar with search + cart
Layout intent — shop first, account second:
1. a slim brand bar with notifications + cart (search stays on the tab bar)
2. the live 18k rate strip (real: GET /pricing/latest)
3. spring card carousel hero (admin slider images)
4. the category rail (real: derived from GET /catalog/products)
@@ -19,7 +19,7 @@
-->
<template>
<div class="home">
<header class="home__bar">
<header class="home__bar app-frame-x">
<div class="home__barInner">
<router-link :to="{ name: 'cart' }" class="home__iconBtn" aria-label="سبد خرید">
<Icon icon="vuesax-linear:bag-2" size="21" />
@@ -28,8 +28,9 @@
<img src="/img/brand-logo.png" :alt="brand.name" class="home__logo" />
<router-link :to="{ name: 'search' }" class="home__iconBtn" aria-label="جستجو">
<Icon icon="vuesax-linear:search-normal-1" size="21" />
<router-link :to="{ name: 'notifications' }" class="home__iconBtn" aria-label="پیام‌ها">
<Icon icon="vuesax-linear:notification" size="21" />
<span v-if="notifBadge > 0" class="home__badge">{{ notifBadge > 9 ? '۹+' : notifBadge }}</span>
</router-link>
</div>
</header>
@@ -105,7 +106,7 @@
<script setup lang="ts">
import { brand } from '../brand'
import { computed, onActivated, onMounted, ref } from 'vue'
import { computed, onActivated, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import AppPullRefresh from '../components/ui/AppPullRefresh.vue'
import Icon from '../components/icon/Icon.vue'
@@ -120,6 +121,8 @@ import { useConfigStore } from '../stores/configStore'
import type { HomeCategory } from '../stores/configStore'
import { useCartStore } from '../stores/cartStore'
import { useFiltersStore } from '../stores/filtersStore'
import { useSessionStore } from '../stores/session'
import { useNotificationsStore } from '../stores/notificationsStore'
import { useReveal } from '../composables/useReveal'
import type { AdminSlide, GoldPriceQuote, ProductSummary } from '../api/client'
import { useCachedReads } from '../api/cachedReads'
@@ -131,8 +134,12 @@ const memory = useConfigStore()
const cart = useCartStore()
const filters = useFiltersStore()
const catalog = useCatalogStore()
const session = useSessionStore()
const notifications = useNotificationsStore()
const reads = useCachedReads()
const notifBadge = computed(() => notifications.badgeTotal)
const loading = ref(!catalog.loaded)
const refreshing = ref(false)
const products = computed(() => catalog.products)
@@ -254,8 +261,20 @@ onMounted(async () => {
await loadData()
loading.value = false
reveal()
if (session.isLoggedIn) void notifications.refreshBadge()
})
watch(
() => session.isLoggedIn,
(ok) => {
if (ok) void notifications.refreshBadge()
else {
notifications.adminUnread = 0
notifications.statusFresh = 0
}
},
)
/**
* Owner ruling 2026-10-12: re-read on entering a screen rather than caching
* with a lifetime. This view is kept alive, so without this the gold price was
@@ -280,6 +299,7 @@ onActivated(async () => {
// Keep the figure already on screen rather than blanking it.
console.log(e)
}
if (session.isLoggedIn) void notifications.refreshBadge()
})
</script>
@@ -292,8 +312,7 @@ onActivated(async () => {
.home__bar {
position: fixed;
top: var(--aab-h, 0px);
left: 0;
right: 0;
// left/right from global .app-frame-x
z-index: 20;
background: alpha(@canvas, 88%);
backdrop-filter: blur(14px);
@@ -31,7 +31,7 @@
-->
<template>
<div class="dash">
<header class="dash__bar">
<header class="dash__bar app-frame-x">
<div class="dash__barInner">
<span class="dash__barSpacer" aria-hidden="true" />
<h1 class="dash__barTitle">میز کار</h1>
@@ -400,8 +400,7 @@ onMounted(async () => {
.dash__bar {
position: fixed;
top: var(--aab-h, 0px);
left: 0;
right: 0;
// left/right from global .app-frame-x
z-index: 20;
background: alpha(@canvas, 88%);
backdrop-filter: blur(14px);
@@ -1,25 +1,267 @@
<!--
New nav destination (bottom nav redesign) — no legacy equivalent and no
notifications backend exists in apps/api. Renders the same dashed
empty-state pattern used elsewhere (see FavoritesView) rather than
fabricating fake notification data.
Inbox for support messages and account updates (order status is one type among others).
Opened from the home bar bell (search stays on the bottom tab).
-->
<template>
<div class="notif">
<PageBar title="اعلان‌ها" back-to="home" />
<PageBar title="پیام‌ها" back-to="home" />
<container class="notif__body">
<section class="notif__empty">
<Icon icon="vuesax-linear:notification" size="26" />
<p>اعلانی برای نمایش وجود ندارد.</p>
</section>
</container>
<AppPullRefresh v-model="refreshing" @refresh="onRefresh">
<Spinner :spinning="loading && !items.length" tip="در حال دریافت پیام‌ها">
<container class="notif__body" :class="{ 'is-revealed': revealed }">
<!-- Guest -->
<section v-if="!session.isLoggedIn" class="notif__empty reveal" :style="revealDelay(0)">
<div class="notif__emptyIcon" aria-hidden="true">
<Icon icon="vuesax-linear:notification" size="28" />
</div>
<h2 class="notif__emptyTitle">پیام‌های شما اینجاست</h2>
<p class="notif__emptyText">
پس از ورود، پیام‌های پشتیبانی و به‌روزرسانی‌های حساب را اینجا می‌بینید.
</p>
<button type="button" class="notif__cta" @click="goLogin">ورود به حساب</button>
</section>
<!-- Error -->
<section v-else-if="failed && !items.length" class="notif__empty reveal" :style="revealDelay(0)">
<div class="notif__emptyIcon" aria-hidden="true">
<Icon icon="vuesax-linear:danger" size="28" />
</div>
<h2 class="notif__emptyTitle">دریافت پیام‌ها ممکن نشد</h2>
<p class="notif__emptyText">اتصال را بررسی کنید و دوباره تلاش کنید.</p>
<button type="button" class="notif__cta notif__cta--ghost" @click="reload">تلاش دوباره</button>
</section>
<!-- Empty -->
<section v-else-if="!loading && !items.length" class="notif__empty reveal" :style="revealDelay(0)">
<div class="notif__emptyIcon" aria-hidden="true">
<Icon icon="vuesax-linear:notification-bing" size="28" />
</div>
<h2 class="notif__emptyTitle">فعلاً پیامی نیست</h2>
<p class="notif__emptyText">
وقتی پیامی از پشتیبانی یا به‌روزرسانی جدیدی برسد، اینجا نمایش داده می‌شود.
</p>
</section>
<!-- Feed -->
<template v-else>
<p class="notif__hint reveal" :style="revealDelay(0)">
جدیدترین پیام‌ها
</p>
<ul class="notif__list">
<li
v-for="(item, i) in items"
:key="item.key"
class="notif__item reveal"
:class="{ 'is-unread': item.unread }"
:data-kind="item.kind"
:style="revealDelay(Math.min(i + 1, 8))"
@click="onOpen(item)"
>
<span
class="notif__icon"
:style="{ color: item.color.font, background: item.color.background }"
aria-hidden="true"
>
<Icon :icon="item.icon" size="18" />
</span>
<div class="notif__main">
<div class="notif__top">
<h3 class="notif__title">{{ item.title }}</h3>
<time class="notif__when" dir="ltr" :datetime="item.createdAt">{{ jtimelineWhen(item.createdAt) }}</time>
</div>
<p class="notif__body">{{ item.body }}</p>
<span v-if="item.meta" class="notif__meta">{{ item.meta }}</span>
</div>
<span v-if="item.unread" class="notif__dot" aria-label="خوانده‌نشده" />
</li>
</ul>
</template>
</container>
</Spinner>
</AppPullRefresh>
</div>
</template>
<script setup lang="ts">
import { computed, 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 { useReveal } from '../../composables/useReveal'
import { useSessionStore } from '../../stores/session'
import { useNotificationsStore } from '../../stores/notificationsStore'
import { api, type CustomerNotification, type OrderDetail } from '../../api/client'
import { orderStatusColor, orderStatusIcon } from '../../utils/orderStatus'
import { orderNotifyBody, orderNotifyTitle } from '../../utils/notificationCopy'
import { jtimelineWhen } from '../../utils/filters'
type FeedKind = 'message' | 'status'
interface FeedItem {
key: string
kind: FeedKind
title: string
body: string
meta?: string
createdAt: string
unread: boolean
icon: string
color: { font: string; background: string }
/** Admin recipient id, or order id for status rows. */
targetId: string
orderNumber?: string
}
const router = useRouter()
const session = useSessionStore()
const notifStore = useNotificationsStore()
const { revealed, revealDelay, reveal } = useReveal('notifications')
const loading = ref(false)
const failed = ref(false)
const refreshing = ref(false)
const messages = ref<CustomerNotification[]>([])
const orders = ref<OrderDetail[]>([])
/** Cutoff frozen at first paint of this visit — so marking “seen” does not strip unread chrome mid-view. */
const statusUnreadBefore = ref<number | null>(null)
const MESSAGE_COLOR = { font: 'var(--c-wine, #7A1F2B)', background: 'var(--c-wineWash, #7A1F2B14)' }
const items = computed<FeedItem[]>(() => {
const seenMs = statusUnreadBefore.value ?? 0
const feed: FeedItem[] = []
for (const m of messages.value) {
feed.push({
key: `m-${m.id}`,
kind: 'message',
title: m.title,
body: m.body,
meta: 'پیام پشتیبانی',
createdAt: m.createdAt,
unread: !m.isRead,
icon: 'vuesax-linear:message-text',
color: MESSAGE_COLOR,
targetId: m.id,
})
}
for (const order of orders.value) {
if (order.revisionStatus === 'AWAITING_CUSTOMER' && order.pendingRevision) {
const createdAt = order.pendingRevision.createdAt
const rejected = order.pendingRevision.customerDecision === 'REJECTED'
feed.push({
key: `r-${order.pendingRevision.id}`,
kind: 'status',
title: rejected ? 'ویرایش ردشده — در انتظار پشتیبانی' : 'ویرایش سفارش منتظر تأیید شماست',
body: rejected
? 'رد شما ثبت شد. تا تصمیم پشتیبانی، پیشنهاد ویرایش باز می‌ماند.'
: 'ادمین این سفارش را ویرایش کرده است. می‌توانید تأیید یا رد کنید.',
meta: `سفارش ${order.orderNumber}`,
createdAt,
unread: !rejected && Date.parse(createdAt) > seenMs,
icon: 'vuesax-linear:edit-2',
color: MESSAGE_COLOR,
targetId: order.id,
orderNumber: order.orderNumber,
})
}
for (const ev of order.statusEvents ?? []) {
const status = ev.toStatus
feed.push({
key: `s-${ev.id}`,
kind: 'status',
title: orderNotifyTitle(status),
body: orderNotifyBody(status),
meta: `سفارش ${order.orderNumber}`,
createdAt: ev.createdAt,
unread: Date.parse(ev.createdAt) > seenMs,
icon: orderStatusIcon(status),
color: orderStatusColor(status),
targetId: order.id,
orderNumber: order.orderNumber,
})
}
}
feed.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt))
return feed
})
async function load() {
if (!session.isLoggedIn) {
messages.value = []
orders.value = []
failed.value = false
return
}
if (statusUnreadBefore.value === null) {
const seen = notifStore.seenAt()
// No prior visit → treat nothing as "new" so history is not all highlighted.
statusUnreadBefore.value = seen ? Date.parse(seen) : Date.now()
}
loading.value = true
failed.value = false
try {
const [msgs, page] = await Promise.all([
api.getMyNotifications(),
api.getMyOrders({ page: 1, limit: 30 }),
])
messages.value = msgs
orders.value = page.result ?? []
} catch (e) {
console.log(e)
failed.value = true
} finally {
loading.value = false
}
}
async function afterVisit() {
if (!session.isLoggedIn) return
try {
await api.markAllNotificationsRead()
} catch (e) {
console.log(e)
}
notifStore.markSeenNow()
notifStore.adminUnread = 0
// Recompute status freshness as zero after markSeenNow
messages.value = messages.value.map((m) => ({ ...m, isRead: true, readAt: m.readAt ?? new Date().toISOString() }))
}
async function reload() {
await load()
await afterVisit()
reveal()
}
async function onRefresh() {
await reload()
refreshing.value = false
}
function goLogin() {
router.push({ name: 'login', query: { redirect: '/notifications' } })
}
function onOpen(item: FeedItem) {
if (item.kind !== 'message' || !item.unread) return
api.markNotificationRead(item.targetId).catch((e) => console.log(e))
const row = messages.value.find((m) => m.id === item.targetId)
if (row) {
row.isRead = true
row.readAt = new Date().toISOString()
}
}
onMounted(async () => {
await load()
await afterVisit()
reveal()
})
</script>
<style scoped lang="less">
@@ -30,22 +272,162 @@ import PageBar from '../../components/ui/PageBar.vue'
.notif__body {
padding-top: 76px;
padding-bottom: 90px;
padding-bottom: 100px;
}
.notif__hint {
margin: 0 0 12px;
font-size: 12px;
color: @inkFaint;
}
.notif__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.notif__item {
display: flex;
gap: 12px;
align-items: flex-start;
padding: 14px 14px 13px;
border-radius: 18px;
background: @canvas;
border: 1px solid @hairline;
transition: border-color 0.2s ease, background 0.2s ease;
}
.notif__item[data-kind='message'] {
cursor: pointer;
}
.notif__item.is-unread {
border-color: color-mix(in srgb, @wine 22%, @hairline);
background: color-mix(in srgb, @wineWash 55%, @canvas);
}
.notif__icon {
flex: 0 0 40px;
width: 40px;
height: 40px;
border-radius: 14px;
display: grid;
place-items: center;
}
.notif__main {
flex: 1;
min-width: 0;
}
.notif__top {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
}
.notif__title {
margin: 0;
font-size: 13px;
font-weight: 800;
color: @ink;
line-height: 1.5;
}
.notif__when {
flex: 0 0 auto;
font-size: 10.5px;
color: @inkFaint;
white-space: nowrap;
}
.notif__body {
margin: 4px 0 0;
font-size: 12px;
line-height: 1.75;
color: @inkMuted;
}
.notif__meta {
display: inline-block;
margin-top: 7px;
font-size: 11px;
font-weight: 700;
color: @inkSoft;
}
.notif__dot {
flex: 0 0 8px;
width: 8px;
height: 8px;
margin-top: 6px;
border-radius: 50%;
background: @wine;
}
.notif__empty {
margin-top: 8px;
padding: 38px 20px;
border: 1px dashed @hairline;
border-radius: 18px;
margin-top: 18px;
padding: 42px 22px 36px;
border-radius: 22px;
border: 1px solid @hairline;
background: @canvas;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
color: @inkFaint;
text-align: center;
gap: 8px;
}
.notif__empty p {
.notif__emptyIcon {
width: 56px;
height: 56px;
border-radius: 50%;
display: grid;
place-items: center;
color: @wine;
background: @wineWash;
margin-bottom: 6px;
}
.notif__emptyTitle {
margin: 0;
font-size: 15px;
font-weight: 800;
color: @ink;
}
.notif__emptyText {
margin: 0;
max-width: 280px;
font-size: 12.5px;
line-height: 1.85;
color: @inkMuted;
}
.notif__cta {
margin-top: 14px;
padding: 11px 22px;
border: 0;
cursor: pointer;
border-radius: 999px;
font-family: inherit;
font-size: 12.5px;
font-weight: 700;
color: #fff;
background: @primary;
transition: transform 0.15s ease;
}
.notif__cta:active {
transform: scale(0.98);
}
.notif__cta--ghost {
color: @wine;
background: @wineWash;
}
</style>