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>
This commit is contained in:
s.esmaeili
2026-09-14 23:05:29 +03:30
co-authored by Claude Opus 5
parent a12fb0450e
commit f2d83c53df
41 changed files with 2041 additions and 241 deletions
@@ -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

+9
View File
@@ -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>
+10 -1
View File
@@ -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,
}
+349 -94
View File
@@ -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>
+13 -1
View File
@@ -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"
}