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:
@@ -34,8 +34,23 @@
|
||||
</style>
|
||||
<title>%BRAND_TITLE%</title>
|
||||
</head>
|
||||
<body style="background-color: #fff">
|
||||
<div class="w-screen h-screen fixed" style="background-color: var(--c-page, #fff)"></div>
|
||||
<body>
|
||||
<!--
|
||||
Boot splash: same phone column as #app so desktop never flashes a
|
||||
full-bleed white sheet. Removed in main.ts once Vue mounts.
|
||||
-->
|
||||
<div
|
||||
id="app-splash"
|
||||
style="
|
||||
position: fixed;
|
||||
inset-block: 0;
|
||||
inset-inline: max(0px, calc(50vw - 215px));
|
||||
width: min(100vw, 430px);
|
||||
margin-inline: auto;
|
||||
background-color: var(--c-page, #fff);
|
||||
z-index: 0;
|
||||
"
|
||||
></div>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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] ?? 'وضعیت سفارش شما تغییر کرده است.'
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
// LESS plugin; for a theme token it emits `rgb(var(--c-token-rgb) / 40%)`, for
|
||||
// a real colour it behaves exactly like `fade`.
|
||||
//
|
||||
// This file is kept identical in user-app and user-app-v2; only the JSONs
|
||||
// Appearance defaults for the customer app; only the JSONs
|
||||
// differ per app.
|
||||
import type { Plugin } from 'vite'
|
||||
import light from './colors.json'
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/app.vue","./src/brand.ts","./src/env.d.ts","./src/main.ts","./src/sw.ts","./src/api/cachedreads.ts","./src/api/client.ts","./src/components/appupdategate.vue","./src/components/dialogvant.vue","./src/components/dialogvantonebutton.vue","./src/components/emptystate.vue","./src/components/spinner.vue","./src/components/expert/actingasbar.vue","./src/components/home/categoryrail.vue","./src/components/home/ratestrip.vue","./src/components/home/sectionhead.vue","./src/components/home/showcasecard.vue","./src/components/home/showcaseslider.vue","./src/components/icon/icon.vue","./src/components/input/otpinput.vue","./src/components/input/singleotpinput.vue","./src/components/product/cartitem.vue","./src/components/product/galleryslider.vue","./src/components/product/ordercard.vue","./src/components/product/orderitemcard.vue","./src/components/product/productcard.vue","./src/components/product/productmorph.vue","./src/components/product/singleproduct.vue","./src/components/search/filters.vue","./src/components/search/searchinput.vue","./src/components/ui/apppullrefresh.vue","./src/components/ui/container.vue","./src/components/ui/dropdown.vue","./src/components/ui/navigation.vue","./src/components/ui/pagebar.vue","./src/components/ui/refreshoverlay.vue","./src/components/ui/scrollfetchsearch.vue","./src/components/ui/toastcard.vue","./src/components/ui/toasthost.vue","./src/components/ui/toggleswitch.vue","./src/components/upload/singleimageuploader.vue","./src/composables/useappupdate.ts","./src/composables/useorderitemproduct.ts","./src/composables/usepagetransition.ts","./src/composables/usepagedproducts.ts","./src/composables/useproduct.ts","./src/composables/useproductdetail.ts","./src/composables/useproductmorph.ts","./src/composables/useproducts.ts","./src/composables/usereveal.ts","./src/composables/usetoast.ts","./src/layout/fullpagelayout.vue","./src/layout/masterlayout.vue","./src/router/index.ts","./src/stores/appearancestore.ts","./src/stores/cartstore.ts","./src/stores/catalogstore.ts","./src/stores/configstore.ts","./src/stores/favoritesstore.ts","./src/stores/filtersstore.ts","./src/stores/navstore.ts","./src/stores/querycache.ts","./src/stores/session.ts","./src/stores/transitionstore.ts","./src/stores/wagequotestore.ts","./src/stores/windowdatastore.ts","./src/types/moment-jalaali-shim.d.ts","./src/types/swiper-shim.d.ts","./src/utils/checkaccess.ts","./src/utils/filters.ts","./src/utils/orderstatus.ts","./src/utils/validator.ts","./src/views/cartview.vue","./src/views/homeview.vue","./src/views/newestview.vue","./src/views/expert/childordersview.vue","./src/views/expert/customerchoiceview.vue","./src/views/report/reportdocument.vue","./src/views/report/reportview.vue","./src/views/search/collectionview.vue","./src/views/search/searchview.vue","./src/views/user/appearanceview.vue","./src/views/user/completeprofileview.vue","./src/views/user/dashboardview.vue","./src/views/user/favoritesview.vue","./src/views/user/iamcallback.vue","./src/views/user/loginregisterview.vue","./src/views/user/loginview.vue","./src/views/user/notificationsview.vue","./src/views/user/ordersview.vue","./src/views/user/profileview.vue","./src/views/user/registerview.vue","./src/views/user/transactionsview.vue"],"version":"5.9.3"}
|
||||
{"root":["./src/app.vue","./src/brand.ts","./src/env.d.ts","./src/main.ts","./src/sw.ts","./src/api/cachedreads.ts","./src/api/client.ts","./src/components/appupdategate.vue","./src/components/dialogvant.vue","./src/components/dialogvantonebutton.vue","./src/components/emptystate.vue","./src/components/spinner.vue","./src/components/expert/actingasbar.vue","./src/components/home/carousel.vue","./src/components/home/categoryrail.vue","./src/components/home/ratestrip.vue","./src/components/home/sectionhead.vue","./src/components/home/showcaseslider.vue","./src/components/icon/icon.vue","./src/components/input/otpinput.vue","./src/components/input/singleotpinput.vue","./src/components/product/cartitem.vue","./src/components/product/galleryslider.vue","./src/components/product/ordercard.vue","./src/components/product/orderitemcard.vue","./src/components/product/productcard.vue","./src/components/product/productmorph.vue","./src/components/product/productrow.vue","./src/components/product/singleproduct.vue","./src/components/pwa/installpromptmodal.vue","./src/components/search/filters.vue","./src/components/search/searchinput.vue","./src/components/ui/apppullrefresh.vue","./src/components/ui/container.vue","./src/components/ui/dropdown.vue","./src/components/ui/navigation.vue","./src/components/ui/pagebar.vue","./src/components/ui/scrollfetchsearch.vue","./src/components/ui/toastcard.vue","./src/components/ui/toasthost.vue","./src/components/ui/toggleswitch.vue","./src/components/upload/singleimageuploader.vue","./src/composables/useappupdate.ts","./src/composables/useorderitemproduct.ts","./src/composables/usepagetransition.ts","./src/composables/useproduct.ts","./src/composables/useproductdetail.ts","./src/composables/useproductmorph.ts","./src/composables/useproducts.ts","./src/composables/usepwainstall.ts","./src/composables/usereveal.ts","./src/composables/usetoast.ts","./src/layout/fullpagelayout.vue","./src/layout/masterlayout.vue","./src/router/index.ts","./src/stores/appearancestore.ts","./src/stores/cartstore.ts","./src/stores/catalogstore.ts","./src/stores/configstore.ts","./src/stores/favoritesstore.ts","./src/stores/filtersstore.ts","./src/stores/navstore.ts","./src/stores/querycache.ts","./src/stores/session.ts","./src/stores/transitionstore.ts","./src/stores/wagequotestore.ts","./src/stores/windowdatastore.ts","./src/types/moment-jalaali-shim.d.ts","./src/types/swiper-shim.d.ts","./src/utils/checkaccess.ts","./src/utils/filters.ts","./src/utils/orderstatus.ts","./src/utils/validator.ts","./src/views/cartview.vue","./src/views/homeview.vue","./src/views/newestview.vue","./src/views/expert/childordersview.vue","./src/views/expert/customerchoiceview.vue","./src/views/report/reportdocument.vue","./src/views/report/reportview.vue","./src/views/search/collectionview.vue","./src/views/search/searchview.vue","./src/views/user/appearanceview.vue","./src/views/user/completeprofileview.vue","./src/views/user/dashboardview.vue","./src/views/user/favoritesview.vue","./src/views/user/iamcallback.vue","./src/views/user/loginregisterview.vue","./src/views/user/loginview.vue","./src/views/user/notificationsview.vue","./src/views/user/ordersview.vue","./src/views/user/profileview.vue","./src/views/user/registerview.vue","./src/views/user/transactionsview.vue"],"version":"5.9.3"}
|
||||
@@ -57,8 +57,11 @@ export default defineConfig({
|
||||
// stuck dev server is obvious rather than moving to a random port.
|
||||
strictPort: true,
|
||||
proxy: {
|
||||
'/api': `http://localhost:${apiPort}`,
|
||||
'/uploads': `http://localhost:${apiPort}`,
|
||||
// 127.0.0.1, not localhost: on Windows localhost often prefers ::1, and
|
||||
// another process can bind only IPv6 :3000 while our API is on IPv4 —
|
||||
// the proxy then hits the wrong app and every /api call 404s.
|
||||
'/api': `http://127.0.0.1:${apiPort}`,
|
||||
'/uploads': `http://127.0.0.1:${apiPort}`,
|
||||
},
|
||||
},
|
||||
css: {
|
||||
|
||||
Reference in New Issue
Block a user