user-app: redesigned home screen and product detail
Design language introduced here and reused across both screens:
- LESS colour tokens (@ink/@inkSoft/@inkMuted/@inkFaint, @wine/@wineSoft,
@canvas/@canvasSoft, @hairline/@hairlineSoft) added to
themeConfig/colors.json and injected globally via vite's modifyVars, so
scoped <style lang="less"> blocks resolve them with no import
- BEM-ish scoped class names, hairline-bordered cards at 14-18px radii,
ink surfaces with a wine accent, dashed-border empty states
- components/home/: SectionHead, ShowcaseCard, ShowcaseSlider, CategoryRail,
RateStrip, AccountStrip
Data honesty is deliberate throughout: no rial price is printed for a piece,
because a finished item's price is the live gold rate x weight plus wage,
computed at order time and never stored on the product. Only genuinely
present fields are shown (weight, making-charge percent, stock, category,
brand). Categories and the hero are derived from the real catalogue, so an
empty catalogue renders an empty state rather than placeholder tiles.
Also fixes a real bug in the product detail view: it ran, at module scope,
history.pushState + window.onpopstate = () => history.go(1). Because the
drawer mounts its slot eagerly, that fired for every product card on the
home screen and permanently hijacked the back button for the session.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="480" height="480">
|
||||
<rect width="64" height="64" fill="#F6F3EF"/>
|
||||
<g transform="translate(0,0)" color="#8D6A46" opacity="0.92">
|
||||
<ellipse cx="32" cy="32" rx="22" ry="14" fill="none" stroke="currentColor" stroke-width="4"/><ellipse cx="32" cy="32" rx="14" ry="8" fill="none" stroke="currentColor" stroke-width="2" opacity="0.5"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 415 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="480" height="480">
|
||||
<rect width="64" height="64" fill="#F3EADC"/>
|
||||
<g transform="translate(0,0)" color="#8D6A46" opacity="0.92">
|
||||
<ellipse cx="32" cy="32" rx="22" ry="14" fill="none" stroke="currentColor" stroke-width="4"/><ellipse cx="32" cy="32" rx="14" ry="8" fill="none" stroke="currentColor" stroke-width="2" opacity="0.5"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 415 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="480" height="480">
|
||||
<rect width="64" height="64" fill="#FAF5EC"/>
|
||||
<g transform="translate(0,0)" color="#8D6A46" opacity="0.92">
|
||||
<ellipse cx="32" cy="32" rx="22" ry="14" fill="none" stroke="currentColor" stroke-width="4"/><ellipse cx="32" cy="32" rx="14" ry="8" fill="none" stroke="currentColor" stroke-width="2" opacity="0.5"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 415 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="480" height="480">
|
||||
<rect width="64" height="64" fill="#F0E6D6"/>
|
||||
<g transform="translate(0,0)" color="#8D6A46" opacity="0.92">
|
||||
<ellipse cx="32" cy="32" rx="22" ry="14" fill="none" stroke="currentColor" stroke-width="4"/><ellipse cx="32" cy="32" rx="14" ry="8" fill="none" stroke="currentColor" stroke-width="2" opacity="0.5"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 415 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="480" height="480">
|
||||
<rect width="64" height="64" fill="#F8F1E6"/>
|
||||
<g transform="translate(0,0)" color="#8D6A46" opacity="0.92">
|
||||
<ellipse cx="32" cy="32" rx="22" ry="14" fill="none" stroke="currentColor" stroke-width="4"/><ellipse cx="32" cy="32" rx="14" ry="8" fill="none" stroke="currentColor" stroke-width="2" opacity="0.5"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 415 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="480" height="480">
|
||||
<rect width="64" height="64" fill="#EFE4D3"/>
|
||||
<g transform="translate(0,0)" color="#8D6A46" opacity="0.92">
|
||||
<ellipse cx="32" cy="32" rx="22" ry="14" fill="none" stroke="currentColor" stroke-width="4"/><ellipse cx="32" cy="32" rx="14" ry="8" fill="none" stroke="currentColor" stroke-width="2" opacity="0.5"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 415 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="480" height="480">
|
||||
<rect width="64" height="64" fill="#F6F3EF"/>
|
||||
<g transform="translate(0,0)" color="#D4AF37" opacity="0.92">
|
||||
<circle cx="32" cy="32" r="22" fill="none" stroke="currentColor" stroke-width="4"/><circle cx="32" cy="32" r="14" fill="none" stroke="currentColor" stroke-width="2" opacity="0.6"/><text x="32" y="38" font-size="14" text-anchor="middle" fill="currentColor" font-family="sans-serif">Au</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 506 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="480" height="480">
|
||||
<rect width="64" height="64" fill="#F3EADC"/>
|
||||
<g transform="translate(0,0)" color="#D4AF37" opacity="0.92">
|
||||
<circle cx="32" cy="32" r="22" fill="none" stroke="currentColor" stroke-width="4"/><circle cx="32" cy="32" r="14" fill="none" stroke="currentColor" stroke-width="2" opacity="0.6"/><text x="32" y="38" font-size="14" text-anchor="middle" fill="currentColor" font-family="sans-serif">Au</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 506 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="480" height="480">
|
||||
<rect width="64" height="64" fill="#FAF5EC"/>
|
||||
<g transform="translate(0,0)" color="#D4AF37" opacity="0.92">
|
||||
<circle cx="32" cy="32" r="22" fill="none" stroke="currentColor" stroke-width="4"/><circle cx="32" cy="32" r="14" fill="none" stroke="currentColor" stroke-width="2" opacity="0.6"/><text x="32" y="38" font-size="14" text-anchor="middle" fill="currentColor" font-family="sans-serif">Au</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 506 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="480" height="480">
|
||||
<rect width="64" height="64" fill="#F0E6D6"/>
|
||||
<g transform="translate(0,0)" color="#D4AF37" opacity="0.92">
|
||||
<circle cx="32" cy="32" r="22" fill="none" stroke="currentColor" stroke-width="4"/><circle cx="32" cy="32" r="14" fill="none" stroke="currentColor" stroke-width="2" opacity="0.6"/><text x="32" y="38" font-size="14" text-anchor="middle" fill="currentColor" font-family="sans-serif">Au</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 506 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="480" height="480">
|
||||
<rect width="64" height="64" fill="#F8F1E6"/>
|
||||
<g transform="translate(0,0)" color="#D4AF37" opacity="0.92">
|
||||
<circle cx="32" cy="32" r="22" fill="none" stroke="currentColor" stroke-width="4"/><circle cx="32" cy="32" r="14" fill="none" stroke="currentColor" stroke-width="2" opacity="0.6"/><text x="32" y="38" font-size="14" text-anchor="middle" fill="currentColor" font-family="sans-serif">Au</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 506 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="480" height="480">
|
||||
<rect width="64" height="64" fill="#EFE4D3"/>
|
||||
<g transform="translate(0,0)" color="#D4AF37" opacity="0.92">
|
||||
<circle cx="32" cy="32" r="22" fill="none" stroke="currentColor" stroke-width="4"/><circle cx="32" cy="32" r="14" fill="none" stroke="currentColor" stroke-width="2" opacity="0.6"/><text x="32" y="38" font-size="14" text-anchor="middle" fill="currentColor" font-family="sans-serif">Au</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 506 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="480" height="480">
|
||||
<rect width="64" height="64" fill="#F6F3EF"/>
|
||||
<g transform="translate(0,0)" color="#A67C52" opacity="0.92">
|
||||
<circle cx="32" cy="16" r="5" fill="currentColor"/><path d="M32 21 L32 34" stroke="currentColor" stroke-width="3"/><circle cx="32" cy="42" r="10" fill="none" stroke="currentColor" stroke-width="3"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 414 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="480" height="480">
|
||||
<rect width="64" height="64" fill="#F3EADC"/>
|
||||
<g transform="translate(0,0)" color="#A67C52" opacity="0.92">
|
||||
<circle cx="32" cy="16" r="5" fill="currentColor"/><path d="M32 21 L32 34" stroke="currentColor" stroke-width="3"/><circle cx="32" cy="42" r="10" fill="none" stroke="currentColor" stroke-width="3"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 414 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="480" height="480">
|
||||
<rect width="64" height="64" fill="#FAF5EC"/>
|
||||
<g transform="translate(0,0)" color="#A67C52" opacity="0.92">
|
||||
<circle cx="32" cy="16" r="5" fill="currentColor"/><path d="M32 21 L32 34" stroke="currentColor" stroke-width="3"/><circle cx="32" cy="42" r="10" fill="none" stroke="currentColor" stroke-width="3"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 414 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="480" height="480">
|
||||
<rect width="64" height="64" fill="#F0E6D6"/>
|
||||
<g transform="translate(0,0)" color="#A67C52" opacity="0.92">
|
||||
<circle cx="32" cy="16" r="5" fill="currentColor"/><path d="M32 21 L32 34" stroke="currentColor" stroke-width="3"/><circle cx="32" cy="42" r="10" fill="none" stroke="currentColor" stroke-width="3"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 414 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="480" height="480">
|
||||
<rect width="64" height="64" fill="#F8F1E6"/>
|
||||
<g transform="translate(0,0)" color="#A67C52" opacity="0.92">
|
||||
<circle cx="32" cy="16" r="5" fill="currentColor"/><path d="M32 21 L32 34" stroke="currentColor" stroke-width="3"/><circle cx="32" cy="42" r="10" fill="none" stroke="currentColor" stroke-width="3"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 414 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="480" height="480">
|
||||
<rect width="64" height="64" fill="#EFE4D3"/>
|
||||
<g transform="translate(0,0)" color="#A67C52" opacity="0.92">
|
||||
<circle cx="32" cy="16" r="5" fill="currentColor"/><path d="M32 21 L32 34" stroke="currentColor" stroke-width="3"/><circle cx="32" cy="42" r="10" fill="none" stroke="currentColor" stroke-width="3"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 414 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="480" height="480">
|
||||
<rect width="64" height="64" fill="#F6F3EF"/>
|
||||
<g transform="translate(0,0)" color="#C9A24B" opacity="0.92">
|
||||
<path d="M14 14 C14 34 24 44 32 44 C40 44 50 34 50 14" fill="none" stroke="currentColor" stroke-width="3"/><path d="M32 44 L32 52 L26 58 L38 58 Z" fill="currentColor"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 384 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="480" height="480">
|
||||
<rect width="64" height="64" fill="#F3EADC"/>
|
||||
<g transform="translate(0,0)" color="#C9A24B" opacity="0.92">
|
||||
<path d="M14 14 C14 34 24 44 32 44 C40 44 50 34 50 14" fill="none" stroke="currentColor" stroke-width="3"/><path d="M32 44 L32 52 L26 58 L38 58 Z" fill="currentColor"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 384 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="480" height="480">
|
||||
<rect width="64" height="64" fill="#FAF5EC"/>
|
||||
<g transform="translate(0,0)" color="#C9A24B" opacity="0.92">
|
||||
<path d="M14 14 C14 34 24 44 32 44 C40 44 50 34 50 14" fill="none" stroke="currentColor" stroke-width="3"/><path d="M32 44 L32 52 L26 58 L38 58 Z" fill="currentColor"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 384 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="480" height="480">
|
||||
<rect width="64" height="64" fill="#F0E6D6"/>
|
||||
<g transform="translate(0,0)" color="#C9A24B" opacity="0.92">
|
||||
<path d="M14 14 C14 34 24 44 32 44 C40 44 50 34 50 14" fill="none" stroke="currentColor" stroke-width="3"/><path d="M32 44 L32 52 L26 58 L38 58 Z" fill="currentColor"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 384 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="480" height="480">
|
||||
<rect width="64" height="64" fill="#F8F1E6"/>
|
||||
<g transform="translate(0,0)" color="#C9A24B" opacity="0.92">
|
||||
<path d="M14 14 C14 34 24 44 32 44 C40 44 50 34 50 14" fill="none" stroke="currentColor" stroke-width="3"/><path d="M32 44 L32 52 L26 58 L38 58 Z" fill="currentColor"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 384 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="480" height="480">
|
||||
<rect width="64" height="64" fill="#EFE4D3"/>
|
||||
<g transform="translate(0,0)" color="#C9A24B" opacity="0.92">
|
||||
<path d="M14 14 C14 34 24 44 32 44 C40 44 50 34 50 14" fill="none" stroke="currentColor" stroke-width="3"/><path d="M32 44 L32 52 L26 58 L38 58 Z" fill="currentColor"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 384 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="480" height="480">
|
||||
<rect width="64" height="64" fill="#F6F3EF"/>
|
||||
<g transform="translate(0,0)" color="#B08D57" opacity="0.92">
|
||||
<circle cx="32" cy="40" r="14" fill="none" stroke="currentColor" stroke-width="4"/><circle cx="32" cy="20" r="6" fill="currentColor"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 350 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="480" height="480">
|
||||
<rect width="64" height="64" fill="#F3EADC"/>
|
||||
<g transform="translate(0,0)" color="#B08D57" opacity="0.92">
|
||||
<circle cx="32" cy="40" r="14" fill="none" stroke="currentColor" stroke-width="4"/><circle cx="32" cy="20" r="6" fill="currentColor"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 350 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="480" height="480">
|
||||
<rect width="64" height="64" fill="#FAF5EC"/>
|
||||
<g transform="translate(0,0)" color="#B08D57" opacity="0.92">
|
||||
<circle cx="32" cy="40" r="14" fill="none" stroke="currentColor" stroke-width="4"/><circle cx="32" cy="20" r="6" fill="currentColor"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 350 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="480" height="480">
|
||||
<rect width="64" height="64" fill="#F0E6D6"/>
|
||||
<g transform="translate(0,0)" color="#B08D57" opacity="0.92">
|
||||
<circle cx="32" cy="40" r="14" fill="none" stroke="currentColor" stroke-width="4"/><circle cx="32" cy="20" r="6" fill="currentColor"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 350 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="480" height="480">
|
||||
<rect width="64" height="64" fill="#F8F1E6"/>
|
||||
<g transform="translate(0,0)" color="#B08D57" opacity="0.92">
|
||||
<circle cx="32" cy="40" r="14" fill="none" stroke="currentColor" stroke-width="4"/><circle cx="32" cy="20" r="6" fill="currentColor"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 350 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="480" height="480">
|
||||
<rect width="64" height="64" fill="#EFE4D3"/>
|
||||
<g transform="translate(0,0)" color="#B08D57" opacity="0.92">
|
||||
<circle cx="32" cy="40" r="14" fill="none" stroke="currentColor" stroke-width="4"/><circle cx="32" cy="20" r="6" fill="currentColor"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 350 B |
@@ -19,6 +19,12 @@ export interface ProductVariantSummary {
|
||||
title: string;
|
||||
rashWeightGrams: string;
|
||||
defaultWagePercent: string;
|
||||
/// On-hand stock and the "orderable at zero stock" flag. Both exist on
|
||||
/// ProductVariant and are already returned by GET /catalog/products (the
|
||||
/// catalog service includes every variant field) — declared here so the UI
|
||||
/// can read them.
|
||||
qty?: number;
|
||||
isPreOrder?: boolean;
|
||||
}
|
||||
|
||||
export interface CategorySummary {
|
||||
@@ -43,6 +49,9 @@ export interface ProductSummary {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
/// Legacy Product.product_code — the human-facing code. Already returned by
|
||||
/// GET /catalog/products.
|
||||
productCode?: string | null;
|
||||
variants: ProductVariantSummary[];
|
||||
images: Array<{ url: string }>;
|
||||
category: CategorySummary | null;
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
<!--
|
||||
The "حساب" block that sits BELOW the shop content on the home screen.
|
||||
|
||||
Two genuinely different states:
|
||||
- signed out: a quiet invitation, no fabricated numbers.
|
||||
- signed in: the customer's real gram position from GET /customers/:id
|
||||
(goldDebtGrams / fiatDebtRial), labelled بدهکار or بستانکار the same way
|
||||
DashboardView does, since the ledger stores debt as a signed figure.
|
||||
-->
|
||||
<template>
|
||||
<section class="acct">
|
||||
<template v-if="customer">
|
||||
<div class="acct__head">
|
||||
<div>
|
||||
<div class="acct__eyebrow">{{ positionLabel }}</div>
|
||||
<div class="acct__figure">
|
||||
{{ grams }}
|
||||
<span class="acct__unit">گرم</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="acct__seal" :class="isDebtor ? 'is-debtor' : 'is-creditor'">
|
||||
{{ isDebtor ? 'بدهکار' : 'بستانکار' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="acct__row">
|
||||
<span class="acct__rowLabel">بدهی ریالی</span>
|
||||
<span class="acct__rowValue">{{ price(fiat) }} <i>ریال</i></span>
|
||||
</div>
|
||||
|
||||
<div class="acct__actions">
|
||||
<button type="button" class="acct__action" @click="emit('go', 'ordersList')">
|
||||
<Icon icon="vuesax-linear:box" size="18" />
|
||||
سفارشها
|
||||
</button>
|
||||
<button type="button" class="acct__action" @click="emit('go', 'transactionsList')">
|
||||
<Icon icon="vuesax-linear:receipt-text" size="18" />
|
||||
اسناد
|
||||
</button>
|
||||
<button type="button" class="acct__action" @click="emit('go', 'profileData')">
|
||||
<Icon icon="vuesax-linear:user" size="18" />
|
||||
پروفایل
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="acct__invite">
|
||||
<div class="acct__inviteIcon" aria-hidden="true">
|
||||
<Icon icon="vuesax-linear:security-user" size="20" />
|
||||
</div>
|
||||
<div class="acct__inviteCopy">
|
||||
<h3>حساب گرمی شما</h3>
|
||||
<p>برای دیدن موجودی، سفارشها و اسناد خود وارد شوید.</p>
|
||||
</div>
|
||||
<button type="button" class="acct__inviteCta" @click="emit('login')">ورود / ثبتنام</button>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import Icon from '../icon/Icon.vue'
|
||||
import { price } from '../../utils/filters'
|
||||
import type { CustomerDetail } from '../../api/client'
|
||||
|
||||
const props = defineProps<{ customer: CustomerDetail | null }>()
|
||||
const emit = defineEmits<{ (e: 'go', route: string): void; (e: 'login'): void }>()
|
||||
|
||||
const isDebtor = computed(() => (props.customer ? parseFloat(props.customer.goldDebtGrams) > 0 : false))
|
||||
const positionLabel = computed(() => (isDebtor.value ? 'مانده بدهی گرمی' : 'مانده بستانکاری گرمی'))
|
||||
const grams = computed(() => (props.customer ? Math.abs(parseFloat(props.customer.goldDebtGrams)) : 0))
|
||||
const fiat = computed(() => (props.customer ? props.customer.fiatDebtRial.split('.')[0] : '0'))
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.acct {
|
||||
border-radius: 20px;
|
||||
padding: 18px;
|
||||
background: @ink;
|
||||
color: #fff;
|
||||
box-shadow: 0 22px 44px -32px rgba(20, 22, 26, 0.9);
|
||||
}
|
||||
|
||||
.acct__head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.acct__eyebrow {
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
padding-bottom: 7px;
|
||||
}
|
||||
|
||||
.acct__figure {
|
||||
font-size: 26px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.acct__unit {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
padding-inline-start: 3px;
|
||||
}
|
||||
|
||||
.acct__seal {
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
padding: 5px 11px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.acct__seal.is-debtor {
|
||||
color: #fff;
|
||||
background: @wine;
|
||||
}
|
||||
.acct__seal.is-creditor {
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.13);
|
||||
}
|
||||
|
||||
.acct__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 16px;
|
||||
padding-top: 13px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.acct__rowLabel {
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
|
||||
.acct__rowValue {
|
||||
font-weight: 700;
|
||||
}
|
||||
.acct__rowValue i {
|
||||
font-style: normal;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.acct__actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.acct__action {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 11px 4px;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
border-radius: 13px;
|
||||
font-family: inherit;
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
transition: background 0.18s ease, transform 0.18s ease;
|
||||
}
|
||||
.acct__action:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.acct__invite {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 13px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.acct__inviteIcon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 13px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #fff;
|
||||
background: @wine;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.acct__inviteCopy {
|
||||
flex: 1 1 150px;
|
||||
}
|
||||
.acct__inviteCopy h3 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
}
|
||||
.acct__inviteCopy p {
|
||||
margin: 5px 0 0;
|
||||
font-size: 11.5px;
|
||||
line-height: 1.8;
|
||||
color: rgba(255, 255, 255, 0.58);
|
||||
}
|
||||
|
||||
.acct__inviteCta {
|
||||
flex: 1 1 100%;
|
||||
margin-top: 4px;
|
||||
padding: 12px;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
border-radius: 13px;
|
||||
background: #fff;
|
||||
color: @ink;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
.acct__inviteCta:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,94 @@
|
||||
<!--
|
||||
Horizontal category rail. Categories are real, derived from the product
|
||||
catalogue by configStore (GET /catalog/products), so this renders nothing
|
||||
rather than placeholder tiles when the catalogue is empty.
|
||||
-->
|
||||
<template>
|
||||
<div class="rail no-scrollbar">
|
||||
<button
|
||||
v-for="cat in categories"
|
||||
:key="cat.id"
|
||||
type="button"
|
||||
class="rail__chip"
|
||||
:class="{ 'is-active': cat.id === activeId }"
|
||||
@click="emit('select', cat)"
|
||||
>
|
||||
<span class="rail__mark" aria-hidden="true">
|
||||
<img v-if="cat.icon?.image" :src="cat.icon.image" alt="" />
|
||||
</span>
|
||||
<span class="rail__title">{{ cat.title }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { HomeCategory } from '../../stores/configStore'
|
||||
|
||||
defineProps<{ categories: HomeCategory[]; activeId?: string | null }>()
|
||||
const emit = defineEmits<{ (e: 'select', cat: HomeCategory): void }>()
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.rail {
|
||||
display: flex;
|
||||
gap: 9px;
|
||||
overflow-x: auto;
|
||||
padding: 2px 16px 2px;
|
||||
margin: 0 -16px;
|
||||
scroll-snap-type: x proximity;
|
||||
}
|
||||
|
||||
.rail__chip {
|
||||
scroll-snap-align: start;
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 14px 8px 10px;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: @inkSoft;
|
||||
background: @canvas;
|
||||
border: 1px solid @hairline;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.rail__chip:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.rail__chip.is-active {
|
||||
background: @ink;
|
||||
border-color: @ink;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.rail__mark {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
background: @hairlineSoft;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.rail__chip.is-active .rail__mark {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
|
||||
.rail__mark img {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
object-fit: contain;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.rail__title {
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,107 @@
|
||||
<!--
|
||||
Live 18k gold rate strip. Real data: GET /pricing/latest (PricingController).
|
||||
The strip degrades to a quiet placeholder when the quote endpoint has no
|
||||
data yet, rather than rendering a fabricated number.
|
||||
-->
|
||||
<template>
|
||||
<div class="rate-strip" :class="{ 'is-empty': !quote }">
|
||||
<div class="rate-strip__label">
|
||||
<span class="rate-strip__dot" aria-hidden="true"></span>
|
||||
نرخ طلای ۱۸ عیار
|
||||
</div>
|
||||
|
||||
<div v-if="quote" class="rate-strip__value">
|
||||
<span class="rate-strip__num">{{ price(perGram) }}</span>
|
||||
<span class="rate-strip__unit">ریال / گرم</span>
|
||||
</div>
|
||||
<div v-else class="rate-strip__value rate-strip__value--muted">در انتظار اعلام نرخ</div>
|
||||
|
||||
<div v-if="quote" class="rate-strip__time">{{ jdate(quote.quotedAt) }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { price, jdate } from '../../utils/filters'
|
||||
import type { GoldPriceQuote } from '../../api/client'
|
||||
|
||||
const props = defineProps<{ quote: GoldPriceQuote | null }>()
|
||||
|
||||
// pricePerGramRial is a decimal string from the ledger — never parsed into a
|
||||
// float for display, only trimmed of any fractional tail.
|
||||
const perGram = computed(() => (props.quote ? props.quote.pricePerGramRial.split('.')[0] : ''))
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.rate-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 14px;
|
||||
background: @ink;
|
||||
color: #fff;
|
||||
box-shadow: 0 10px 24px -18px rgba(20, 22, 26, 0.9);
|
||||
}
|
||||
|
||||
.rate-strip__label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.68);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rate-strip__dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: @wineSoft;
|
||||
box-shadow: 0 0 0 0 rgba(163, 60, 85, 0.65);
|
||||
animation: ratePulse 2.4s ease-out infinite;
|
||||
}
|
||||
|
||||
.rate-strip__value {
|
||||
margin-inline-start: auto;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.rate-strip__value--muted {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
|
||||
.rate-strip__num {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.rate-strip__unit {
|
||||
font-size: 10.5px;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
|
||||
.rate-strip__time {
|
||||
font-size: 10.5px;
|
||||
color: rgba(255, 255, 255, 0.42);
|
||||
padding-inline-start: 10px;
|
||||
border-inline-start: 1px solid rgba(255, 255, 255, 0.14);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@keyframes ratePulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(163, 60, 85, 0.6);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 7px rgba(163, 60, 85, 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(163, 60, 85, 0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,59 @@
|
||||
<!-- Section heading with an optional "see all" affordance. -->
|
||||
<template>
|
||||
<header class="sec">
|
||||
<div class="sec__text">
|
||||
<h2 class="sec__title">{{ title }}</h2>
|
||||
<p v-if="subtitle" class="sec__sub">{{ subtitle }}</p>
|
||||
</div>
|
||||
<button v-if="actionLabel" type="button" class="sec__action" @click="emit('action')">
|
||||
{{ actionLabel }}
|
||||
<Icon icon="vuesax-linear:arrow-left-2" size="15" />
|
||||
</button>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Icon from '../icon/Icon.vue'
|
||||
|
||||
defineProps<{ title: string; subtitle?: string; actionLabel?: string }>()
|
||||
const emit = defineEmits<{ (e: 'action'): void }>()
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.sec {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding-bottom: 13px;
|
||||
}
|
||||
|
||||
.sec__title {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
color: @ink;
|
||||
letter-spacing: -0.1px;
|
||||
}
|
||||
|
||||
.sec__sub {
|
||||
margin: 5px 0 0;
|
||||
font-size: 11.5px;
|
||||
color: @inkFaint;
|
||||
}
|
||||
|
||||
.sec__action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border: 0;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
font-size: 11.5px;
|
||||
font-weight: 700;
|
||||
color: @wine;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,150 @@
|
||||
<!--
|
||||
Product card for the redesigned home screen.
|
||||
|
||||
Shows only fields that genuinely exist on ProductSummary today: title, the
|
||||
cheapest variant's rash weight, its making-charge (اجرت) percent, and the
|
||||
catalogue image. No price is printed on the card, because a finished piece's
|
||||
rial price is a function of the live gold rate plus wage and is computed at
|
||||
order time, not stored on the product — printing one here would be invented.
|
||||
-->
|
||||
<template>
|
||||
<article class="card" :class="{ 'card--wide': wide }" @click="emit('open', value)">
|
||||
<div class="card__art">
|
||||
<img v-lazy="image" :alt="title" />
|
||||
<button type="button" class="card__fav" :class="{ 'is-on': favorites.isFavorite(id) }" @click.stop="favorites.toggle(id)">
|
||||
<Icon :icon="favorites.isFavorite(id) ? 'vuesax-linear:heart-fill' : 'vuesax-linear:heart'" size="17" />
|
||||
</button>
|
||||
<span v-if="badge" class="card__badge">{{ badge }}</span>
|
||||
</div>
|
||||
|
||||
<div class="card__body">
|
||||
<h3 class="card__title">{{ title }}</h3>
|
||||
<div class="card__meta">
|
||||
<span class="card__weight">{{ min_gram }} گرم</span>
|
||||
<span v-if="wage != null" class="card__wage">اجرت {{ wageLabel }}٪</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import Icon from '../icon/Icon.vue'
|
||||
import { useProduct } from '../../composables/useProduct'
|
||||
import { useFavoritesStore } from '../../stores/favoritesStore'
|
||||
import type { ProductSummary } from '../../api/client'
|
||||
|
||||
const props = defineProps<{ value: ProductSummary; wide?: boolean; badge?: string }>()
|
||||
const emit = defineEmits<{ (e: 'open', value: ProductSummary): void }>()
|
||||
|
||||
const favorites = useFavoritesStore()
|
||||
const { image, id, title, min_gram, wage } = useProduct(props.value)
|
||||
|
||||
const wageLabel = computed(() => {
|
||||
const w = wage.value
|
||||
if (w == null) return ''
|
||||
return w % 1 === 0 ? String(w) : w.toFixed(2)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.card {
|
||||
cursor: pointer;
|
||||
background: @canvas;
|
||||
border: 1px solid @hairline;
|
||||
border-radius: 18px;
|
||||
overflow: hidden;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.card:active {
|
||||
transform: scale(0.985);
|
||||
}
|
||||
|
||||
.card__art {
|
||||
position: relative;
|
||||
aspect-ratio: 1 / 1;
|
||||
background: @hairlineSoft;
|
||||
}
|
||||
|
||||
.card--wide .card__art {
|
||||
aspect-ratio: 4 / 3;
|
||||
}
|
||||
|
||||
.card__art img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.card__fav {
|
||||
position: absolute;
|
||||
top: 9px;
|
||||
left: 9px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
color: @inkMuted;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
backdrop-filter: blur(6px);
|
||||
transition: color 0.18s ease, transform 0.18s ease;
|
||||
}
|
||||
.card__fav.is-on {
|
||||
color: @wine;
|
||||
}
|
||||
.card__fav:active {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
.card__badge {
|
||||
position: absolute;
|
||||
top: 9px;
|
||||
right: 9px;
|
||||
padding: 4px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
background: @wine;
|
||||
}
|
||||
|
||||
.card__body {
|
||||
padding: 11px 12px 13px;
|
||||
}
|
||||
|
||||
.card__title {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: @ink;
|
||||
line-height: 1.6;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.card__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding-top: 7px;
|
||||
}
|
||||
|
||||
.card__weight {
|
||||
font-size: 11.5px;
|
||||
font-weight: 700;
|
||||
color: @inkSoft;
|
||||
}
|
||||
|
||||
.card__wage {
|
||||
font-size: 10.5px;
|
||||
color: @inkFaint;
|
||||
padding-inline-start: 7px;
|
||||
border-inline-start: 1px solid @hairline;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,201 @@
|
||||
<!--
|
||||
Editorial hero for the home screen. Replaces the legacy swiper banner with a
|
||||
self-contained, dependency-free slider: no swiper CSS to fight, no autoplay
|
||||
jitter, and it keeps working when a slide has no artwork.
|
||||
-->
|
||||
<template>
|
||||
<section class="showcase" v-if="slides.length">
|
||||
<div class="showcase__frame">
|
||||
<transition-group name="showcase-fade" tag="div" class="showcase__stage">
|
||||
<article v-for="(slide, i) in slides" :key="slide.key" v-show="i === index" class="showcase__slide">
|
||||
<img v-if="slide.image" :src="slide.image" alt="" class="showcase__art" />
|
||||
<div class="showcase__scrim" aria-hidden="true"></div>
|
||||
|
||||
<div class="showcase__copy">
|
||||
<span class="showcase__eyebrow">{{ slide.eyebrow }}</span>
|
||||
<h2 class="showcase__title">{{ slide.title }}</h2>
|
||||
<p class="showcase__sub">{{ slide.subtitle }}</p>
|
||||
<button type="button" class="showcase__cta" @click="emit('select', slide)">
|
||||
{{ slide.cta }}
|
||||
<Icon icon="vuesax-linear:arrow-left-2" size="17" />
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</transition-group>
|
||||
</div>
|
||||
|
||||
<div class="showcase__dots" v-if="slides.length > 1">
|
||||
<button
|
||||
v-for="(slide, i) in slides"
|
||||
:key="slide.key"
|
||||
type="button"
|
||||
class="showcase__dot"
|
||||
:class="{ 'is-active': i === index }"
|
||||
:aria-label="slide.title"
|
||||
@click="go(i)"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import Icon from '../icon/Icon.vue'
|
||||
|
||||
export interface ShowcaseSlide {
|
||||
key: string
|
||||
eyebrow: string
|
||||
title: string
|
||||
subtitle: string
|
||||
cta: string
|
||||
image?: string
|
||||
categoryId?: string
|
||||
}
|
||||
|
||||
const props = defineProps<{ slides: ShowcaseSlide[] }>()
|
||||
const emit = defineEmits<{ (e: 'select', slide: ShowcaseSlide): void }>()
|
||||
|
||||
const index = ref(0)
|
||||
let timer: ReturnType<typeof setInterval> | undefined
|
||||
|
||||
function go(i: number) {
|
||||
index.value = i
|
||||
restart()
|
||||
}
|
||||
function restart() {
|
||||
if (timer) clearInterval(timer)
|
||||
if (props.slides.length > 1) {
|
||||
timer = setInterval(() => {
|
||||
index.value = (index.value + 1) % props.slides.length
|
||||
}, 6000)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(restart)
|
||||
onBeforeUnmount(() => {
|
||||
if (timer) clearInterval(timer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.showcase__frame {
|
||||
position: relative;
|
||||
border-radius: 22px;
|
||||
overflow: hidden;
|
||||
background: @ink;
|
||||
box-shadow: 0 26px 50px -34px rgba(20, 22, 26, 0.85);
|
||||
}
|
||||
|
||||
.showcase__stage {
|
||||
position: relative;
|
||||
display: block;
|
||||
aspect-ratio: 320 / 208;
|
||||
}
|
||||
|
||||
.showcase__slide {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.showcase__art {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
opacity: 0.5;
|
||||
transform: scale(1.04);
|
||||
}
|
||||
|
||||
.showcase__scrim {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
linear-gradient(255deg, rgba(122, 27, 51, 0.55) 0%, rgba(122, 27, 51, 0) 52%),
|
||||
linear-gradient(to left, rgba(20, 22, 26, 0.2) 0%, rgba(20, 22, 26, 0.92) 72%);
|
||||
}
|
||||
|
||||
.showcase__copy {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
padding: 22px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.showcase__eyebrow {
|
||||
font-size: 10.5px;
|
||||
letter-spacing: 1.6px;
|
||||
color: rgba(255, 255, 255, 0.62);
|
||||
padding-bottom: 9px;
|
||||
}
|
||||
|
||||
.showcase__title {
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
line-height: 1.35;
|
||||
color: #fff;
|
||||
margin: 0 0 7px;
|
||||
}
|
||||
|
||||
.showcase__sub {
|
||||
font-size: 12.5px;
|
||||
line-height: 1.85;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
margin: 0 0 16px;
|
||||
max-width: 30ch;
|
||||
}
|
||||
|
||||
.showcase__cta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
padding: 9px 16px;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
color: @ink;
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
font-family: inherit;
|
||||
transition: transform 0.18s ease, background 0.18s ease;
|
||||
}
|
||||
.showcase__cta:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.showcase__dots {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.showcase__dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
border-radius: 999px;
|
||||
background: @hairline;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
.showcase__dot.is-active {
|
||||
width: 20px;
|
||||
background: @wine;
|
||||
}
|
||||
|
||||
.showcase-fade-enter-active,
|
||||
.showcase-fade-leave-active {
|
||||
transition: opacity 0.55s ease;
|
||||
}
|
||||
.showcase-fade-enter-from,
|
||||
.showcase-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,156 +1,220 @@
|
||||
<!--
|
||||
Ported from legacy henza_pwa/src/views/singleProduct.vue.
|
||||
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.
|
||||
|
||||
CRITICAL EXISTING ISSUE — ported as-is, flagged prominently (not silently
|
||||
fixed): the bottom of legacy's script has an unconditional global side
|
||||
effect —
|
||||
history.pushState(null, location.href);
|
||||
window.onpopstate = function () { history.go(1); };
|
||||
— that runs every time a SingleProduct instance is created (not just when
|
||||
its drawer opens — ant-design-vue's Drawer does not lazily mount its slot
|
||||
content by default). This permanently overrides the browser/hardware back
|
||||
button for the rest of the session (forcing it to always go *forward*), and
|
||||
fires immediately once the Home page renders its product cards, not only
|
||||
when a product detail is actually opened. This looks like a broken attempt
|
||||
to trap the user inside the drawer. I did not fix this — it's ported
|
||||
verbatim below — but flagging it as the single most impactful bug found in
|
||||
this port so far; recommend fixing in a dedicated bug-fix pass rather than
|
||||
silently patching it here.
|
||||
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 (اجرت ٪)
|
||||
- stock, from ProductVariant.qty / isPreOrder
|
||||
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 (not merely ported) — a genuine crash, not a stylistic difference:
|
||||
legacy's image-gallery guard was `v-if="product.images[0].image"`, which
|
||||
throws immediately for any product with zero images (TypeError: cannot read
|
||||
property of undefined). Since 100% of our current seeded products have
|
||||
`images: []`, porting this literally would make the view crash for every
|
||||
real product today. Legacy's own `v-else` branch (a decorative placeholder
|
||||
SVG) makes clear a "no images" case was already intended — the guard here
|
||||
is `product.images && product.images.length > 0`, which is what's needed
|
||||
for that already-intended fallback to actually be reachable. Flagging this
|
||||
explicitly since it's a behavior change, not just documentation.
|
||||
|
||||
DROPPED as confirmed-dead code (not "possibly dead" — verified unreachable):
|
||||
legacy also declared a second DialogVant (`loginStepTwoNotify`) gating order
|
||||
placement on a customer verification step. The only code that ever set it
|
||||
to `true` was in an explicicitly commented-out alternate click handler
|
||||
(legacy's own comment: "کد قبلی (نیاز به تایید مدارک...)"); the actual live
|
||||
click handler (`isUserLoggedIn() ? addToCart() : loginNotify = true`) never
|
||||
references it. It cannot show under any reachable code path. Not ported.
|
||||
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
|
||||
for *every* product card rendered on the home screen, not just when a detail
|
||||
view opened, and permanently hijacked the browser/hardware back button for
|
||||
the rest of the session (forcing it forward). It was carried over from legacy
|
||||
and flagged in the old file's header as the most impactful bug in the port.
|
||||
It is removed here; closing is handled by close() alone, which is what the
|
||||
drawer and the /product/:id route both already rely on.
|
||||
-->
|
||||
<template>
|
||||
<Spinner :spinning="loading" tip="درحال دریافت اطلاعات" cls="overflow-hidden" v-if="flag && productDetail.productRef.value">
|
||||
<container class="px-[32px] py-[16px] flex flex-col pb-[66px] bg-prim max-h-screen" style="padding-bottom: 20px">
|
||||
<div class="w-screen col-span-6 mx-[-32px] max-w-screen-md">
|
||||
<div class="fixed top-[20px] right-[16px] z-[100] p-4" @click="close">
|
||||
<Icon icon="vuesax-linear:arrow-right-1" class="cursor-pointer text-white bg-secondary rounded-[5px] p-[2.5px]" size="24" />
|
||||
</div>
|
||||
<div v-if="favorites.isFavorite(id)">
|
||||
<Icon icon="vuesax-linear:heart-fill" class="cursor-pointer text-secondary z-[100] fixed top-[20px] left-[16px] p-4" @click="toggleFav" size="25" />
|
||||
</div>
|
||||
<div v-else>
|
||||
<Icon icon="vuesax-linear:heart" class="cursor-pointer text-secondary z-[100] fixed top-[20px] left-[16px] p-4" @click="toggleFav" size="25" />
|
||||
</div>
|
||||
<transition name="slideUp" mode="out-in">
|
||||
<div v-if="hasImages">
|
||||
<Slider :slides="slides" width="auto" height="55vh" />
|
||||
<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">
|
||||
<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="علاقهمندی"
|
||||
@click="toggleFav"
|
||||
>
|
||||
<Icon :icon="favorites.isFavorite(id) ? 'vuesax-linear:heart-fill' : 'vuesax-linear:heart'" size="19" />
|
||||
</button>
|
||||
|
||||
<div v-if="hasImages" class="pd__stage">
|
||||
<img :src="slides[imageIndex]" :alt="title" class="pd__art" />
|
||||
<div v-if="slides.length > 1" class="pd__counter">{{ imageIndex + 1 }}/{{ slides.length }}</div>
|
||||
<div v-if="slides.length > 1" class="pd__dots">
|
||||
<button
|
||||
v-for="(s, i) in slides"
|
||||
:key="s"
|
||||
type="button"
|
||||
class="pd__dot"
|
||||
:class="{ 'is-on': i === imageIndex }"
|
||||
:aria-label="`تصویر ${i + 1}`"
|
||||
@click="imageIndex = i"
|
||||
/>
|
||||
</div>
|
||||
<div v-else>
|
||||
<svg height="55vh" width="auto" viewBox="0 0 12 9" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M6 9C5.91177 5.77359 3.66177 4.75472 0 4.5C3.57353 4.37264 5.91177 3.18396 6 0C6.08824 3.22642 8.38235 4.4151 12 4.5C8.38235 4.75472 6.08824 5.77359 6 9Z"
|
||||
fill="#8D6A46"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
<div v-else class="pd__stage pd__stage--empty">
|
||||
<Icon icon="vuesax-linear:diamonds" size="46" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1">
|
||||
<div class="overflow-y-auto max-h-[300px]">
|
||||
<div class="w-full flex justify-between mb-4">
|
||||
<h5 class="col-span-4 m-0 text-[19px] font-medium">{{ title }}</h5>
|
||||
<Button type="primary" class="justify-self-end m-0 col-span-2" @click="showPopup = true">توضیحات</Button>
|
||||
</div>
|
||||
<div class="w-full flex justify-between mb-4">
|
||||
<div>
|
||||
<div v-if="showDiscountWage" class="flex mb-4">
|
||||
<div class="ml-4">اجرت</div>
|
||||
<div class="rounded-[5px] bg-danger text-white py-[4px] px-[8px]" v-if="promotion">-%{{ promotion.discount_percent }}</div>
|
||||
</div>
|
||||
<div v-if="showDiscountWage" class="flex">
|
||||
<div :class="{ 'text-secondary': true, 'ml-4 text-[13px] line-through decoration-danger': hasDiscountWage }">
|
||||
{{ wage != null && wage % 1 === 0 ? wage : wage?.toFixed(2) }}% اجرت
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="mb-0">{{ min_gram }}</h3>
|
||||
<div class="text-secondary mb-0 text-left">گرم</div>
|
||||
<!-- Sheet -->
|
||||
<div class="pd__sheet">
|
||||
<header class="pd__head">
|
||||
<div class="pd__headMain">
|
||||
<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">{{ categoryTitle }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full flex justify-between">
|
||||
<div class="w-full mb-[10px]">تنوع ها</div>
|
||||
<span class="pd__stock" :class="stockClass">{{ stockLabel }}</span>
|
||||
</header>
|
||||
|
||||
<!-- Headline figures -->
|
||||
<div class="pd__figures">
|
||||
<div class="pd__figure">
|
||||
<span class="pd__figureLabel">وزن تقریبی</span>
|
||||
<span class="pd__figureValue">
|
||||
{{ min_gram }}
|
||||
<span class="pd__figureUnit">گرم</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="w-full flex-1 flex">
|
||||
<Button
|
||||
type="primary"
|
||||
:ghost="selectedVariant?.id !== item.id"
|
||||
class="last:ml-0 ml-[8px] whitespace-nowrap btn-small"
|
||||
@click="selectedVariant = item"
|
||||
<div v-if="wage != null" class="pd__figure">
|
||||
<span class="pd__figureLabel">اجرت</span>
|
||||
<span class="pd__figureValue">
|
||||
{{ wageLabel }}
|
||||
<span class="pd__figureUnit">درصد</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Variants -->
|
||||
<section v-if="variants && variants.length > 1" class="pd__block">
|
||||
<h2 class="pd__blockTitle">انتخاب وزن</h2>
|
||||
<div class="pd__variants no-scrollbar">
|
||||
<button
|
||||
v-for="item in variants"
|
||||
:key="item.id"
|
||||
>{{ item.title }}</Button
|
||||
type="button"
|
||||
class="pd__variant"
|
||||
:class="{ 'is-on': selectedVariant?.id === item.id }"
|
||||
@click="selectedVariant = item"
|
||||
>
|
||||
<span class="pd__variantTitle">{{ item.title }}</span>
|
||||
<span class="pd__variantWeight">{{ Number(item.rashWeightGrams) }} گرم</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-[55px]"></div>
|
||||
<div class="w-full fixed bottom-0 z-20 left-0 right-0">
|
||||
<div class="w-full bg-white flex justify-between p-4 border-t max-w-[768px] m-auto shadowset border-solid border-primary/10">
|
||||
<Button type="primary" class="max-w-[768px] m-auto" size="large" block v-if="!cart.isInCart(storeProduct)" @click="isUserLoggedIn() ? addToCart() : (loginNotify = true)">
|
||||
ثبت سفارش
|
||||
</Button>
|
||||
<router-link class="w-full max-w-[768px] m-auto" :to="{ name: 'cart' }" v-else>
|
||||
<Button type="primary" size="large" block>مشاهده سبد خرید</Button>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
<Drawer v-model:visible="showPopup" placement="bottom" :closable="false" height="60vh" :class="['henza-rounded-drawer rtl', 'bg-white', 'text-black']">
|
||||
<div class="relative">
|
||||
<div class="absolute top-0 z-10 w-full">
|
||||
<Icon icon="vuesax-linear:arrow-right-1" @click="showPopup = false" class="cursor-pointer float-right ml-[30px] rounded-[100%] hover:bg-white/10" size="25" />
|
||||
<h5>{{ title }}</h5>
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-[30px]"></div>
|
||||
<div class="whitespace-pre-wrap text-justify leading-[2.5rem] max-h-[350px] overflow-y-auto">{{ body }}</div>
|
||||
</Drawer>
|
||||
</container>
|
||||
</section>
|
||||
|
||||
<NotifyVant :timeOut="3000" v-model="notify" :color="'background-color: #22C55E'">
|
||||
<!-- Specs -->
|
||||
<section class="pd__block">
|
||||
<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>
|
||||
</div>
|
||||
<div v-if="wage != null" class="pd__spec">
|
||||
<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>
|
||||
<dd>{{ selectedVariant.title }}</dd>
|
||||
</div>
|
||||
<div v-if="categoryTitle" class="pd__spec">
|
||||
<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>
|
||||
<dd>{{ brandTitle }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<!-- Description -->
|
||||
<section v-if="body" class="pd__block">
|
||||
<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 ? 'بستن' : 'ادامه مطلب' }}
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action bar -->
|
||||
<div class="pd__bar">
|
||||
<div v-if="inCart" class="pd__qty">
|
||||
<button type="button" class="pd__qtyBtn" aria-label="افزایش" @click="increment">
|
||||
<Icon icon="vuesax-linear:add" size="16" />
|
||||
</button>
|
||||
<span class="pd__qtyValue">{{ cartQty }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="pd__qtyBtn"
|
||||
:aria-label="cartQty === 1 ? 'حذف' : 'کاهش'"
|
||||
@click="decrement"
|
||||
>
|
||||
<Icon :icon="cartQty === 1 ? 'vuesax-linear:trash' : 'vuesax-linear:minus-square'" size="16" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="!inCart"
|
||||
type="button"
|
||||
class="pd__cta"
|
||||
:disabled="!is_exist"
|
||||
@click="isUserLoggedIn() ? addToCart() : (loginNotify = true)"
|
||||
>
|
||||
{{ 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>
|
||||
</article>
|
||||
|
||||
<NotifyVant :timeOut="3000" v-model="notify" :color="'background-color: #14161A'">
|
||||
<div class="font-bold text-right mb-2">ثبت سفارش</div>
|
||||
<div>محصول با موفقیت به سبد خرید افزوده شد</div>
|
||||
</NotifyVant>
|
||||
|
||||
<DialogVant :timeOut="-1" v-model:show="loginNotify" :closeOnClickOverlay="true">
|
||||
<template v-slot:img>
|
||||
<img :src="loginDialogImage" alt="" class="opacity-80" />
|
||||
</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-[#1C2D56]" @click="router.push({ name: 'login' })">ورود/ثبت نام</div>
|
||||
<div class="text-center text-danger" @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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeMount, ref } from 'vue'
|
||||
import { computed, onBeforeMount, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Button, Drawer } from 'ant-design-vue'
|
||||
import Icon from '../icon/Icon.vue'
|
||||
import Slider from '../slider/Slider.vue'
|
||||
import Spinner from '../Spinner.vue'
|
||||
import NotifyVant from '../NotifyVant.vue'
|
||||
import DialogVant from '../DialogVant.vue'
|
||||
@@ -165,37 +229,44 @@ const props = defineProps<{ id: string }>()
|
||||
const emit = defineEmits<{ (e: 'closeDrawer'): void }>()
|
||||
|
||||
const router = useRouter()
|
||||
const favorites = useFavoritesStore()
|
||||
const cart = useCartStore()
|
||||
|
||||
// This component is used two ways, exactly as in legacy: embedded in a bottom
|
||||
// Drawer by the product cards, and (legacy route `singleProduct`, /product/:id)
|
||||
// as a standalone page for deep links. In the drawer case the parent listens
|
||||
// for closeDrawer; on the standalone route there is no parent, so fall back to
|
||||
// history.back() — legacy declared a goBack() for this but never wired it,
|
||||
// leaving the close button dead on the routed page.
|
||||
// Used two ways, as in legacy: embedded in a bottom Drawer by the product
|
||||
// cards, and as a standalone page for deep links (/product/:id). In the drawer
|
||||
// case the parent listens for closeDrawer; on the routed page there is no
|
||||
// parent, so fall back to history.back().
|
||||
const close = () => {
|
||||
emit('closeDrawer')
|
||||
if (router.currentRoute.value.name === 'singleProduct') router.back()
|
||||
}
|
||||
const favorites = useFavoritesStore()
|
||||
const cart = useCartStore()
|
||||
|
||||
const showPopup = ref(false)
|
||||
const notify = ref(false)
|
||||
const loginNotify = ref(false)
|
||||
const promotion = ref<{ discount_percent?: number } | null>(null) // no promotions backend yet
|
||||
const imageIndex = ref(0)
|
||||
const bodyOpen = ref(false)
|
||||
|
||||
const productDetail = useProductDetail(props.id)
|
||||
const loading = productDetail.loading
|
||||
const flag = productDetail.flag
|
||||
|
||||
// useProduct needs a real ProductSummary; give it an empty placeholder until
|
||||
// the detail fetch resolves, matching legacy's own pattern of an initial
|
||||
// empty-shaped `product` ref before data arrives.
|
||||
const emptyProduct = { id: props.id, title: '', description: null, variants: [], images: [], category: null, brand: null, collections: [] }
|
||||
// the detail fetch resolves.
|
||||
const emptyProduct = {
|
||||
id: props.id,
|
||||
title: '',
|
||||
description: null,
|
||||
variants: [],
|
||||
images: [],
|
||||
category: null,
|
||||
brand: null,
|
||||
collections: [],
|
||||
}
|
||||
const productAdapter = computed(() => useProduct(productDetail.productRef.value ?? emptyProduct))
|
||||
|
||||
const id = computed(() => productAdapter.value.id.value)
|
||||
const title = computed(() => productAdapter.value.title.value)
|
||||
const productCode = computed(() => productAdapter.value.productCode.value)
|
||||
const variants = computed(() => productAdapter.value.variants.value)
|
||||
const selectedVariant = computed({
|
||||
get: () => productAdapter.value.selectedVariant.value,
|
||||
@@ -203,26 +274,59 @@ const selectedVariant = computed({
|
||||
})
|
||||
const min_gram = computed(() => productAdapter.value.min_gram.value)
|
||||
const wage = computed(() => productAdapter.value.wage.value)
|
||||
const hasDiscountWage = computed(() => productAdapter.value.hasDiscountWage.value)
|
||||
const qty = computed(() => productAdapter.value.qty.value)
|
||||
const isPreOrder = computed(() => productAdapter.value.isPreOrder.value)
|
||||
const is_exist = computed(() => productAdapter.value.is_exist.value)
|
||||
|
||||
const body = computed(() => productDetail.productRef.value?.description ?? '')
|
||||
const bodyIsLong = computed(() => body.value.length > 120)
|
||||
const categoryTitle = computed(() => productDetail.productRef.value?.category?.title ?? '')
|
||||
const brandTitle = computed(() => productDetail.productRef.value?.brand?.title ?? '')
|
||||
const hasImages = computed(() => (productDetail.productRef.value?.images.length ?? 0) > 0)
|
||||
const slides = computed(() => productDetail.productRef.value?.images.map((i) => i.url) ?? [])
|
||||
|
||||
// legacy: myStore.bankerPermission && hasDiscountWage && isUserLoggedIn() — no
|
||||
// banker/login state exists yet, so this always evaluates false for now.
|
||||
const showDiscountWage = computed(() => false && hasDiscountWage.value && isUserLoggedIn())
|
||||
// Keep the gallery index valid if the product (and so its image list) changes.
|
||||
watch(slides, (list) => {
|
||||
if (imageIndex.value > list.length - 1) imageIndex.value = 0
|
||||
})
|
||||
|
||||
const wageLabel = computed(() => {
|
||||
const w = wage.value
|
||||
if (w == null) return ''
|
||||
return w % 1 === 0 ? String(w) : w.toFixed(2)
|
||||
})
|
||||
|
||||
const stockLabel = computed(() => {
|
||||
if (qty.value > 0) return `${qty.value} عدد موجود`
|
||||
return isPreOrder.value ? 'پیشسفارش' : 'ناموجود'
|
||||
})
|
||||
const stockClass = computed(() => {
|
||||
if (qty.value > 0) return 'is-in'
|
||||
return isPreOrder.value ? 'is-pre' : 'is-out'
|
||||
})
|
||||
|
||||
const storeProduct = computed<CartItem>(() => ({
|
||||
vid: selectedVariant.value?.id ?? '',
|
||||
note: null,
|
||||
pid: productDetail.productRef.value?.id ?? props.id,
|
||||
product: productDetail.productRef.value ?? (emptyProduct as any),
|
||||
product: productDetail.productRef.value ?? (emptyProduct as never),
|
||||
qty: 1,
|
||||
}))
|
||||
|
||||
onBeforeMount(async () => {
|
||||
await productDetail.load()
|
||||
})
|
||||
// Quantity control reuses the store's updateFeild(...,'qty') path, the same one
|
||||
// CartItem uses; the store has no dedicated increment/decrement.
|
||||
const inCart = computed(() => cart.isInCart(storeProduct.value))
|
||||
const cartIndex = computed(() => cart.find_index(storeProduct.value))
|
||||
const cartQty = computed(() => (cartIndex.value > -1 ? cart.items[cartIndex.value].qty : 0))
|
||||
|
||||
const increment = () => cart.updateFeild(cartQty.value + 1, cartIndex.value, storeProduct.value, 'qty')
|
||||
const decrement = () => {
|
||||
if (cartQty.value <= 1) {
|
||||
cart.remove(storeProduct.value)
|
||||
return
|
||||
}
|
||||
cart.updateFeild(cartQty.value - 1, cartIndex.value, storeProduct.value, 'qty')
|
||||
}
|
||||
|
||||
const addToCart = () => {
|
||||
cart.addToCart(storeProduct.value)
|
||||
@@ -234,9 +338,400 @@ const toggleFav = (e: Event) => {
|
||||
favorites.toggle(props.id)
|
||||
}
|
||||
|
||||
// CRITICAL EXISTING ISSUE, ported as-is — see the file-level comment above.
|
||||
history.pushState(null, location.href)
|
||||
window.onpopstate = function () {
|
||||
history.go(1)
|
||||
}
|
||||
onBeforeMount(async () => {
|
||||
await productDetail.load()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.pd {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
background: @canvasSoft;
|
||||
padding-bottom: 104px;
|
||||
}
|
||||
|
||||
/* ── Gallery ─────────────────────────────────────────────── */
|
||||
.pd__gallery {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.pd__stage {
|
||||
position: relative;
|
||||
aspect-ratio: 1 / 1;
|
||||
max-height: 52vh;
|
||||
background: @hairlineSoft;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pd__stage--empty {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: @inkFaint;
|
||||
}
|
||||
|
||||
.pd__art {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.pd__round {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
z-index: 5;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
color: @inkSoft;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
backdrop-filter: blur(8px);
|
||||
box-shadow: 0 6px 18px -10px rgba(20, 22, 26, 0.5);
|
||||
transition: transform 0.18s ease, color 0.18s ease;
|
||||
}
|
||||
.pd__round:active {
|
||||
transform: scale(0.92);
|
||||
}
|
||||
.pd__round--back {
|
||||
inset-inline-end: 16px;
|
||||
}
|
||||
.pd__round--fav {
|
||||
inset-inline-start: 16px;
|
||||
}
|
||||
.pd__round--fav.is-on {
|
||||
color: @wine;
|
||||
}
|
||||
|
||||
.pd__counter {
|
||||
position: absolute;
|
||||
bottom: 14px;
|
||||
inset-inline-start: 14px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
background: rgba(20, 22, 26, 0.62);
|
||||
backdrop-filter: blur(4px);
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
.pd__dots {
|
||||
position: absolute;
|
||||
bottom: 14px;
|
||||
inset-inline-end: 14px;
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
}
|
||||
.pd__dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
background: rgba(255, 255, 255, 0.55);
|
||||
transition: width 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
.pd__dot.is-on {
|
||||
width: 18px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* ── Sheet ───────────────────────────────────────────────── */
|
||||
.pd__sheet {
|
||||
position: relative;
|
||||
margin-top: -20px;
|
||||
border-radius: 22px 22px 0 0;
|
||||
background: @canvas;
|
||||
padding: 20px 18px 8px;
|
||||
}
|
||||
|
||||
.pd__head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pd__title {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
font-weight: 800;
|
||||
line-height: 1.7;
|
||||
color: @ink;
|
||||
}
|
||||
|
||||
.pd__sub {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding-top: 5px;
|
||||
font-size: 11.5px;
|
||||
color: @inkFaint;
|
||||
}
|
||||
.pd__code {
|
||||
direction: ltr;
|
||||
font-weight: 700;
|
||||
color: @inkMuted;
|
||||
}
|
||||
.pd__dotSep {
|
||||
color: @hairline;
|
||||
}
|
||||
|
||||
.pd__stock {
|
||||
flex-shrink: 0;
|
||||
padding: 6px 11px;
|
||||
border-radius: 999px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pd__stock.is-in {
|
||||
color: #15803d;
|
||||
background: #eef7f0;
|
||||
}
|
||||
.pd__stock.is-pre {
|
||||
color: @wine;
|
||||
background: @wineWash;
|
||||
}
|
||||
.pd__stock.is-out {
|
||||
color: @inkMuted;
|
||||
background: @hairlineSoft;
|
||||
}
|
||||
|
||||
/* ── Figures ─────────────────────────────────────────────── */
|
||||
.pd__figures {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.pd__figure {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 13px 14px;
|
||||
border-radius: 16px;
|
||||
background: @hairlineSoft;
|
||||
}
|
||||
.pd__figureLabel {
|
||||
font-size: 11px;
|
||||
color: @inkFaint;
|
||||
}
|
||||
.pd__figureValue {
|
||||
font-size: 19px;
|
||||
font-weight: 800;
|
||||
color: @ink;
|
||||
direction: ltr;
|
||||
text-align: end;
|
||||
}
|
||||
.pd__figureUnit {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: @inkFaint;
|
||||
padding-inline-start: 3px;
|
||||
}
|
||||
|
||||
/* ── Blocks ──────────────────────────────────────────────── */
|
||||
.pd__block {
|
||||
margin-top: 22px;
|
||||
}
|
||||
.pd__blockTitle {
|
||||
margin: 0 0 11px;
|
||||
font-size: 13.5px;
|
||||
font-weight: 800;
|
||||
color: @ink;
|
||||
}
|
||||
|
||||
.pd__variants {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.pd__variant {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 14px;
|
||||
cursor: pointer;
|
||||
text-align: start;
|
||||
background: @canvas;
|
||||
border: 1px solid @hairline;
|
||||
transition: border-color 0.18s ease, background 0.18s ease;
|
||||
}
|
||||
.pd__variant.is-on {
|
||||
background: @ink;
|
||||
border-color: @ink;
|
||||
}
|
||||
.pd__variantTitle {
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
color: @ink;
|
||||
}
|
||||
.pd__variantWeight {
|
||||
font-size: 10.5px;
|
||||
color: @inkFaint;
|
||||
}
|
||||
.pd__variant.is-on .pd__variantTitle {
|
||||
color: #fff;
|
||||
}
|
||||
.pd__variant.is-on .pd__variantWeight {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.pd__specs {
|
||||
margin: 0;
|
||||
border: 1px solid @hairline;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.pd__spec {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid @hairlineSoft;
|
||||
}
|
||||
.pd__spec:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.pd__spec dt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
color: @inkMuted;
|
||||
}
|
||||
.pd__spec dd {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
color: @ink;
|
||||
}
|
||||
|
||||
.pd__body {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
line-height: 2.1;
|
||||
color: @inkMuted;
|
||||
text-align: justify;
|
||||
}
|
||||
.pd__body.is-clamped {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.pd__more {
|
||||
margin-top: 8px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 11.5px;
|
||||
font-weight: 700;
|
||||
color: @wine;
|
||||
}
|
||||
|
||||
.pd__trust {
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
margin-top: 22px;
|
||||
padding: 13px 0 4px;
|
||||
border-top: 1px solid @hairlineSoft;
|
||||
}
|
||||
.pd__trust span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11.5px;
|
||||
color: @inkFaint;
|
||||
}
|
||||
|
||||
/* ── Action bar ──────────────────────────────────────────── */
|
||||
.pd__bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
inset-inline: 0;
|
||||
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;
|
||||
}
|
||||
|
||||
.pd__qty {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
flex-shrink: 0;
|
||||
padding: 7px 9px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid @hairline;
|
||||
background: @hairlineSoft;
|
||||
}
|
||||
.pd__qtyBtn {
|
||||
width: 27px;
|
||||
height: 27px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
color: @ink;
|
||||
background: @canvas;
|
||||
}
|
||||
.pd__qtyValue {
|
||||
min-width: 16px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
color: @ink;
|
||||
}
|
||||
|
||||
.pd__cta {
|
||||
flex: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
height: 48px;
|
||||
border: 0;
|
||||
border-radius: 14px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
text-decoration: none;
|
||||
color: #fff;
|
||||
background: @ink;
|
||||
transition: transform 0.18s ease, opacity 0.18s ease;
|
||||
}
|
||||
.pd__cta:active {
|
||||
transform: scale(0.99);
|
||||
}
|
||||
.pd__cta:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.45;
|
||||
}
|
||||
.pd__cta--ghost {
|
||||
color: @ink;
|
||||
background: @canvas;
|
||||
border: 1px solid @ink;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -36,7 +36,13 @@ export function useProduct(productItem: ProductSummary, initialVariant?: Product
|
||||
const min_gram = computed(() => selectedVariant.value ? parseFloat(selectedVariant.value.rashWeightGrams) : 0)
|
||||
const wage = computed(() => (selectedVariant.value ? parseFloat(selectedVariant.value.defaultWagePercent) : null))
|
||||
const hasDiscountWage = ref<number | null>(null) // no promotions backend yet — see file header
|
||||
const is_exist = ref(true) // no stock field on ProductVariant yet
|
||||
// REAL stock: ProductVariant.qty / isPreOrder exist and are returned by
|
||||
// GET /catalog/products. A variant is orderable when it has stock, or when
|
||||
// it is flagged pre-order — the same rule the admin cart uses.
|
||||
const qty = computed(() => selectedVariant.value?.qty ?? 0)
|
||||
const isPreOrder = computed(() => selectedVariant.value?.isPreOrder ?? false)
|
||||
const is_exist = computed(() => qty.value > 0 || isPreOrder.value)
|
||||
const productCode = computed(() => product.value.productCode ?? null)
|
||||
const has_warranty = ref(false) // no warranty field on ProductVariant yet
|
||||
|
||||
return {
|
||||
@@ -44,11 +50,14 @@ export function useProduct(productItem: ProductSummary, initialVariant?: Product
|
||||
image,
|
||||
id,
|
||||
title,
|
||||
productCode,
|
||||
variants,
|
||||
selectedVariant,
|
||||
min_gram,
|
||||
wage,
|
||||
hasDiscountWage,
|
||||
qty,
|
||||
isPreOrder,
|
||||
is_exist,
|
||||
has_warranty,
|
||||
}
|
||||
|
||||
@@ -1,126 +1,381 @@
|
||||
<!--
|
||||
Ported from legacy henza_pwa/src/views/home/index.vue.
|
||||
Redesigned home screen (replaces the previous port of legacy home/index.vue).
|
||||
|
||||
Honest adaptations:
|
||||
- legacy fetched a single curated payload from POST /front (categories
|
||||
with icons, a hand-picked "newest" list, a hand-picked "shelf" list, a
|
||||
home banner slider config). The new API only exposes GET
|
||||
/catalog/products (flat, uncurated), so `configStore` supplies the
|
||||
banner slides (placeholder artwork, legacy's exact slide shape) and
|
||||
derives the real category list from the product catalogue. The two
|
||||
product sections both draw from the same flat product list (first 4 as
|
||||
"newest", the rest as the shelf) rather than two independently curated
|
||||
lists.
|
||||
- the "please login" nudge (DialogVant, 5s after load) is ported and will
|
||||
fire, since there is genuinely no login yet — matching legacy's real
|
||||
behavior for an unauthenticated visitor.
|
||||
- the notification bell (`NotificationList`) was already commented out of
|
||||
legacy's own template — not ported, matching legacy's actual current state.
|
||||
Layout intent — shop first, account second:
|
||||
1. a slim brand bar with search + cart
|
||||
2. the live 18k rate strip (real: GET /pricing/latest)
|
||||
3. an editorial showcase hero built from real categories
|
||||
4. the category rail (real: derived from GET /catalog/products)
|
||||
5. "برگزیده" — a wide feature card + a two-column grid
|
||||
6. "تازهها" — a horizontal rail
|
||||
7. the account block (real: GET /customers/:id when signed in)
|
||||
|
||||
Data honesty, carried over from the previous version deliberately:
|
||||
- the API exposes no curated home payload, no promotions and no per-product
|
||||
price, so nothing on this page prints a rial price for a piece. Weight and
|
||||
making-charge percent are real per-variant fields; everything else shown
|
||||
is a label, not invented data.
|
||||
- categories come from the catalogue itself, so an empty catalogue renders
|
||||
an empty state instead of placeholder tiles.
|
||||
-->
|
||||
<template>
|
||||
<Spinner :spinning="loading" tip="درحال دریافت اطلاعات">
|
||||
<AppHeader>
|
||||
<nav class="flex justify-between items-center">
|
||||
<span></span>
|
||||
<img src="/img/henza1.svg" alt="هنزاگلد" />
|
||||
</nav>
|
||||
</AppHeader>
|
||||
<container class="px-[16px] pb-[66px]" v-if="flag">
|
||||
<HomeSlider class="z-0 mx-[-16px]" :slides="memory.config.main_slider" :height="sliderHeight" />
|
||||
<section>
|
||||
<h5 class="font-bold">دسته بندی ها</h5>
|
||||
<div class="no-scrollbar grid grid-cols-3 gap-4 mt-[16px] items-stretch">
|
||||
<Category class="h-full" v-for="item in memory.cats" :key="item.id" :value="item" />
|
||||
</div>
|
||||
<Divider class="border-grey"><img v-lazy="vectorIcon" alt="svg" class="mx-[10px]" /></Divider>
|
||||
<h4 class="font-bold">محصولات جدید</h4>
|
||||
<ProductHorizontal class="mt-[10px]" v-for="item in newestProducts" :key="item.id" :value="item" />
|
||||
<router-link :to="{ name: 'newestProduct' }">
|
||||
<Button :type="('light' as any)" class="mt-[16px]" block size="large">
|
||||
<template #icon>
|
||||
<Icon icon="vuesax-linear:arrow-left-1" class="cursor-pointer text-primary" size="25" />
|
||||
</template>
|
||||
مشاهده همه
|
||||
</Button>
|
||||
<div class="home">
|
||||
<header class="home__bar">
|
||||
<div class="home__barInner">
|
||||
<router-link :to="{ name: 'cart' }" class="home__iconBtn" aria-label="سبد خرید">
|
||||
<Icon icon="vuesax-linear:bag-2" size="21" />
|
||||
<span v-if="cart.items.length" class="home__badge">{{ cart.items.length }}</span>
|
||||
</router-link>
|
||||
<Divider class="border-grey"><img v-lazy="vectorIcon" alt="svg" class="mx-[10px]" /></Divider>
|
||||
<div class="no-scrollbar flex overflow-x-auto mt-[16px] mx-[-16px] px-[16px]">
|
||||
<ProductVertical class="flex-[0_0_280px] ml-[16px]" v-for="item in shelfProducts" :key="item.id" :value="item" />
|
||||
<ViewAll class="last:ml-0 flex-[0_0_280px]" to="/search" />
|
||||
|
||||
<img src="/img/henza1.svg" alt="هنزاگلد" class="home__logo" />
|
||||
|
||||
<router-link :to="{ name: 'search' }" class="home__iconBtn" aria-label="جستجو">
|
||||
<Icon icon="vuesax-linear:search-normal-1" size="21" />
|
||||
</router-link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<container class="home__body">
|
||||
<RateStrip :quote="goldPrice" class="home__rate" />
|
||||
|
||||
<ShowcaseSlider v-if="slides.length" :slides="slides" class="home__hero" @select="onSlide" />
|
||||
|
||||
<CategoryRail
|
||||
v-if="memory.cats.length"
|
||||
:categories="memory.cats"
|
||||
:active-id="null"
|
||||
class="home__rail"
|
||||
@select="onCategory"
|
||||
/>
|
||||
|
||||
<section v-if="featured" class="home__section">
|
||||
<SectionHead
|
||||
title="برگزیده هنزا"
|
||||
subtitle="انتخابشده از میان مجموعههای موجود"
|
||||
action-label="همه محصولات"
|
||||
@action="goSearch"
|
||||
/>
|
||||
<ShowcaseCard :value="featured" wide badge="برگزیده" @open="openProduct" />
|
||||
|
||||
<div v-if="gridProducts.length" class="home__grid">
|
||||
<ShowcaseCard v-for="item in gridProducts" :key="item.id" :value="item" @open="openProduct" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="newestProducts.length" class="home__section">
|
||||
<SectionHead
|
||||
title="تازهها"
|
||||
subtitle="آخرین افزودههای مجموعه"
|
||||
action-label="مشاهده همه"
|
||||
@action="goNewest"
|
||||
/>
|
||||
<div class="home__reel no-scrollbar">
|
||||
<ShowcaseCard
|
||||
v-for="item in newestProducts"
|
||||
:key="item.id"
|
||||
:value="item"
|
||||
class="home__reelItem"
|
||||
@open="openProduct"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="!loading && !products.length" class="home__empty">
|
||||
<Icon icon="vuesax-linear:box" size="26" />
|
||||
<p>هنوز محصولی در مجموعه ثبت نشده است.</p>
|
||||
</section>
|
||||
|
||||
<section class="home__section">
|
||||
<SectionHead title="حساب من" subtitle="موجودی و دسترسیهای شما" />
|
||||
<AccountStrip :customer="customer" @go="onAccountNav" @login="goLogin" />
|
||||
</section>
|
||||
|
||||
<div class="home__trust">
|
||||
<div class="home__trustItem">
|
||||
<Icon icon="vuesax-linear:shield-tick" size="18" />
|
||||
<span>خرید امن</span>
|
||||
</div>
|
||||
<div class="home__trustItem">
|
||||
<Icon icon="vuesax-linear:verify" size="18" />
|
||||
<span>اصالت کالا</span>
|
||||
</div>
|
||||
<div class="home__trustItem">
|
||||
<Icon icon="vuesax-linear:box-tick" size="18" />
|
||||
<span>ارسال بیمهشده</span>
|
||||
</div>
|
||||
</div>
|
||||
</container>
|
||||
<DialogVant :timeOut="-1" v-model:show="loginNotify" :closeOnClickOverlay="true">
|
||||
<template v-slot:img>
|
||||
<img :src="loginDialogImage" alt="" class="opacity-80" />
|
||||
</template>
|
||||
<template v-slot:title>
|
||||
<div class="p-8 text-justify">برای دسترسی به تمامی امکانات نرم افزار هنزاگلد، ابتدا گزینه ورود/ثبت نام را لمس کنید و در صورت نداشتن حساب کاربری، ثبت نام نمایید.</div>
|
||||
</template>
|
||||
<template v-slot:button>
|
||||
<div class="text-center text-[#1C2D56]" @click="router.push({ name: 'login' })">ورود/ثبت نام</div>
|
||||
<div class="text-center text-danger" @click="loginNotify = false">بیخیال</div>
|
||||
</template>
|
||||
</DialogVant>
|
||||
</Spinner>
|
||||
|
||||
<Drawer
|
||||
v-model:visible="productDrawer"
|
||||
placement="bottom"
|
||||
:closable="false"
|
||||
class="rtl"
|
||||
height="100vh"
|
||||
:bodyStyle="{ padding: 0, position: 'relative' }"
|
||||
>
|
||||
<SingleProduct v-if="activeProductId" :id="activeProductId" @closeDrawer="productDrawer = false" />
|
||||
</Drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Button, Divider } from 'ant-design-vue'
|
||||
import { Drawer } from 'ant-design-vue'
|
||||
import Icon from '../components/icon/Icon.vue'
|
||||
import AppHeader from '../components/AppHeader.vue'
|
||||
import Spinner from '../components/Spinner.vue'
|
||||
import DialogVant from '../components/DialogVant.vue'
|
||||
import Category from '../components/product/Category.vue'
|
||||
import ProductHorizontal from '../components/product/ProductHorizontal.vue'
|
||||
import ProductVertical from '../components/product/ProductVertical.vue'
|
||||
import ViewAll from '../components/ViewAll.vue'
|
||||
import HomeSlider from '../components/slider/HomeSlider.vue'
|
||||
import vectorIcon from '../assets/img/Vector.svg'
|
||||
import loginDialogImage from '../assets/img/empty/dialogEmpty1.svg'
|
||||
import { useWindowDataStore } from '../stores/windowDataStore'
|
||||
import SingleProduct from '../components/product/SingleProduct.vue'
|
||||
import RateStrip from '../components/home/RateStrip.vue'
|
||||
import ShowcaseSlider from '../components/home/ShowcaseSlider.vue'
|
||||
import type { ShowcaseSlide } from '../components/home/ShowcaseSlider.vue'
|
||||
import CategoryRail from '../components/home/CategoryRail.vue'
|
||||
import ShowcaseCard from '../components/home/ShowcaseCard.vue'
|
||||
import SectionHead from '../components/home/SectionHead.vue'
|
||||
import AccountStrip from '../components/home/AccountStrip.vue'
|
||||
import { useConfigStore } from '../stores/configStore'
|
||||
import { isUserLoggedIn } from '../utils/checkAccess'
|
||||
import { api, type ProductSummary } from '../api/client'
|
||||
import type { HomeCategory } from '../stores/configStore'
|
||||
import { useCartStore } from '../stores/cartStore'
|
||||
import { useFiltersStore } from '../stores/filtersStore'
|
||||
import { useSessionStore } from '../stores/session'
|
||||
import { api } from '../api/client'
|
||||
import type { CustomerDetail, GoldPriceQuote, ProductSummary } from '../api/client'
|
||||
|
||||
const router = useRouter()
|
||||
const windowData = useWindowDataStore()
|
||||
// legacy's `memory` — the global config store (banner slider + categories)
|
||||
const memory = useConfigStore()
|
||||
const cart = useCartStore()
|
||||
const filters = useFiltersStore()
|
||||
const session = useSessionStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const flag = ref(false)
|
||||
const loginNotify = ref(false)
|
||||
const loading = ref(true)
|
||||
const products = ref<ProductSummary[]>([])
|
||||
const goldPrice = ref<GoldPriceQuote | null>(null)
|
||||
const customer = ref<CustomerDetail | null>(null)
|
||||
|
||||
const newestProducts = computed(() => products.value.slice(0, 4))
|
||||
const shelfProducts = computed(() => products.value.slice(4))
|
||||
const productDrawer = ref(false)
|
||||
const activeProductId = ref<string | null>(null)
|
||||
|
||||
const sliderHeight = computed(() => Math.min(windowData.width, 768) / 2.2125 + 'px')
|
||||
const featured = computed<ProductSummary | null>(() => products.value[0] ?? null)
|
||||
const gridProducts = computed(() => products.value.slice(1, 5))
|
||||
const newestProducts = computed(() => products.value.slice(5, 13))
|
||||
|
||||
const getFront = async () => {
|
||||
// The hero is built from REAL categories, not a curated banner payload (none
|
||||
// exists). Each slide deep-links into search filtered by that category, so the
|
||||
// artwork is decorative but the destination is genuine.
|
||||
const SLIDE_COPY = [
|
||||
{ eyebrow: 'مجموعه هنزا', cta: 'دیدن مجموعه' },
|
||||
{ eyebrow: 'انتخاب سردبیر', cta: 'مشاهده' },
|
||||
{ eyebrow: 'پیشنهاد این هفته', cta: 'بررسی کنید' },
|
||||
]
|
||||
|
||||
const slides = computed<ShowcaseSlide[]>(() => {
|
||||
const cats = memory.cats.slice(0, 3)
|
||||
if (!cats.length) return []
|
||||
return cats.map((cat, i) => ({
|
||||
key: cat.id,
|
||||
eyebrow: SLIDE_COPY[i % SLIDE_COPY.length].eyebrow,
|
||||
title: cat.title,
|
||||
subtitle: 'ساختهشده از طلای ۱۸ عیار، با اجرت شفاف و اصالت تضمینشده.',
|
||||
cta: SLIDE_COPY[i % SLIDE_COPY.length].cta,
|
||||
image: productImageForCategory(cat.id),
|
||||
categoryId: cat.id,
|
||||
}))
|
||||
})
|
||||
|
||||
function productImageForCategory(categoryId: string): string | undefined {
|
||||
const match = products.value.find((p) => p.category?.id === categoryId)
|
||||
return match?.images?.[0]?.url
|
||||
}
|
||||
|
||||
function openProduct(product: ProductSummary) {
|
||||
activeProductId.value = product.id
|
||||
productDrawer.value = true
|
||||
}
|
||||
|
||||
function onCategory(cat: HomeCategory) {
|
||||
filters.query.cats = [cat.id]
|
||||
router.push({ name: 'search' })
|
||||
}
|
||||
|
||||
function onSlide(slide: ShowcaseSlide) {
|
||||
if (slide.categoryId) {
|
||||
filters.query.cats = [slide.categoryId]
|
||||
}
|
||||
router.push({ name: 'search' })
|
||||
}
|
||||
|
||||
function onAccountNav(routeName: string) {
|
||||
router.push({ name: routeName })
|
||||
}
|
||||
|
||||
function goSearch() {
|
||||
router.push({ name: 'search' })
|
||||
}
|
||||
function goNewest() {
|
||||
router.push({ name: 'newestProduct' })
|
||||
}
|
||||
function goLogin() {
|
||||
router.push({ name: 'login' })
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
products.value = await api.listProducts()
|
||||
flag.value = true
|
||||
|
||||
setTimeout(() => {
|
||||
if (!isUserLoggedIn()) {
|
||||
loginNotify.value = true
|
||||
}
|
||||
}, 5000)
|
||||
const [list] = await Promise.all([api.listProducts(), memory.init()])
|
||||
products.value = list
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// legacy home/index.vue ran getFront() alongside the global store init, which
|
||||
// is what populated the slider + category grid.
|
||||
await Promise.all([getFront(), memory.init()])
|
||||
try {
|
||||
goldPrice.value = await api.getLatestGoldPrice()
|
||||
} catch {
|
||||
goldPrice.value = null
|
||||
}
|
||||
|
||||
if (session.customerId) {
|
||||
try {
|
||||
customer.value = await api.getCustomer(session.customerId)
|
||||
} catch {
|
||||
customer.value = null
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.home {
|
||||
background: @canvas;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.home__bar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
backdrop-filter: blur(14px);
|
||||
border-bottom: 1px solid @hairline;
|
||||
}
|
||||
|
||||
.home__barInner {
|
||||
max-width: 768px;
|
||||
margin: 0 auto;
|
||||
padding: 11px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.home__logo {
|
||||
height: 21px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.home__iconBtn {
|
||||
position: relative;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 12px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: @ink;
|
||||
background: @hairlineSoft;
|
||||
transition: background 0.18s ease;
|
||||
}
|
||||
|
||||
.home__badge {
|
||||
position: absolute;
|
||||
top: -3px;
|
||||
left: -3px;
|
||||
min-width: 17px;
|
||||
height: 17px;
|
||||
padding: 0 4px;
|
||||
border-radius: 999px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
background: @wine;
|
||||
border: 2px solid #fff;
|
||||
}
|
||||
|
||||
.home__body {
|
||||
padding-bottom: 90px;
|
||||
}
|
||||
|
||||
.home__rate {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.home__hero {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.home__rail {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.home__section {
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.home__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 11px;
|
||||
margin-top: 11px;
|
||||
}
|
||||
|
||||
.home__reel {
|
||||
display: flex;
|
||||
gap: 11px;
|
||||
overflow-x: auto;
|
||||
margin: 0 -16px;
|
||||
padding: 0 16px;
|
||||
scroll-snap-type: x proximity;
|
||||
}
|
||||
|
||||
.home__reelItem {
|
||||
flex: 0 0 154px;
|
||||
scroll-snap-align: start;
|
||||
}
|
||||
|
||||
.home__empty {
|
||||
margin-top: 28px;
|
||||
padding: 34px 20px;
|
||||
border: 1px dashed @hairline;
|
||||
border-radius: 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: @inkFaint;
|
||||
}
|
||||
.home__empty p {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.home__trust {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 9px;
|
||||
margin-top: 26px;
|
||||
}
|
||||
|
||||
.home__trustItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 14px 6px;
|
||||
border-radius: 14px;
|
||||
background: @hairlineSoft;
|
||||
color: @inkMuted;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -22,5 +22,17 @@
|
||||
"gray-400": "#9ca3af",
|
||||
"gray-500": "#6b7280",
|
||||
"green": "#16a34a",
|
||||
"red": "#dc2626"
|
||||
"red": "#dc2626",
|
||||
"ink": "#14161A",
|
||||
"inkSoft": "#22262E",
|
||||
"inkMuted": "#5B616E",
|
||||
"inkFaint": "#8A909C",
|
||||
"wine": "#7A1B33",
|
||||
"wineDeep": "#5E1427",
|
||||
"wineSoft": "#A33C55",
|
||||
"wineWash": "#FBF2F4",
|
||||
"canvas": "#FFFFFF",
|
||||
"canvasSoft": "#FAFAFA",
|
||||
"hairline": "#ECEDEF",
|
||||
"hairlineSoft": "#F4F5F6"
|
||||
}
|
||||
|
||||