Redesign orders/cart/search flows and fix RTL/auth edge cases in user-app

- Fix silent redirect-to-home for unauthenticated users on protected routes;
  now redirects to login with a return path
- Restyle OrderCard, SearchInput, Filters, DropDown, and product card image
  sizing/centering to match the app's LESS-token design language
- Fix ant-design Slider handle placement/drag under RTL and seed a valid
  default range so handles actually render
- Fix chip-row edge padding and top spacing across Orders/Cart/Search lists
- Add pull-to-refresh to the dashboard and fix its header/padding to match
  the fixed PageBar pattern
- Remove the floating WhatsApp button from index.html
This commit is contained in:
s.esmaeili
2026-09-16 01:15:16 +03:30
parent 0e5f9ccd7b
commit e756fbaf85
30 changed files with 890 additions and 365 deletions
+1
View File
@@ -42,6 +42,7 @@ export default defineConfig(({ mode }) => {
sourcemap: false, // legacy: productionSourceMap: false
},
server: {
host: '0.0.0.0',
port: 5174,
proxy: {
'/api': 'http://localhost:3000',
+59
View File
@@ -0,0 +1,59 @@
// Generates distinct SVG placeholder images per product category, so the 200
// mock products render varied artwork instead of one shared no-items.png icon.
// Not photography — an honest stand-in, styled to the app's own gold/cream
// palette, with a distinct glyph + background tint per category so the grid
// reads as varied rather than one repeated tile.
const fs = require('fs');
const path = require('path');
const OUT_DIR = path.join(__dirname, '../../user-app/public/img/products');
fs.mkdirSync(OUT_DIR, { recursive: true });
// category slug -> [glyph path (viewBox 0 0 64 64), accent color]
const GLYPHS = {
rings: {
accent: '#B08D57',
path: '<circle cx="32" cy="40" r="14" fill="none" stroke="currentColor" stroke-width="4"/><circle cx="32" cy="20" r="6" fill="currentColor"/>',
},
bracelets: {
accent: '#8D6A46',
path: '<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"/>',
},
necklaces: {
accent: '#C9A24B',
path: '<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"/>',
},
earrings: {
accent: '#A67C52',
path: '<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"/>',
},
coins: {
accent: '#D4AF37',
path: '<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>',
},
};
// A handful of warm background tints so 200 tiles don't look identical even
// within one category.
const BG_TINTS = ['#F6F3EF', '#F3EADC', '#FAF5EC', '#F0E6D6', '#F8F1E6', '#EFE4D3'];
function svgFor(categorySlug, variantIndex) {
const g = GLYPHS[categorySlug] || GLYPHS.rings;
const bg = BG_TINTS[variantIndex % BG_TINTS.length];
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="480" height="480">
<rect width="64" height="64" fill="${bg}"/>
<g transform="translate(0,0)" color="${g.accent}" opacity="0.92">
${g.path}
</g>
</svg>`;
}
let written = 0;
for (const slug of Object.keys(GLYPHS)) {
for (let i = 0; i < 6; i++) {
const file = path.join(OUT_DIR, `${slug}-${i}.svg`);
fs.writeFileSync(file, svgFor(slug, i));
written++;
}
}
console.log(`wrote ${written} placeholder SVGs to ${OUT_DIR}`);
+118
View File
@@ -0,0 +1,118 @@
const fs = require('fs');
const path = require('path');
const seedPath = path.join(__dirname, 'seed.ts');
let content = fs.readFileSync(seedPath, 'utf-8');
const marker = ' imageCount += 1;\n }\n\n // \u2500\u2500 Featured collection';
if (!content.includes(marker)) {
console.error('MARKER NOT FOUND');
process.exit(1);
}
const insertion = ` imageCount += 1;
}
// \u2500\u2500 200 mock products for a realistic-looking catalogue \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
// These are generated, not hand-authored like the 5 above (whose ids are
// referenced elsewhere in this seed, e.g. by orders \u2014 left untouched).
// Images: real product photography does not exist in this project, so each
// mock product cycles through 6 generated SVG placeholders per category
// (apps/user-app/public/img/products/<slug>-N.svg), giving the catalogue
// grid genuine visual variety instead of one repeated icon.
const MOCK_CATEGORIES = [
{ slug: 'rings', id: ringsCategory.id, nouns: ['انگشتر نگین‌دار', 'انگشتر سولیتر', 'انگشتر آویزدار', 'انگشتر پهن', 'انگشتر ظریف', 'انگشتر رینگی'] },
{ slug: 'bracelets', id: braceletsCategory.id, nouns: ['دستبند زنجیری', 'دستبند پاندول', 'دستبند بافت', 'دستبند تنیس', 'دستبند پلاک‌دار', 'دستبند چرمی طلاکوب'] },
{ slug: 'necklaces', id: necklacesCategory.id, nouns: ['گردنبند آویز قلب', 'گردنبند ماه و ستاره', 'گردنبند ظریف', 'گردنبند رولو', 'گردنبند صلیب', 'گردنبند اسم'] },
{ slug: 'earrings', id: earringsCategory.id, nouns: ['گوشواره حلقه‌ای', 'گوشواره آویز', 'گوشواره میخی', 'گوشواره پروانه‌ای', 'گوشواره مرواریدی', 'گوشواره هندسی'] },
{ slug: 'coins', id: coinsCategory.id, nouns: ['ربع سکه بهار آزادی', 'نیم سکه بهار آزادی', 'سکه امامی', 'سکه پارسیان', 'شمش طلای ۱۰ گرمی', 'شمش طلای ۵ گرمی'] },
];
const MOCK_ADJECTIVES = ['کلاسیک', 'مدرن', 'مینیمال', 'لوکس', 'دست‌ساز', 'ظریف', 'خاص', 'شیک', 'سنتی', 'رزگلد'];
const MOCK_BRANDS = [brandHenza.id, brandArtisan.id];
const MOCK_PRODUCT_COUNT = 200;
let mockImageCount = 0;
for (let i = 0; i < MOCK_PRODUCT_COUNT; i++) {
const cat = MOCK_CATEGORIES[i % MOCK_CATEGORIES.length];
const noun = cat.nouns[i % cat.nouns.length];
const adjective = MOCK_ADJECTIVES[i % MOCK_ADJECTIVES.length];
const brandId = MOCK_BRANDS[i % MOCK_BRANDS.length];
const productId = \`mock-product-\${i + 1}\`;
const title = \`\${noun} \${adjective} \${i + 1}\`;
// Deterministic but varied numeric fields \u2014 no randomness, so a re-seed
// is stable and diffable.
const baseWeight = 1.5 + ((i * 37) % 250) / 20; // ~1.5g .. ~14g
const wagePercent = (6 + ((i * 13) % 12)).toFixed(1); // 6.0% .. 17.9%
const qty = (i * 7) % 15; // 0..14, so some are genuinely out of stock
const isPreOrder = i % 9 === 0;
const product = await prisma.product.upsert({
where: { id: productId },
update: {},
create: {
id: productId,
title,
description: \`\${noun} با طراحی \${adjective}، مناسب استفاده روزمره یا هدیه.\`,
categoryId: cat.id,
brandId,
productCode: \`P-\${String(i + 1).padStart(4, '0')}\`,
},
});
const variantId = \`\${productId}-variant-1\`;
await prisma.productVariant.upsert({
where: { id: variantId },
update: {},
create: {
id: variantId,
productId: product.id,
title: 'استاندارد',
sku: \`MOCK-\${String(i + 1).padStart(4, '0')}\`,
rashWeightGrams: baseWeight.toFixed(3),
defaultWagePercent: wagePercent,
qty,
isPreOrder,
},
});
// A second variant on ~1 in 4 products, so the "\u062a\u0646\u0648\u0639 \u0648\u0632\u0646\u06cc" chip row has
// something real to show on more than the 5 hand-authored products.
if (i % 4 === 0) {
const variant2Id = \`\${productId}-variant-2\`;
await prisma.productVariant.upsert({
where: { id: variant2Id },
update: {},
create: {
id: variant2Id,
productId: product.id,
title: 'سایز بزرگ',
sku: \`MOCK-\${String(i + 1).padStart(4, '0')}-L\`,
rashWeightGrams: (baseWeight * 1.35).toFixed(3),
defaultWagePercent: wagePercent,
qty: (qty + 3) % 15,
isPreOrder,
},
});
}
const imgIndex = i % 6;
const imageUrl = \`/img/products/\${cat.slug}-\${imgIndex}.svg\`;
await prisma.productImage.upsert({
where: { id: \`\${productId}-img-1\` },
update: { url: imageUrl },
create: {
id: \`\${productId}-img-1\`,
productId: product.id,
url: imageUrl,
sortOrder: 0,
},
});
mockImageCount += 1;
}
imageCount += mockImageCount;
// \u2500\u2500 Featured collection`;
content = content.replace(marker, insertion);
fs.writeFileSync(seedPath, content, 'utf-8');
console.log('inserted mock product generator into seed.ts');
-35
View File
@@ -9,45 +9,10 @@
<meta name="background-color" content="#1C2D56" />
<meta content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" name="viewport" />
<title>طلای هنزا</title>
<style>
#whatsapp-button {
position: fixed;
bottom: 90px;
right: 20px;
width: 60px;
height: 60px;
padding: 8px;
box-sizing: border-box;
background-color: #ffffff;
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.2s ease;
z-index: 1000000000;
}
#whatsapp-button img {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
#whatsapp-button:hover {
transform: scale(1.1);
box-shadow: 0 6px 14px rgba(0, 0, 0, 0.3);
}
</style>
</head>
<body style="background-color: #1C2D56">
<div class="bg-white w-screen h-screen fixed"></div>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
<a href="https://wa.me/989229425856" target="_blank" id="whatsapp-button" aria-label="پشتیبانی واتساپ">
<img src="/whatsapp.png" alt="پشتیبانی واتساپ" width="44" height="44" />
</a>
</body>
</html>
+3 -2
View File
@@ -1,6 +1,6 @@
<!-- Ported from legacy henza_pwa/src/components/spinner.vue -->
<template>
<Spin :spinning="spinning" :tip="tip" :size="(size as 'small' | 'default' | 'large')" :delay="delay" class="!max-h-screen !fixed">
<Spin :spinning="spinning" :tip="tip" :size="(size as 'small' | 'default' | 'large')" :delay="delay" :class="fixed ? '!max-h-screen !fixed' : ''">
<template #indicator>
<svg v-if="!indicator" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 114.39 137.84">
<g>
@@ -43,8 +43,9 @@ withDefaults(
delay?: number
spinning: boolean
indicator?: boolean
fixed?: boolean
}>(),
{ size: 'large' },
{ size: 'large', fixed: true },
)
</script>
@@ -64,6 +64,9 @@ const wageLabel = computed(() => {
.card__art {
position: relative;
aspect-ratio: 1 / 1;
display: flex;
align-items: center;
justify-content: center;
background: @hairlineSoft;
}
@@ -73,8 +76,11 @@ const wageLabel = computed(() => {
.card__art img {
width: 100%;
max-width: 200px;
height: 100%;
object-fit: cover;
max-height: 200px;
object-fit: contain;
object-position: center;
display: block;
}
@@ -90,26 +90,30 @@ const wageLabel = computed(() => {
<style scoped lang="less">
.ci {
display: flex;
gap: 12px;
padding: 11px;
gap: 14px;
padding: 16px;
background: @canvas;
border: 1px solid @hairline;
border-radius: 18px;
}
.ci__art {
flex: 0 0 76px;
width: 76px;
height: 76px;
flex: 0 0 84px;
width: 84px;
height: 84px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 13px;
overflow: hidden;
background: @hairlineSoft;
}
.ci__art img {
width: 100%;
height: 100%;
object-fit: cover;
width: 90px;
height: 90px;
object-fit: contain;
object-position: center;
display: block;
}
@@ -3,44 +3,47 @@
components/home/* and HomeView). Ported originally from legacy
henza_pwa/src/components/product/order.vue.
Layout follows the same leading-icon-badge row pattern as every other list
row in the app (TransactionsView's .tx__row, OrderItemCard's .oi__art) —
a status-coloured circular badge on the lead edge, content stacked beside
it — instead of the previous all-stacked-text block, which was the one
list card in the app with no leading visual anchor.
Status colours still come from utils/orderStatus.ts, which is the single
source of truth shared with the detail view — not re-declared here.
-->
<template>
<article class="oc" @click="emit('view', value)">
<div class="oc__head">
<div class="oc__id">
<span class="oc__idLabel">سفارش</span>
<span class="oc__idValue">{{ value.orderNumber }}</span>
</div>
<span class="oc__status" :style="{ background: color?.background, color: color?.font }">
{{ statusLabel }}
</span>
</div>
<span class="oc__mark" :style="{ background: color?.background, color: color?.font }" aria-hidden="true">
<Icon icon="vuesax-linear:receipt-text" size="16" />
</span>
<div class="oc__figures">
<div class="oc__figure">
<span class="oc__figureLabel">تعداد</span>
<span class="oc__figureValue">
<div class="oc__main">
<div class="oc__head">
<h3 class="oc__id">{{ value.orderNumber }}</h3>
<span class="oc__status" :style="{ background: color?.background, color: color?.font }">
{{ statusLabel }}
</span>
</div>
<div class="oc__figures">
<span class="oc__figure">
{{ value.items.length }}
<i>آیتم</i>
</span>
</div>
<div class="oc__figure">
<span class="oc__figureLabel">وزن</span>
<span class="oc__figureValue">
<span class="oc__figure">
{{ parseFloat(value.totalGoldWeightGrams).toFixed(2) }}
<i>گرم</i>
</span>
</div>
</div>
<div class="oc__foot">
<span class="oc__date">{{ jdate(value.createdAt) }}</span>
<span class="oc__more">
جزئیات
<Icon icon="vuesax-linear:arrow-left-2" size="14" />
</span>
<div class="oc__foot">
<span class="oc__date">{{ jdate(value.createdAt) }}</span>
<span class="oc__more">
جزئیات
<Icon icon="vuesax-linear:arrow-left-2" size="14" />
</span>
</div>
</div>
</article>
</template>
@@ -61,8 +64,10 @@ const color = computed(() => ORDER_STATUS_COLORS[props.value.status])
<style scoped lang="less">
.oc {
display: flex;
gap: 11px;
cursor: pointer;
padding: 13px 14px;
padding: 12px;
background: @canvas;
border: 1px solid @hairline;
border-radius: 18px;
@@ -73,6 +78,22 @@ const color = computed(() => ORDER_STATUS_COLORS[props.value.status])
transform: scale(0.99);
}
.oc__mark {
flex: 0 0 36px;
width: 36px;
height: 36px;
border-radius: 50%;
display: grid;
place-items: center;
background: @hairlineSoft;
color: @inkFaint;
}
.oc__main {
flex: 1;
min-width: 0;
}
.oc__head {
display: flex;
align-items: center;
@@ -81,18 +102,7 @@ const color = computed(() => ORDER_STATUS_COLORS[props.value.status])
}
.oc__id {
display: flex;
align-items: baseline;
gap: 6px;
min-width: 0;
}
.oc__idLabel {
font-size: 11px;
color: @inkFaint;
}
.oc__idValue {
margin: 0;
font-size: 13.5px;
font-weight: 800;
color: @ink;
@@ -112,32 +122,21 @@ const color = computed(() => ORDER_STATUS_COLORS[props.value.status])
.oc__figures {
display: flex;
gap: 26px;
padding: 12px 0 11px;
gap: 14px;
padding-top: 6px;
}
.oc__figure {
display: flex;
flex-direction: column;
gap: 3px;
font-size: 12px;
font-weight: 700;
color: @inkSoft;
}
.oc__figureLabel {
font-size: 10.5px;
color: @inkFaint;
}
.oc__figureValue {
font-size: 14px;
font-weight: 800;
color: @ink;
}
.oc__figureValue i {
.oc__figure i {
font-style: normal;
font-size: 10.5px;
font-size: 10px;
font-weight: 600;
color: @inkFaint;
padding-inline-start: 3px;
padding-inline-start: 2px;
}
.oc__foot {
@@ -145,8 +144,7 @@ const color = computed(() => ORDER_STATUS_COLORS[props.value.status])
align-items: center;
justify-content: space-between;
gap: 10px;
padding-top: 10px;
border-top: 1px solid @hairlineSoft;
padding-top: 8px;
}
.oc__date {
@@ -105,13 +105,19 @@ const wageLabel = computed(() => {
.pc__art {
position: relative;
aspect-ratio: 1 / 1;
display: flex;
align-items: center;
justify-content: center;
background: @hairlineSoft;
}
.pc__art img {
width: 100%;
max-width: 150px;
height: 100%;
object-fit: cover;
max-height: 150px;
object-fit: contain;
object-position: center;
display: block;
}
+214 -32
View File
@@ -1,11 +1,19 @@
<!--
Ported from legacy henza_pwa/src/components/search/Filters.vue, adapted:
Ported from legacy henza_pwa/src/components/search/Filters.vue, restyled
into the redesigned design language (see components/home/* and HomeView) —
previously raw Tailwind utility classes (text-primary, border-grey,
rounded-[5px] Button/Checkbox from ant-design-vue), the one filter sheet in
the app that didn't match the LESS-token look every other surface uses.
Adapted:
- Brand filter: real, but derived client-side from the product list (via
`products` prop) rather than a customer-specific `myStore.state.filterBrand`
list the old backend used to send.
list the old backend used to send. Rendered as a toggle-chip list (same
pattern as SingleProduct's variant picker) instead of an ant CheckboxGroup.
- Gram-range filter: real, bounds derived from the actual min/max
rashWeightGrams across all variants (rather than a customer-specific
server-provided bound).
server-provided bound). Still uses ant-design-vue's Slider for the actual
drag/range behaviour, restyled via :deep() to the wine accent.
- DROPPED: color filter — no color concept exists anywhere in the new
Product/ProductVariant schema, not a stubbed-off feature like promotions,
genuinely nothing to filter by.
@@ -14,47 +22,67 @@
the architectural cut already made in DashboardView (no banker/type
concept in the new Customer model).
- DROPPED: discount toggle — no promotions data exists (see useProduct.ts).
Top padding (120px) accounts for PageBar + the search input row with no
chips row below it (chips are hidden while this sheet is open) — same
bar-chrome math SearchView/OrdersView use for their own #below content.
-->
<template>
<section>
<container class="pt-[120px] overflow-y-scroll" padding="126px">
<section class="filt">
<div class="filt__body">
<DropDown text="فیلتر بر اساس برند">
<CheckboxGroup v-model:value="modelValue.brands" class="w-full">
<div class="w-full grid grid-cols-1 gap-[15px]">
<Checkbox :value="item.id" class="mt-[15px]" v-for="item in availableBrands" :key="item.id">{{ item.title }}</Checkbox>
</div>
</CheckboxGroup>
<div v-if="availableBrands.length" class="filt__chips">
<button
v-for="item in availableBrands"
:key="item.id"
type="button"
class="filt__chip"
:class="{ 'is-on': modelValue.brands.includes(item.id) }"
@click="toggleBrand(item.id)"
>
{{ item.title }}
</button>
</div>
<p v-else class="filt__empty">برندی برای فیلتر موجود نیست.</p>
</DropDown>
<Divider class="border-grey"><img v-lazy="Vector" alt="svg" class="mx-[10px]" /></Divider>
<DropDown text="فیلتر بر اساس گرم">
<div class="px-[12px] pt-[15px]">
<Slider v-model:value="(modelValue.limit_gram as unknown as [number, number])" :step="0.01" range :min="gramBounds[0]" :max="gramBounds[1]" />
<div class="flex justify-between mt-[30px]" v-if="modelValue.limit_gram.length > 0">
<div><span class="text-lightGray">بیشترین : </span><span class="text-primary">{{ modelValue.limit_gram[1] }} گرم</span></div>
<div><span class="text-lightGray">کمترین : </span><span class="text-primary">{{ modelValue.limit_gram[0] }} گرم</span></div>
<div class="filt__gram">
<Slider
v-model:value="(modelValue.limit_gram as unknown as [number, number])"
:step="0.01"
range
reverse
:min="gramBounds[0]"
:max="gramBounds[1]"
/>
<div class="filt__gramFigures" v-if="modelValue.limit_gram.length > 0">
<span class="filt__gramFigure">
<i>کمترین</i>
{{ modelValue.limit_gram[0] }} گرم
</span>
<span class="filt__gramFigure">
<i>بیشترین</i>
{{ modelValue.limit_gram[1] }} گرم
</span>
</div>
</div>
</DropDown>
</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 border-solid border-primary/10 shadowset">
<div class="w-full z-10 bg-white flex grid grid-cols-2 gap-4 max-w-[768px] m-auto">
<Button size="large" :type="('light' as any)" block @click="closeFilters(false)">پاک کردن فیلتر ها</Button>
<Button size="large" type="primary" class="btn-secondary" block @click="closeFilters(true)">اعمال فیلتر</Button>
</div>
</div>
<div class="filt__bar">
<div class="filt__barInner">
<button type="button" class="filt__clear" @click="closeFilters(false)">پاک کردن فیلتر‌ها</button>
<button type="button" class="filt__apply" @click="closeFilters(true)">اعمال فیلتر</button>
</div>
</container>
</div>
</section>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { Button, Checkbox, CheckboxGroup, Divider, Slider } from 'ant-design-vue'
import { computed, watch } from 'vue'
import { Slider } from 'ant-design-vue'
import DropDown from '../ui/DropDown.vue'
import Vector from '../../assets/img/Vector.svg'
import type { FiltersQuery } from '../../stores/filtersStore'
import type { ProductSummary } from '../../api/client'
@@ -78,6 +106,28 @@ const gramBounds = computed((): [number, number] => {
return [Math.floor(Math.min(...weights)), Math.ceil(Math.max(...weights))]
})
// The Slider's `range` mode needs a real [start, end] value to place its two
// handles — filtersStore's default `limit_gram: []` has neither, so until
// this seeds it, ant-design-vue's Slider has nothing to derive handle
// positions from and renders no handles at all. Only seeds while still
// empty, so it never clobbers a value the user already dragged.
watch(
gramBounds,
(bounds) => {
if (props.modelValue.limit_gram.length === 0) {
props.modelValue.limit_gram = [...bounds]
}
},
{ immediate: true },
)
const toggleBrand = (id: string) => {
const brands = props.modelValue.brands
const index = brands.indexOf(id)
if (index === -1) brands.push(id)
else brands.splice(index, 1)
}
const closeFilters = (sendQuery: boolean): void => {
const filterKeys: string[] = []
if (sendQuery) {
@@ -100,9 +150,141 @@ const closeFilters = (sendQuery: boolean): void => {
}
</script>
<style>
.shadowset {
box-shadow: 0 -4px 6px -1px rgb(0 0 0 / 0.1);
border-radius: 10px 10px 0 0;
<style scoped lang="less">
.filt {
background: @canvas;
min-height: 100vh;
}
.filt__body {
max-width: 768px;
margin: 0 auto;
padding: 120px 16px 110px;
display: flex;
flex-direction: column;
gap: 12px;
}
.filt__chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.filt__chip {
padding: 8px 14px;
border-radius: 999px;
cursor: pointer;
font-family: inherit;
font-size: 12px;
font-weight: 600;
color: @inkSoft;
background: @canvas;
border: 1px solid @hairline;
transition: all 0.2s ease;
}
.filt__chip.is-on {
color: #fff;
background: @ink;
border-color: @ink;
}
.filt__chip:active {
transform: scale(0.97);
}
.filt__empty {
margin: 0;
font-size: 12px;
color: @inkFaint;
}
.filt__gram {
padding: 4px 6px 0;
}
.filt__gram :deep(.ant-slider-track) {
background-color: @wine;
}
.filt__gram :deep(.ant-slider-handle) {
border-color: @wine;
}
.filt__gram :deep(.ant-slider-rail) {
background-color: @hairline;
}
.filt__gramFigures {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding-top: 18px;
}
.filt__gramFigure {
font-size: 12px;
font-weight: 700;
color: @ink;
}
.filt__gramFigure i {
font-style: normal;
font-size: 11px;
font-weight: 600;
color: @inkFaint;
padding-inline-end: 4px;
}
.filt__bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 5;
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(14px);
border-top: 1px solid @hairline;
padding-bottom: 16px;
}
.filt__barInner {
max-width: 768px;
margin: 0 auto;
padding: 12px 16px;
display: flex;
align-items: center;
gap: 10px;
}
.filt__clear,
.filt__apply {
flex: 1;
padding: 13px 18px;
border: 0;
cursor: pointer;
border-radius: 14px;
font-family: inherit;
font-size: 13px;
font-weight: 700;
transition: transform 0.15s ease;
}
.filt__clear {
color: @inkSoft;
background: @hairlineSoft;
border: 1px solid @hairline;
}
.filt__apply {
color: #fff;
background: @ink;
}
.filt__clear:active,
.filt__apply:active {
transform: scale(0.98);
}
</style>
@@ -1,22 +1,22 @@
<!--
Ported from legacy henza_pwa/src/components/search/SearchInput.vue.
Ported from legacy henza_pwa/src/components/search/SearchInput.vue, restyled
into the redesigned design language (see components/home/* and HomeView) —
previously still Tailwind utility classes with legacy tokens
(rounded-[5px], border-lightGray), the one search input in the app that
didn't match the LESS-token look every other surface uses.
EXISTING ISSUE, ported as-is: the debounce watcher checks `if (value)` (the
ref object itself, always truthy) instead of `if (value.value)`, so the "x"
clear icon shows after any input and never hides itself based on actual
emptiness — only the explicit clear() click correctly hides it.
-->
<template>
<div class="w-full relative">
<input
v-model="value"
class="w-full h-[44px] rounded-[5px] px-[44px] py-[10px] text-[15px] border-solid border-[1px] border-lightGray"
:placeholder="placeHolder"
@keyup.enter="onSearch"
/>
<Icon icon="vuesax-linear:search-normal-1" class="cursor-pointer absolute top-[11.82px] right-[12px] text-primary" size="25" @click="onSearch" />
<span class="absolute top-[10px] left-[12px] cursor-pointer text-primary">
<Icon icon="vuesax-linear:add" v-if="data.clear" class="ml-1 rotate-45" size="25" @click="clear" />
</span>
<div class="si">
<Icon icon="vuesax-linear:search-normal-1" class="si__icon" size="18" @click="onSearch" />
<input v-model="value" class="si__input" :placeholder="placeHolder" @keyup.enter="onSearch" />
<button v-if="data.clear" type="button" class="si__clear" aria-label="پاک کردن" @click="clear">
<Icon icon="vuesax-linear:add" size="16" />
</button>
</div>
</template>
@@ -46,3 +46,61 @@ watch(debounced, () => {
onSearch()
})
</script>
<style scoped lang="less">
.si {
position: relative;
display: flex;
align-items: center;
}
.si__icon {
position: absolute;
right: 14px;
color: @inkFaint;
cursor: pointer;
}
.si__input {
width: 100%;
height: 44px;
padding: 0 42px;
font-family: inherit;
font-size: 13px;
color: @ink;
background: @hairlineSoft;
border: 1px solid @hairline;
border-radius: 14px;
transition: border-color 0.18s ease, background 0.18s ease;
}
.si__input::placeholder {
color: @inkFaint;
}
.si__input:focus {
outline: none;
background: @canvas;
border-color: @wineSoft;
}
.si__clear {
position: absolute;
left: 10px;
width: 26px;
height: 26px;
display: grid;
place-items: center;
border: 0;
cursor: pointer;
border-radius: 50%;
color: @inkFaint;
background: @hairline;
transform: rotate(45deg);
transition: color 0.18s ease, background 0.18s ease;
}
.si__clear:active {
transform: rotate(45deg) scale(0.92);
}
</style>
+65 -9
View File
@@ -1,14 +1,20 @@
<!-- Ported from legacy henza_pwa/src/components/dropDown.vue -->
<!--
Ported from legacy henza_pwa/src/components/dropDown.vue, restyled into the
redesigned design language (see components/home/* and HomeView) — previously
raw Tailwind utility classes with legacy tokens (text-primary,
border-lightGray/20), the one accordion in the app that didn't match the
LESS-token look every other surface uses.
-->
<template>
<div :class="`rounded-[5px] border border-solid text-primary px-[15px] py-[15px] ${show ? 'border-secondary' : 'border-lightGray/20'}`">
<div class="flex w-full justify-between cursor-pointer" @click="show = !show">
<h5 class="flex-1 m-0">
<div class="dd" :class="{ 'is-open': show }">
<button type="button" class="dd__head" @click="show = !show">
<h3 class="dd__title">
<slot name="text">{{ text }}</slot>
</h5>
<Icon icon="vuesax-linear:arrow-down" :class="`transition-all duration-200 ease-linear ${show ? 'rotate-180' : undefined}`" size="25" />
</div>
</h3>
<Icon icon="vuesax-linear:arrow-down" class="dd__chevron" size="18" />
</button>
<transition name="drop" mode="out-in" appear>
<div class="w-full" style="overflow: hidden" v-if="show">
<div class="dd__body" v-if="show">
<slot />
</div>
</transition>
@@ -23,6 +29,56 @@ withDefaults(defineProps<{ text?: string }>(), {})
const show = ref(false)
</script>
<style scoped lang="less">
.dd {
padding: 15px;
border: 1px solid @hairline;
border-radius: 16px;
background: @canvas;
}
.dd.is-open {
border-color: @wineSoft;
}
.dd__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
border: 0;
padding: 0;
cursor: pointer;
background: none;
font-family: inherit;
text-align: start;
}
.dd__title {
margin: 0;
font-size: 13px;
font-weight: 700;
color: @ink;
}
.dd__chevron {
flex: 0 0 auto;
color: @inkFaint;
transition: transform 0.2s ease;
}
.dd.is-open .dd__chevron {
transform: rotate(180deg);
color: @wine;
}
.dd__body {
overflow: hidden;
padding-top: 15px;
}
</style>
<style lang="less">
.drop-enter-active {
animation: drop 0.2s cubic-bezier(0.33, 1, 0.68, 1);
@@ -35,7 +91,7 @@ const show = ref(false)
max-height: 0;
}
100% {
max-height: 120px;
max-height: 240px;
}
}
</style>
+98 -134
View File
@@ -1,155 +1,119 @@
<!-- Ported from legacy henza_pwa/src/components/ui/navigation.vue -->
<template>
<nav class="fixed bottom-0 w-full z-[10]" v-show="nav.showNav">
<div class="flex flex-row-reverse justify-around items-center absolute w-full h-[50px] z-[2] bottom-0">
<template v-for="item in nav.navBarRoutes" :key="item.index">
<router-link :to="{ name: item.name }" class="relative h-[50px]" @click="setLastPositionX(item)">
<div class="nav-item">
<Icon
:icon="item.icon"
class="cursor-pointer"
:style="{ transform: nav.activeLink === item.index ? `translateY(50px)` : undefined }"
size="25"
/>
</div>
</router-link>
</template>
</div>
<div class="active-nav-item absolute" :style="{ top: output[0] + 'px', left: output[1] + 'px' }">
<Icon v-if="activeItem?.icon === 'vuesax-linear:user'" icon="vuesax-linear:user" class="cursor-pointer" size="25" />
<Icon
v-if="activeItem?.icon === 'vuesax-linear:bag-happy'"
icon="vuesax-linear:bag-happy"
class="cursor-pointer"
size="25"
/>
<Icon
v-if="activeItem?.icon === 'vuesax-linear:search-normal-1'"
icon="vuesax-linear:search-normal-1"
class="cursor-pointer"
size="25"
/>
<Icon
v-if="activeItem?.icon === 'vuesax-linear:home-2'"
icon="vuesax-linear:home-2"
class="cursor-pointer"
size="25"
/>
</div>
<!--
Redesigned bottom nav — replaces the old GSAP SVG-notch bar (4 tabs: user,
cart, search, home) with a flat pill-bar of 5 tabs (Home, Search, Likes,
Notifications, Profile), each showing icon + label, and a single sliding
indicator behind the active tab instead of a cut-out notch.
<svg class="h-[50px] w-full">
<path id="nav" class="from-path" :d="shape" />
</svg>
windowData/SVG path math from the old version is gone — the indicator is
now pure CSS, positioned with a transform driven by the active tab's index,
so it needs no measurement of the container width.
-->
<template>
<nav class="tabbar" v-show="nav.showNav">
<div class="tabbar__inner" :style="{ '--count': nav.navBarRoutes.length }">
<div
class="tabbar__indicator"
:style="{ transform: `translateX(${indicatorOffset}%)` }"
aria-hidden="true"
/>
<router-link
v-for="item in nav.navBarRoutes"
:key="item.index"
:to="{ name: item.name }"
class="tabbar__item"
:class="{ 'is-active': nav.activeLink === item.index }"
>
<Icon :icon="item.icon" size="21" />
<span class="tabbar__label">{{ item.label }}</span>
</router-link>
</div>
</nav>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { gsap } from 'gsap'
import { TransitionPresets, useTransition } from '@vueuse/core'
import { computed } from 'vue'
import Icon from '../icon/Icon.vue'
import { useNavStore } from '../../stores/navStore'
import { useWindowDataStore } from '../../stores/windowDataStore'
const nav = useNavStore()
const windowData = useWindowDataStore()
const shape = ref('')
const centershape = [95, 45]
const width = computed(() => windowData.width)
const centerOfCentershape = computed(() => {
return (width.value / nav.navBarRoutes.length) * nav.activeLink - width.value / (nav.navBarRoutes.length * 2)
})
const draw = () => {
const drowData = {
cornnerCurve: 10,
height: 50,
centerTopCurve: 20,
centerBottpmCurve: 32,
centershapeWidth: centershape[0],
centershapeHeight: centershape[1],
startOfCentershape: centerOfCentershape.value - centershape[0] / 2,
endOfCentershape: centerOfCentershape.value + centershape[0] / 2,
}
return `
M0,0
H${drowData.startOfCentershape}
C${drowData.startOfCentershape + drowData.centerTopCurve}, 0, ${centerOfCentershape.value - drowData.centerBottpmCurve}, ${drowData.centershapeHeight}, ${centerOfCentershape.value}, ${drowData.centershapeHeight}
C${centerOfCentershape.value + drowData.centerBottpmCurve},${drowData.centershapeHeight},${drowData.endOfCentershape - drowData.centerTopCurve},0,${drowData.endOfCentershape},0
H${width.value}
V50
H0
Z`
}
watch(width, (val) => {
if (val > 0) {
shape.value = draw()
}
})
watch(
() => nav.$state,
() => {
if (!shape.value) {
shape.value = draw()
} else {
gsap.to('.from-path', { duration: 0.3, attr: { d: draw() } })
}
},
{ deep: true },
)
shape.value = draw()
let lastPositionX = width.value
const activeItem = computed(() => {
const item = nav.navBarRoutes.find((x) => x.index === nav.activeLink)
setActiveIcon()
return item
})
// Legacy called this with an `item` argument that the function body never
// used (plain JS silently ignores extra args); kept as an unused optional
// param so behavior stays identical under TypeScript.
const setLastPositionX = (_item?: unknown) => {
lastPositionX = centerOfCentershape.value - centershape[0] / 2 + 25
}
const setActiveIcon = () => {
const positionX = centerOfCentershape.value - centershape[0] / 2 + 25
baseVector.value = [50, positionX + (lastPositionX - positionX) / 2]
setTimeout(() => {
baseVector.value = [-20, positionX]
}, 150)
}
const baseVector = ref([0, 0])
const output = useTransition(baseVector, {
duration: 150,
transition: TransitionPresets.easeInOutCubic,
})
// Index is 1-based; the indicator sits in tab slot (activeLink - 1) out of N.
// The whole app runs `direction: rtl` (see App.vue), which flips the grid's
// visual column order but NOT a physical translateX — so slot 0 renders on
// the right, and moving right needs a NEGATIVE offset here.
const indicatorOffset = computed(() => -(nav.activeLink - 1) * 100)
</script>
<style scoped lang="less">
#nav {
opacity: 0.4;
fill: #dddddd;
.tabbar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 10;
padding: 10px 14px calc(10px + env(safe-area-inset-bottom, 0px));
background: transparent;
}
.nav-item {
.tabbar__inner {
position: relative;
display: grid;
grid-template-columns: repeat(var(--count), 1fr);
max-width: 420px;
margin: 0 auto;
padding: 6px;
border-radius: 22px;
background: @canvas;
border: 1px solid @hairline;
box-shadow: 0 10px 28px -12px rgba(20, 22, 26, 0.18);
}
.tabbar__indicator {
position: absolute;
inset: 6px 6px 6px auto;
width: calc((100% - 12px) / var(--count));
border-radius: 16px;
background: @wineWash;
transition: transform 0.35s cubic-bezier(0.65, 0, 0.35, 1);
will-change: transform;
}
.tabbar__item {
position: relative;
z-index: 1;
display: flex;
justify-content: center;
flex-direction: column;
align-items: center;
width: 45px;
height: 45px;
justify-content: center;
gap: 2px;
padding: 8px 4px;
border-radius: 16px;
color: @inkFaint;
text-decoration: none;
transition: color 0.25s ease, transform 0.25s cubic-bezier(0.65, 0, 0.35, 1.4);
}
.active-nav-item {
.nav-item;
background: @primary;
color: @white;
border-radius: 100%;
.tabbar__item :deep(svg) {
transition: transform 0.3s cubic-bezier(0.65, 0, 0.35, 1.4);
}
.tabbar__item.is-active {
color: @wine;
transform: translateY(-2px);
}
.tabbar__item.is-active :deep(svg) {
transform: scale(1.08);
}
.tabbar__item:active {
transform: scale(0.94);
}
.tabbar__label {
font-size: 10px;
font-weight: 600;
line-height: 1;
opacity: 0.9;
}
</style>
+3 -1
View File
@@ -52,8 +52,10 @@ function onBack() {
<style scoped lang="less">
.bar {
position: sticky;
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 20;
background: rgba(255, 255, 255, 0.88);
backdrop-filter: blur(14px);
+2 -1
View File
@@ -36,6 +36,7 @@ export const router = createRouter({
},
{ path: '/profile', name: 'profileData', component: () => import('../views/user/ProfileView.vue'), meta: { requiresAuth: true } },
{ path: '/favorites', name: 'favoritesList', component: () => import('../views/user/FavoritesView.vue'), meta: { requiresAuth: true } },
{ path: '/notifications', name: 'notifications', component: () => import('../views/user/NotificationsView.vue') },
{ path: '/search', name: 'search', component: () => import('../views/search/SearchView.vue') },
{ path: '/collection/:id?', name: 'collection', component: () => import('../views/search/CollectionView.vue') },
// legacy redirects any unmatched path (including '/') to the home route by name.
@@ -56,7 +57,7 @@ router.beforeEach((to) => {
return { name: 'home' };
}
if (to.meta.requiresAuth && !session.customerId) {
return { name: 'home' };
return { name: 'login', query: { redirect: to.fullPath } };
}
// legacy router/index.ts:268-270 — the product page slides down into view.
+10 -9
View File
@@ -1,14 +1,14 @@
// Ported from legacy henza_pwa/src/store/navStore.ts (a hand-rolled reactive
// class) to a real Pinia store, preserving the exact same state shape and
// setter behavior — including the pre-existing path/route-name mismatch on
// the "home" entry (path: '/' vs. the app's actual /home route), which is
// unused dead data since navigation uses `name`, not `path`. Ported as-is.
// class) to a real Pinia store, preserving the same state shape and setter
// behavior. The route list itself was redesigned to a 5-tab bottom nav
// (Home, Search, Cart, Orders, Profile) — see components/ui/Navigation.vue.
import { defineStore } from 'pinia'
interface NavItem {
name: string
path: string
icon: string
label: string
index: number
}
@@ -23,12 +23,13 @@ interface NavState {
export const useNavStore = defineStore('nav', {
state: (): NavState => ({
navBarRoutes: [
{ name: 'userPage::hasSubRoute', path: '/user', icon: 'vuesax-linear:user', index: 1 },
{ name: 'cart', path: '/cart', icon: 'vuesax-linear:bag-happy', index: 2 },
{ name: 'search', path: '/search', icon: 'vuesax-linear:search-normal-1', index: 3 },
{ name: 'home', path: '/', icon: 'vuesax-linear:home-2', index: 4 },
{ name: 'home', path: '/home', icon: 'vuesax-linear:home-2', label: 'خانه', index: 1 },
{ name: 'search', path: '/search', icon: 'vuesax-linear:search-normal-1', label: 'جستجو', index: 2 },
{ name: 'cart', path: '/cart', icon: 'vuesax-linear:bag-happy', label: 'سبد خرید', index: 3 },
{ name: 'ordersList', path: '/orders', icon: 'vuesax-linear:receipt-text', label: 'سفارشات', index: 4 },
{ name: 'userPage::hasSubRoute', path: '/user', icon: 'vuesax-linear:user', label: 'پروفایل', index: 5 },
],
activeLink: 4,
activeLink: 1,
showNav: true,
pageTransition: 'right',
}),
+15 -6
View File
@@ -32,10 +32,12 @@
<Spinner :spinning="cartData.loadingPage" tip="درحال دریافت اطلاعات">
<container class="cart__body">
<!-- Step 1: the basket -->
<template v-if="cartData.step === 1">
<PullRefresh v-if="cartData.step === 1" v-model="refreshing" @refresh="onRefresh">
<template #pulling><Loading size="18" /></template>
<template #loosing><Loading size="18" /></template>
<template #loading><Loading size="18" /></template>
<template #success><span /></template>
<template v-if="cart.items.length">
<SectionHead title="اقلام سبد" :subtitle="`${cart.items.length} قلم`" />
<div class="cart__items">
<CartItem
v-for="item in cart.items"
@@ -58,7 +60,7 @@
دیدن محصولات
</button>
</section>
</template>
</PullRefresh>
<!-- Step 2: submitted -->
<section v-else class="cart__done">
@@ -141,12 +143,12 @@
import { onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Drawer, Form, FormItem, Textarea } from 'ant-design-vue'
import { Loading, PullRefresh } from 'vant'
import dialogEmpty4 from '../assets/img/empty/dialogEmpty4.svg'
import loginDialogImage from '../assets/img/empty/dialogEmpty1.svg'
import Icon from '../components/icon/Icon.vue'
import PageBar from '../components/ui/PageBar.vue'
import Spinner from '../components/Spinner.vue'
import SectionHead from '../components/home/SectionHead.vue'
import DialogVant from '../components/DialogVant.vue'
import CartItem from '../components/product/CartItem.vue'
import { useCartStore, type CartItem as CartItemType } from '../stores/cartStore'
@@ -158,6 +160,7 @@ const cart = useCartStore()
const session = useSessionStore()
const loginNotify = ref(false)
const refreshing = ref(false)
const cartData = reactive({
loadingPage: false,
@@ -213,6 +216,11 @@ const submitOrder = async () => {
}
}
async function onRefresh() {
await cart.init()
refreshing.value = false
}
onMounted(async () => {
await cart.init()
})
@@ -225,7 +233,7 @@ onMounted(async () => {
}
.cart__body {
padding-top: 16px;
padding-top: 76px;
padding-bottom: 150px;
}
@@ -233,6 +241,7 @@ onMounted(async () => {
display: flex;
flex-direction: column;
gap: 11px;
padding-top: 16px;
}
.cart__empty {
+23 -29
View File
@@ -35,6 +35,11 @@
</div>
</header>
<PullRefresh v-model="refreshing" @refresh="onRefresh">
<template #pulling><Loading size="18" /></template>
<template #loosing><Loading size="18" /></template>
<template #loading><Loading size="18" /></template>
<template #success><span /></template>
<container class="home__body">
<RateStrip :quote="goldPrice" class="home__rate" />
@@ -85,11 +90,6 @@
<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" />
@@ -105,6 +105,7 @@
</div>
</div>
</container>
</PullRefresh>
<Drawer
v-model:visible="productDrawer"
@@ -123,6 +124,7 @@
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Drawer } from 'ant-design-vue'
import { Loading, PullRefresh } from 'vant'
import Icon from '../components/icon/Icon.vue'
import SingleProduct from '../components/product/SingleProduct.vue'
import RateStrip from '../components/home/RateStrip.vue'
@@ -131,25 +133,22 @@ 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 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'
import type { GoldPriceQuote, ProductSummary } from '../api/client'
const router = useRouter()
const memory = useConfigStore()
const cart = useCartStore()
const filters = useFiltersStore()
const session = useSessionStore()
const loading = ref(true)
const refreshing = ref(false)
const products = ref<ProductSummary[]>([])
const goldPrice = ref<GoldPriceQuote | null>(null)
const customer = ref<CustomerDetail | null>(null)
const productDrawer = ref(false)
const activeProductId = ref<string | null>(null)
@@ -203,28 +202,18 @@ function onSlide(slide: ShowcaseSlide) {
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 () => {
async function loadData() {
try {
const [list] = await Promise.all([api.listProducts(), memory.init()])
products.value = list
} catch (e) {
console.log(e)
} finally {
loading.value = false
}
try {
@@ -232,14 +221,16 @@ onMounted(async () => {
} catch {
goldPrice.value = null
}
}
if (session.customerId) {
try {
customer.value = await api.getCustomer(session.customerId)
} catch {
customer.value = null
}
}
async function onRefresh() {
await loadData()
refreshing.value = false
}
onMounted(async () => {
await loadData()
loading.value = false
})
</script>
@@ -250,8 +241,10 @@ onMounted(async () => {
}
.home__bar {
position: sticky;
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 20;
background: rgba(255, 255, 255, 0.88);
backdrop-filter: blur(14px);
@@ -303,6 +296,7 @@ onMounted(async () => {
}
.home__body {
padding-top: 62px;
padding-bottom: 90px;
}
+1 -1
View File
@@ -82,7 +82,7 @@ const pull = async () => {
}
.newest__body {
padding-top: 16px;
padding-top: 76px;
padding-bottom: 90px;
}
@@ -72,7 +72,7 @@ onMounted(async () => {
}
.coll__body {
padding-top: 16px;
padding-top: 76px;
padding-bottom: 90px;
}
+32 -7
View File
@@ -62,7 +62,12 @@
@change="hideFilters"
/>
<container v-show="!showFilter" class="search__body">
<PullRefresh v-show="!showFilter" v-model="refreshing" @refresh="onRefresh">
<template #pulling><Loading size="18" /></template>
<template #loosing><Loading size="18" /></template>
<template #loading><Loading size="18" /></template>
<template #success><span /></template>
<container class="search__body">
<div v-if="availableCollections.length" class="search__collections">
<button
v-for="item in availableCollections"
@@ -92,7 +97,7 @@
reaches the end — the same incremental behaviour the previous
ScrollFetchSearch-based list had. -->
<div v-if="hasMore" ref="sentinel" class="search__more">
<Spinner :spinning="true" :indicator="true" tip="" cls="my-[10px]" />
<Spinner :spinning="true" :indicator="true" :fixed="false" tip="" cls="my-[10px]" />
</div>
<section v-else-if="!loadingPage" class="search__empty">
@@ -100,12 +105,14 @@
<p>محصولی با این مشخصات پیدا نشد.</p>
</section>
</container>
</PullRefresh>
</Spinner>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { PullRefresh } from 'vant'
import Icon from '../../components/icon/Icon.vue'
import PageBar from '../../components/ui/PageBar.vue'
import Spinner from '../../components/Spinner.vue'
@@ -121,6 +128,7 @@ const filters = useFiltersStore()
const nav = useNavStore()
const loadingPage = ref(false)
const refreshing = ref(false)
const allProducts = ref<ProductSummary[]>([])
const showFilter = ref(false)
const filterKeys = ref<string[]>([])
@@ -203,6 +211,11 @@ async function loadProducts() {
}
}
async function onRefresh() {
await loadProducts()
refreshing.value = false
}
function search(value: string | undefined) {
filters.query.text = value
}
@@ -245,18 +258,26 @@ onMounted(loadProducts)
.search__barExtra {
max-width: 768px;
margin: 0 auto;
padding: 0 16px 11px;
padding: 0 16px 0;
}
.search__chips {
display: flex;
gap: 8px;
gap: 4px;
overflow-x: auto;
margin: 11px -16px 0;
padding: 0 16px;
margin: 8px 0px 8px -16px;
// `padding` alone on an RTL flex scroller can get its edge padding
// collapsed by the browser once content overflows — explicit spacer
// elements hold the same 16px gap reliably at both scroll extremes.
scroll-snap-type: x proximity;
}
.search__chips::before,
.search__chips::after {
content: '';
flex: 0 0 16px;
}
.search__chip {
scroll-snap-align: start;
flex: 0 0 auto;
@@ -292,10 +313,14 @@ onMounted(loadProducts)
}
.search__body {
padding-top: 16px;
padding-top: 160px;
padding-bottom: 90px;
}
.search__body > *:first-child {
margin-top: 16px;
}
.search__collections {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
+17 -4
View File
@@ -48,6 +48,7 @@
</div>
</header>
<PullRefresh v-model="refreshing" @refresh="onRefresh">
<container class="dash__body">
<!-- Signed in: real position from GET /customers/:id -->
<template v-if="customer">
@@ -117,6 +118,7 @@
<RateStrip :quote="goldPrice" />
</section>
</container>
</PullRefresh>
<DialogVant :timeOut="-1" v-model:show="data.notify" :closeOnClickOverlay="true">
<template v-slot:img>
@@ -150,6 +152,7 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { PullRefresh } from 'vant'
import Icon from '../../components/icon/Icon.vue'
import SectionHead from '../../components/home/SectionHead.vue'
import RateStrip from '../../components/home/RateStrip.vue'
@@ -165,6 +168,7 @@ const session = useSessionStore()
const customer = ref<CustomerDetail | null>(null)
const goldPrice = ref<GoldPriceQuote | null>(null)
const refreshing = ref(false)
// The ledger stores the gram position as a signed figure — positive means the
// customer owes gold. Labelled the same way AccountStrip does on the home
@@ -201,7 +205,7 @@ function logOut() {
router.push({ name: 'home' })
}
onMounted(async () => {
async function loadDashboard() {
if (session.customerId) {
customer.value = await api.getCustomer(session.customerId)
}
@@ -210,7 +214,14 @@ onMounted(async () => {
} catch {
goldPrice.value = null
}
})
}
async function onRefresh() {
await loadDashboard()
refreshing.value = false
}
onMounted(loadDashboard)
</script>
<style scoped lang="less">
@@ -220,8 +231,10 @@ onMounted(async () => {
}
.dash__bar {
position: sticky;
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 20;
background: rgba(255, 255, 255, 0.88);
backdrop-filter: blur(14px);
@@ -269,7 +282,7 @@ onMounted(async () => {
}
.dash__body {
padding-top: 16px;
padding-top: 76px;
padding-bottom: 90px;
}
@@ -69,7 +69,7 @@ onMounted(async () => {
}
.favs__body {
padding-top: 16px;
padding-top: 76px;
padding-bottom: 90px;
}
+5 -3
View File
@@ -84,7 +84,7 @@
<script setup lang="ts">
import { computed, nextTick, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { Form, FormItem, Input } from 'ant-design-vue'
import Icon from '../../components/icon/Icon.vue'
import PageBar from '../../components/ui/PageBar.vue'
@@ -96,6 +96,7 @@ import { api, type CustomerSummary } from '../../api/client'
const emit = defineEmits<{ (e: 'register', value: boolean): void }>()
const router = useRouter()
const route = useRoute()
const session = useSessionStore()
const formModel = reactive({ username: undefined as string | undefined })
@@ -152,7 +153,8 @@ const sendToken = async (_otpCode: string) => {
body.loading = true
if (!matchedCustomer) return
session.login(matchedCustomer.id, matchedCustomer.mobile)
await router.push({ name: 'home' })
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : undefined
await router.push(redirect ?? { name: 'home' })
notify.text = 'با موفقیت وارد شدید'
notify.show = true
} finally {
@@ -172,7 +174,7 @@ const btnText = computed(() => (body.step === 1 ? 'دریافت رمز عبور'
}
.auth__body {
padding-top: 26px;
padding-top: 86px;
padding-bottom: 90px;
}
@@ -0,0 +1,51 @@
<!--
New nav destination (bottom nav redesign) — no legacy equivalent and no
notifications backend exists in apps/api. Renders the same dashed
empty-state pattern used elsewhere (see FavoritesView) rather than
fabricating fake notification data.
-->
<template>
<div class="notif">
<PageBar title="اعلان‌ها" back-to="home" />
<container class="notif__body">
<section class="notif__empty">
<Icon icon="vuesax-linear:notification" size="26" />
<p>اعلانی برای نمایش وجود ندارد.</p>
</section>
</container>
</div>
</template>
<script setup lang="ts">
import Icon from '../../components/icon/Icon.vue'
import PageBar from '../../components/ui/PageBar.vue'
</script>
<style scoped lang="less">
.notif {
background: @canvas;
min-height: 100vh;
}
.notif__body {
padding-top: 76px;
padding-bottom: 90px;
}
.notif__empty {
margin-top: 8px;
padding: 38px 20px;
border: 1px dashed @hairline;
border-radius: 18px;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
color: @inkFaint;
}
.notif__empty p {
margin: 0;
font-size: 12.5px;
}
</style>
+13 -6
View File
@@ -48,9 +48,9 @@
</PageBar>
<Spinner :spinning="loading" tip="درحال دریافت اطلاعات">
<PullRefresh v-model="refreshing" @refresh="onRefresh">
<container class="ord__body">
<template v-if="filteredOrders.length">
<SectionHead title="سفارش‌های شما" :subtitle="`${filteredOrders.length} سفارش`" />
<div class="ord__list">
<OrderCard v-for="item in filteredOrders" :key="item.id" :value="item" @view="openDetail" />
</div>
@@ -61,6 +61,7 @@
<p>سفارشی با این مشخصات پیدا نشد.</p>
</section>
</container>
</PullRefresh>
</Spinner>
<Drawer
@@ -144,10 +145,10 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { Drawer } from 'ant-design-vue'
import { PullRefresh } from 'vant'
import Icon from '../../components/icon/Icon.vue'
import PageBar from '../../components/ui/PageBar.vue'
import Spinner from '../../components/Spinner.vue'
import SectionHead from '../../components/home/SectionHead.vue'
import SearchInput from '../../components/search/SearchInput.vue'
import OrderCard from '../../components/product/OrderCard.vue'
import OrderItemCard from '../../components/product/OrderItemCard.vue'
@@ -177,6 +178,7 @@ const tabs = [
const activeTab = ref('ALL')
const loading = ref(false)
const refreshing = ref(false)
const allOrders = ref<OrderDetail[]>([])
const searchTerm = ref<string | undefined>(undefined)
@@ -213,6 +215,11 @@ function openDetail(item: OrderDetail) {
viewData.model = true
}
async function onRefresh() {
await loadOrders()
refreshing.value = false
}
onMounted(loadOrders)
</script>
@@ -225,15 +232,14 @@ onMounted(loadOrders)
.ord__barExtra {
max-width: 768px;
margin: 0 auto;
padding: 0 16px 11px;
padding: 0 16px 0;
}
.ord__chips {
display: flex;
gap: 8px;
overflow-x: auto;
margin: 11px -16px 0;
padding: 0 16px;
margin: 8px 0px 8px -16px;
scroll-snap-type: x proximity;
}
@@ -264,13 +270,14 @@ onMounted(loadOrders)
}
.ord__body {
padding-top: 16px;
padding-top: 160px;
padding-bottom: 90px;
}
.ord__list {
display: flex;
flex-direction: column;
padding-top: 16px;
gap: 11px;
}
+1 -1
View File
@@ -103,7 +103,7 @@ onMounted(async () => {
}
.prof__body {
padding-top: 20px;
padding-top: 80px;
padding-bottom: 90px;
}
@@ -132,7 +132,7 @@ const register = async () => {
}
.auth__body {
padding-top: 26px;
padding-top: 86px;
padding-bottom: 90px;
}
@@ -157,7 +157,7 @@ onMounted(async () => {
}
.tx__body {
padding-top: 16px;
padding-top: 76px;
padding-bottom: 90px;
}
+3 -1
View File
@@ -33,9 +33,11 @@ export default defineConfig({
}),
],
server: {
port: 5174,
host: '0.0.0.0',
port: 5176,
proxy: {
'/api': 'http://localhost:3000',
'/uploads': 'http://localhost:3000',
},
},
css: {