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 sourcemap: false, // legacy: productionSourceMap: false
}, },
server: { server: {
host: '0.0.0.0',
port: 5174, port: 5174,
proxy: { proxy: {
'/api': 'http://localhost:3000', '/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 name="background-color" content="#1C2D56" />
<meta content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" name="viewport" /> <meta content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" name="viewport" />
<title>طلای هنزا</title> <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> </head>
<body style="background-color: #1C2D56"> <body style="background-color: #1C2D56">
<div class="bg-white w-screen h-screen fixed"></div> <div class="bg-white w-screen h-screen fixed"></div>
<div id="app"></div> <div id="app"></div>
<script type="module" src="/src/main.ts"></script> <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> </body>
</html> </html>
+3 -2
View File
@@ -1,6 +1,6 @@
<!-- Ported from legacy henza_pwa/src/components/spinner.vue --> <!-- Ported from legacy henza_pwa/src/components/spinner.vue -->
<template> <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> <template #indicator>
<svg v-if="!indicator" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 114.39 137.84"> <svg v-if="!indicator" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 114.39 137.84">
<g> <g>
@@ -43,8 +43,9 @@ withDefaults(
delay?: number delay?: number
spinning: boolean spinning: boolean
indicator?: boolean indicator?: boolean
fixed?: boolean
}>(), }>(),
{ size: 'large' }, { size: 'large', fixed: true },
) )
</script> </script>
@@ -64,6 +64,9 @@ const wageLabel = computed(() => {
.card__art { .card__art {
position: relative; position: relative;
aspect-ratio: 1 / 1; aspect-ratio: 1 / 1;
display: flex;
align-items: center;
justify-content: center;
background: @hairlineSoft; background: @hairlineSoft;
} }
@@ -73,8 +76,11 @@ const wageLabel = computed(() => {
.card__art img { .card__art img {
width: 100%; width: 100%;
max-width: 200px;
height: 100%; height: 100%;
object-fit: cover; max-height: 200px;
object-fit: contain;
object-position: center;
display: block; display: block;
} }
@@ -90,26 +90,30 @@ const wageLabel = computed(() => {
<style scoped lang="less"> <style scoped lang="less">
.ci { .ci {
display: flex; display: flex;
gap: 12px; gap: 14px;
padding: 11px; padding: 16px;
background: @canvas; background: @canvas;
border: 1px solid @hairline; border: 1px solid @hairline;
border-radius: 18px; border-radius: 18px;
} }
.ci__art { .ci__art {
flex: 0 0 76px; flex: 0 0 84px;
width: 76px; width: 84px;
height: 76px; height: 84px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 13px; border-radius: 13px;
overflow: hidden; overflow: hidden;
background: @hairlineSoft; background: @hairlineSoft;
} }
.ci__art img { .ci__art img {
width: 100%; width: 90px;
height: 100%; height: 90px;
object-fit: cover; object-fit: contain;
object-position: center;
display: block; display: block;
} }
@@ -3,44 +3,47 @@
components/home/* and HomeView). Ported originally from legacy components/home/* and HomeView). Ported originally from legacy
henza_pwa/src/components/product/order.vue. 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 Status colours still come from utils/orderStatus.ts, which is the single
source of truth shared with the detail view — not re-declared here. source of truth shared with the detail view — not re-declared here.
--> -->
<template> <template>
<article class="oc" @click="emit('view', value)"> <article class="oc" @click="emit('view', value)">
<div class="oc__head"> <span class="oc__mark" :style="{ background: color?.background, color: color?.font }" aria-hidden="true">
<div class="oc__id"> <Icon icon="vuesax-linear:receipt-text" size="16" />
<span class="oc__idLabel">سفارش</span> </span>
<span class="oc__idValue">{{ value.orderNumber }}</span>
</div>
<span class="oc__status" :style="{ background: color?.background, color: color?.font }">
{{ statusLabel }}
</span>
</div>
<div class="oc__figures"> <div class="oc__main">
<div class="oc__figure"> <div class="oc__head">
<span class="oc__figureLabel">تعداد</span> <h3 class="oc__id">{{ value.orderNumber }}</h3>
<span class="oc__figureValue"> <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 }} {{ value.items.length }}
<i>آیتم</i> <i>آیتم</i>
</span> </span>
</div> <span class="oc__figure">
<div class="oc__figure">
<span class="oc__figureLabel">وزن</span>
<span class="oc__figureValue">
{{ parseFloat(value.totalGoldWeightGrams).toFixed(2) }} {{ parseFloat(value.totalGoldWeightGrams).toFixed(2) }}
<i>گرم</i> <i>گرم</i>
</span> </span>
</div> </div>
</div>
<div class="oc__foot"> <div class="oc__foot">
<span class="oc__date">{{ jdate(value.createdAt) }}</span> <span class="oc__date">{{ jdate(value.createdAt) }}</span>
<span class="oc__more"> <span class="oc__more">
جزئیات جزئیات
<Icon icon="vuesax-linear:arrow-left-2" size="14" /> <Icon icon="vuesax-linear:arrow-left-2" size="14" />
</span> </span>
</div>
</div> </div>
</article> </article>
</template> </template>
@@ -61,8 +64,10 @@ const color = computed(() => ORDER_STATUS_COLORS[props.value.status])
<style scoped lang="less"> <style scoped lang="less">
.oc { .oc {
display: flex;
gap: 11px;
cursor: pointer; cursor: pointer;
padding: 13px 14px; padding: 12px;
background: @canvas; background: @canvas;
border: 1px solid @hairline; border: 1px solid @hairline;
border-radius: 18px; border-radius: 18px;
@@ -73,6 +78,22 @@ const color = computed(() => ORDER_STATUS_COLORS[props.value.status])
transform: scale(0.99); 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 { .oc__head {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -81,18 +102,7 @@ const color = computed(() => ORDER_STATUS_COLORS[props.value.status])
} }
.oc__id { .oc__id {
display: flex; margin: 0;
align-items: baseline;
gap: 6px;
min-width: 0;
}
.oc__idLabel {
font-size: 11px;
color: @inkFaint;
}
.oc__idValue {
font-size: 13.5px; font-size: 13.5px;
font-weight: 800; font-weight: 800;
color: @ink; color: @ink;
@@ -112,32 +122,21 @@ const color = computed(() => ORDER_STATUS_COLORS[props.value.status])
.oc__figures { .oc__figures {
display: flex; display: flex;
gap: 26px; gap: 14px;
padding: 12px 0 11px; padding-top: 6px;
} }
.oc__figure { .oc__figure {
display: flex; font-size: 12px;
flex-direction: column; font-weight: 700;
gap: 3px; color: @inkSoft;
} }
.oc__figure i {
.oc__figureLabel {
font-size: 10.5px;
color: @inkFaint;
}
.oc__figureValue {
font-size: 14px;
font-weight: 800;
color: @ink;
}
.oc__figureValue i {
font-style: normal; font-style: normal;
font-size: 10.5px; font-size: 10px;
font-weight: 600; font-weight: 600;
color: @inkFaint; color: @inkFaint;
padding-inline-start: 3px; padding-inline-start: 2px;
} }
.oc__foot { .oc__foot {
@@ -145,8 +144,7 @@ const color = computed(() => ORDER_STATUS_COLORS[props.value.status])
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 10px; gap: 10px;
padding-top: 10px; padding-top: 8px;
border-top: 1px solid @hairlineSoft;
} }
.oc__date { .oc__date {
@@ -105,13 +105,19 @@ const wageLabel = computed(() => {
.pc__art { .pc__art {
position: relative; position: relative;
aspect-ratio: 1 / 1; aspect-ratio: 1 / 1;
display: flex;
align-items: center;
justify-content: center;
background: @hairlineSoft; background: @hairlineSoft;
} }
.pc__art img { .pc__art img {
width: 100%; width: 100%;
max-width: 150px;
height: 100%; height: 100%;
object-fit: cover; max-height: 150px;
object-fit: contain;
object-position: center;
display: block; 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 - Brand filter: real, but derived client-side from the product list (via
`products` prop) rather than a customer-specific `myStore.state.filterBrand` `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 - Gram-range filter: real, bounds derived from the actual min/max
rashWeightGrams across all variants (rather than a customer-specific 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 - DROPPED: color filter — no color concept exists anywhere in the new
Product/ProductVariant schema, not a stubbed-off feature like promotions, Product/ProductVariant schema, not a stubbed-off feature like promotions,
genuinely nothing to filter by. genuinely nothing to filter by.
@@ -14,47 +22,67 @@
the architectural cut already made in DashboardView (no banker/type the architectural cut already made in DashboardView (no banker/type
concept in the new Customer model). concept in the new Customer model).
- DROPPED: discount toggle — no promotions data exists (see useProduct.ts). - 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> <template>
<section> <section class="filt">
<container class="pt-[120px] overflow-y-scroll" padding="126px"> <div class="filt__body">
<DropDown text="فیلتر بر اساس برند"> <DropDown text="فیلتر بر اساس برند">
<CheckboxGroup v-model:value="modelValue.brands" class="w-full"> <div v-if="availableBrands.length" class="filt__chips">
<div class="w-full grid grid-cols-1 gap-[15px]"> <button
<Checkbox :value="item.id" class="mt-[15px]" v-for="item in availableBrands" :key="item.id">{{ item.title }}</Checkbox> v-for="item in availableBrands"
</div> :key="item.id"
</CheckboxGroup> 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> </DropDown>
<Divider class="border-grey"><img v-lazy="Vector" alt="svg" class="mx-[10px]" /></Divider>
<DropDown text="فیلتر بر اساس گرم"> <DropDown text="فیلتر بر اساس گرم">
<div class="px-[12px] pt-[15px]"> <div class="filt__gram">
<Slider v-model:value="(modelValue.limit_gram as unknown as [number, number])" :step="0.01" range :min="gramBounds[0]" :max="gramBounds[1]" /> <Slider
<div class="flex justify-between mt-[30px]" v-if="modelValue.limit_gram.length > 0"> v-model:value="(modelValue.limit_gram as unknown as [number, number])"
<div><span class="text-lightGray">بیشترین : </span><span class="text-primary">{{ modelValue.limit_gram[1] }} گرم</span></div> :step="0.01"
<div><span class="text-lightGray">کمترین : </span><span class="text-primary">{{ modelValue.limit_gram[0] }} گرم</span></div> 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>
</div> </div>
</DropDown> </DropDown>
</div>
<div class="w-full fixed bottom-0 z-20 left-0 right-0"> <div class="filt__bar">
<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="filt__barInner">
<div class="w-full z-10 bg-white flex grid grid-cols-2 gap-4 max-w-[768px] m-auto"> <button type="button" class="filt__clear" @click="closeFilters(false)">پاک کردن فیلتر‌ها</button>
<Button size="large" :type="('light' as any)" block @click="closeFilters(false)">پاک کردن فیلتر ها</Button> <button type="button" class="filt__apply" @click="closeFilters(true)">اعمال فیلتر</button>
<Button size="large" type="primary" class="btn-secondary" block @click="closeFilters(true)">اعمال فیلتر</Button>
</div>
</div>
</div> </div>
</container> </div>
</section> </section>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed, watch } from 'vue'
import { Button, Checkbox, CheckboxGroup, Divider, Slider } from 'ant-design-vue' import { Slider } from 'ant-design-vue'
import DropDown from '../ui/DropDown.vue' import DropDown from '../ui/DropDown.vue'
import Vector from '../../assets/img/Vector.svg'
import type { FiltersQuery } from '../../stores/filtersStore' import type { FiltersQuery } from '../../stores/filtersStore'
import type { ProductSummary } from '../../api/client' 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))] 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 closeFilters = (sendQuery: boolean): void => {
const filterKeys: string[] = [] const filterKeys: string[] = []
if (sendQuery) { if (sendQuery) {
@@ -100,9 +150,141 @@ const closeFilters = (sendQuery: boolean): void => {
} }
</script> </script>
<style> <style scoped lang="less">
.shadowset { .filt {
box-shadow: 0 -4px 6px -1px rgb(0 0 0 / 0.1); background: @canvas;
border-radius: 10px 10px 0 0; 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> </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 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" 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 clear icon shows after any input and never hides itself based on actual
emptiness — only the explicit clear() click correctly hides it. emptiness — only the explicit clear() click correctly hides it.
--> -->
<template> <template>
<div class="w-full relative"> <div class="si">
<input <Icon icon="vuesax-linear:search-normal-1" class="si__icon" size="18" @click="onSearch" />
v-model="value" <input v-model="value" class="si__input" :placeholder="placeHolder" @keyup.enter="onSearch" />
class="w-full h-[44px] rounded-[5px] px-[44px] py-[10px] text-[15px] border-solid border-[1px] border-lightGray" <button v-if="data.clear" type="button" class="si__clear" aria-label="پاک کردن" @click="clear">
:placeholder="placeHolder" <Icon icon="vuesax-linear:add" size="16" />
@keyup.enter="onSearch" </button>
/>
<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> </div>
</template> </template>
@@ -46,3 +46,61 @@ watch(debounced, () => {
onSearch() onSearch()
}) })
</script> </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> <template>
<div :class="`rounded-[5px] border border-solid text-primary px-[15px] py-[15px] ${show ? 'border-secondary' : 'border-lightGray/20'}`"> <div class="dd" :class="{ 'is-open': show }">
<div class="flex w-full justify-between cursor-pointer" @click="show = !show"> <button type="button" class="dd__head" @click="show = !show">
<h5 class="flex-1 m-0"> <h3 class="dd__title">
<slot name="text">{{ text }}</slot> <slot name="text">{{ text }}</slot>
</h5> </h3>
<Icon icon="vuesax-linear:arrow-down" :class="`transition-all duration-200 ease-linear ${show ? 'rotate-180' : undefined}`" size="25" /> <Icon icon="vuesax-linear:arrow-down" class="dd__chevron" size="18" />
</div> </button>
<transition name="drop" mode="out-in" appear> <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 /> <slot />
</div> </div>
</transition> </transition>
@@ -23,6 +29,56 @@ withDefaults(defineProps<{ text?: string }>(), {})
const show = ref(false) const show = ref(false)
</script> </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"> <style lang="less">
.drop-enter-active { .drop-enter-active {
animation: drop 0.2s cubic-bezier(0.33, 1, 0.68, 1); animation: drop 0.2s cubic-bezier(0.33, 1, 0.68, 1);
@@ -35,7 +91,7 @@ const show = ref(false)
max-height: 0; max-height: 0;
} }
100% { 100% {
max-height: 120px; max-height: 240px;
} }
} }
</style> </style>
+98 -134
View File
@@ -1,155 +1,119 @@
<!-- Ported from legacy henza_pwa/src/components/ui/navigation.vue --> <!--
<template> Redesigned bottom nav — replaces the old GSAP SVG-notch bar (4 tabs: user,
<nav class="fixed bottom-0 w-full z-[10]" v-show="nav.showNav"> cart, search, home) with a flat pill-bar of 5 tabs (Home, Search, Likes,
<div class="flex flex-row-reverse justify-around items-center absolute w-full h-[50px] z-[2] bottom-0"> Notifications, Profile), each showing icon + label, and a single sliding
<template v-for="item in nav.navBarRoutes" :key="item.index"> indicator behind the active tab instead of a cut-out notch.
<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>
<svg class="h-[50px] w-full"> windowData/SVG path math from the old version is gone — the indicator is
<path id="nav" class="from-path" :d="shape" /> now pure CSS, positioned with a transform driven by the active tab's index,
</svg> 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> </nav>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue' import { computed } from 'vue'
import { gsap } from 'gsap'
import { TransitionPresets, useTransition } from '@vueuse/core'
import Icon from '../icon/Icon.vue' import Icon from '../icon/Icon.vue'
import { useNavStore } from '../../stores/navStore' import { useNavStore } from '../../stores/navStore'
import { useWindowDataStore } from '../../stores/windowDataStore'
const nav = useNavStore() const nav = useNavStore()
const windowData = useWindowDataStore()
const shape = ref('') // 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
const centershape = [95, 45] // visual column order but NOT a physical translateX — so slot 0 renders on
const width = computed(() => windowData.width) // the right, and moving right needs a NEGATIVE offset here.
const centerOfCentershape = computed(() => { const indicatorOffset = computed(() => -(nav.activeLink - 1) * 100)
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,
})
</script> </script>
<style scoped lang="less"> <style scoped lang="less">
#nav { .tabbar {
opacity: 0.4; position: fixed;
fill: #dddddd; 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; display: flex;
justify-content: center; flex-direction: column;
align-items: center; align-items: center;
width: 45px; justify-content: center;
height: 45px; 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 { .tabbar__item :deep(svg) {
.nav-item; transition: transform 0.3s cubic-bezier(0.65, 0, 0.35, 1.4);
background: @primary; }
color: @white;
border-radius: 100%; .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> </style>
+3 -1
View File
@@ -52,8 +52,10 @@ function onBack() {
<style scoped lang="less"> <style scoped lang="less">
.bar { .bar {
position: sticky; position: fixed;
top: 0; top: 0;
left: 0;
right: 0;
z-index: 20; z-index: 20;
background: rgba(255, 255, 255, 0.88); background: rgba(255, 255, 255, 0.88);
backdrop-filter: blur(14px); 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: '/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: '/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: '/search', name: 'search', component: () => import('../views/search/SearchView.vue') },
{ path: '/collection/:id?', name: 'collection', component: () => import('../views/search/CollectionView.vue') }, { path: '/collection/:id?', name: 'collection', component: () => import('../views/search/CollectionView.vue') },
// legacy redirects any unmatched path (including '/') to the home route by name. // legacy redirects any unmatched path (including '/') to the home route by name.
@@ -56,7 +57,7 @@ router.beforeEach((to) => {
return { name: 'home' }; return { name: 'home' };
} }
if (to.meta.requiresAuth && !session.customerId) { 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. // 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 // 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 // class) to a real Pinia store, preserving the same state shape and setter
// setter behavior — including the pre-existing path/route-name mismatch on // behavior. The route list itself was redesigned to a 5-tab bottom nav
// the "home" entry (path: '/' vs. the app's actual /home route), which is // (Home, Search, Cart, Orders, Profile) — see components/ui/Navigation.vue.
// unused dead data since navigation uses `name`, not `path`. Ported as-is.
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
interface NavItem { interface NavItem {
name: string name: string
path: string path: string
icon: string icon: string
label: string
index: number index: number
} }
@@ -23,12 +23,13 @@ interface NavState {
export const useNavStore = defineStore('nav', { export const useNavStore = defineStore('nav', {
state: (): NavState => ({ state: (): NavState => ({
navBarRoutes: [ navBarRoutes: [
{ name: 'userPage::hasSubRoute', path: '/user', icon: 'vuesax-linear:user', index: 1 }, { name: 'home', path: '/home', icon: 'vuesax-linear:home-2', label: 'خانه', index: 1 },
{ name: 'cart', path: '/cart', icon: 'vuesax-linear:bag-happy', index: 2 }, { name: 'search', path: '/search', icon: 'vuesax-linear:search-normal-1', label: 'جستجو', index: 2 },
{ name: 'search', path: '/search', icon: 'vuesax-linear:search-normal-1', index: 3 }, { name: 'cart', path: '/cart', icon: 'vuesax-linear:bag-happy', label: 'سبد خرید', index: 3 },
{ name: 'home', path: '/', icon: 'vuesax-linear:home-2', index: 4 }, { 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, showNav: true,
pageTransition: 'right', pageTransition: 'right',
}), }),
+15 -6
View File
@@ -32,10 +32,12 @@
<Spinner :spinning="cartData.loadingPage" tip="درحال دریافت اطلاعات"> <Spinner :spinning="cartData.loadingPage" tip="درحال دریافت اطلاعات">
<container class="cart__body"> <container class="cart__body">
<!-- Step 1: the basket --> <!-- 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"> <template v-if="cart.items.length">
<SectionHead title="اقلام سبد" :subtitle="`${cart.items.length} قلم`" />
<div class="cart__items"> <div class="cart__items">
<CartItem <CartItem
v-for="item in cart.items" v-for="item in cart.items"
@@ -58,7 +60,7 @@
دیدن محصولات دیدن محصولات
</button> </button>
</section> </section>
</template> </PullRefresh>
<!-- Step 2: submitted --> <!-- Step 2: submitted -->
<section v-else class="cart__done"> <section v-else class="cart__done">
@@ -141,12 +143,12 @@
import { onMounted, reactive, ref } from 'vue' import { onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { Drawer, Form, FormItem, Textarea } from 'ant-design-vue' import { Drawer, Form, FormItem, Textarea } from 'ant-design-vue'
import { Loading, PullRefresh } from 'vant'
import dialogEmpty4 from '../assets/img/empty/dialogEmpty4.svg' import dialogEmpty4 from '../assets/img/empty/dialogEmpty4.svg'
import loginDialogImage from '../assets/img/empty/dialogEmpty1.svg' import loginDialogImage from '../assets/img/empty/dialogEmpty1.svg'
import Icon from '../components/icon/Icon.vue' import Icon from '../components/icon/Icon.vue'
import PageBar from '../components/ui/PageBar.vue' import PageBar from '../components/ui/PageBar.vue'
import Spinner from '../components/Spinner.vue' import Spinner from '../components/Spinner.vue'
import SectionHead from '../components/home/SectionHead.vue'
import DialogVant from '../components/DialogVant.vue' import DialogVant from '../components/DialogVant.vue'
import CartItem from '../components/product/CartItem.vue' import CartItem from '../components/product/CartItem.vue'
import { useCartStore, type CartItem as CartItemType } from '../stores/cartStore' import { useCartStore, type CartItem as CartItemType } from '../stores/cartStore'
@@ -158,6 +160,7 @@ const cart = useCartStore()
const session = useSessionStore() const session = useSessionStore()
const loginNotify = ref(false) const loginNotify = ref(false)
const refreshing = ref(false)
const cartData = reactive({ const cartData = reactive({
loadingPage: false, loadingPage: false,
@@ -213,6 +216,11 @@ const submitOrder = async () => {
} }
} }
async function onRefresh() {
await cart.init()
refreshing.value = false
}
onMounted(async () => { onMounted(async () => {
await cart.init() await cart.init()
}) })
@@ -225,7 +233,7 @@ onMounted(async () => {
} }
.cart__body { .cart__body {
padding-top: 16px; padding-top: 76px;
padding-bottom: 150px; padding-bottom: 150px;
} }
@@ -233,6 +241,7 @@ onMounted(async () => {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 11px; gap: 11px;
padding-top: 16px;
} }
.cart__empty { .cart__empty {
+23 -29
View File
@@ -35,6 +35,11 @@
</div> </div>
</header> </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"> <container class="home__body">
<RateStrip :quote="goldPrice" class="home__rate" /> <RateStrip :quote="goldPrice" class="home__rate" />
@@ -85,11 +90,6 @@
<p>هنوز محصولی در مجموعه ثبت نشده است.</p> <p>هنوز محصولی در مجموعه ثبت نشده است.</p>
</section> </section>
<section class="home__section">
<SectionHead title="حساب من" subtitle="موجودی و دسترسی‌های شما" />
<AccountStrip :customer="customer" @go="onAccountNav" @login="goLogin" />
</section>
<div class="home__trust"> <div class="home__trust">
<div class="home__trustItem"> <div class="home__trustItem">
<Icon icon="vuesax-linear:shield-tick" size="18" /> <Icon icon="vuesax-linear:shield-tick" size="18" />
@@ -105,6 +105,7 @@
</div> </div>
</div> </div>
</container> </container>
</PullRefresh>
<Drawer <Drawer
v-model:visible="productDrawer" v-model:visible="productDrawer"
@@ -123,6 +124,7 @@
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { Drawer } from 'ant-design-vue' import { Drawer } from 'ant-design-vue'
import { Loading, PullRefresh } from 'vant'
import Icon from '../components/icon/Icon.vue' import Icon from '../components/icon/Icon.vue'
import SingleProduct from '../components/product/SingleProduct.vue' import SingleProduct from '../components/product/SingleProduct.vue'
import RateStrip from '../components/home/RateStrip.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 CategoryRail from '../components/home/CategoryRail.vue'
import ShowcaseCard from '../components/home/ShowcaseCard.vue' import ShowcaseCard from '../components/home/ShowcaseCard.vue'
import SectionHead from '../components/home/SectionHead.vue' import SectionHead from '../components/home/SectionHead.vue'
import AccountStrip from '../components/home/AccountStrip.vue'
import { useConfigStore } from '../stores/configStore' import { useConfigStore } from '../stores/configStore'
import type { HomeCategory } from '../stores/configStore' import type { HomeCategory } from '../stores/configStore'
import { useCartStore } from '../stores/cartStore' import { useCartStore } from '../stores/cartStore'
import { useFiltersStore } from '../stores/filtersStore' import { useFiltersStore } from '../stores/filtersStore'
import { useSessionStore } from '../stores/session'
import { api } from '../api/client' import { api } from '../api/client'
import type { CustomerDetail, GoldPriceQuote, ProductSummary } from '../api/client' import type { GoldPriceQuote, ProductSummary } from '../api/client'
const router = useRouter() const router = useRouter()
const memory = useConfigStore() const memory = useConfigStore()
const cart = useCartStore() const cart = useCartStore()
const filters = useFiltersStore() const filters = useFiltersStore()
const session = useSessionStore()
const loading = ref(true) const loading = ref(true)
const refreshing = ref(false)
const products = ref<ProductSummary[]>([]) const products = ref<ProductSummary[]>([])
const goldPrice = ref<GoldPriceQuote | null>(null) const goldPrice = ref<GoldPriceQuote | null>(null)
const customer = ref<CustomerDetail | null>(null)
const productDrawer = ref(false) const productDrawer = ref(false)
const activeProductId = ref<string | null>(null) const activeProductId = ref<string | null>(null)
@@ -203,28 +202,18 @@ function onSlide(slide: ShowcaseSlide) {
router.push({ name: 'search' }) router.push({ name: 'search' })
} }
function onAccountNav(routeName: string) {
router.push({ name: routeName })
}
function goSearch() { function goSearch() {
router.push({ name: 'search' }) router.push({ name: 'search' })
} }
function goNewest() { function goNewest() {
router.push({ name: 'newestProduct' }) router.push({ name: 'newestProduct' })
} }
function goLogin() { async function loadData() {
router.push({ name: 'login' })
}
onMounted(async () => {
try { try {
const [list] = await Promise.all([api.listProducts(), memory.init()]) const [list] = await Promise.all([api.listProducts(), memory.init()])
products.value = list products.value = list
} catch (e) { } catch (e) {
console.log(e) console.log(e)
} finally {
loading.value = false
} }
try { try {
@@ -232,14 +221,16 @@ onMounted(async () => {
} catch { } catch {
goldPrice.value = null goldPrice.value = null
} }
}
if (session.customerId) { async function onRefresh() {
try { await loadData()
customer.value = await api.getCustomer(session.customerId) refreshing.value = false
} catch { }
customer.value = null
} onMounted(async () => {
} await loadData()
loading.value = false
}) })
</script> </script>
@@ -250,8 +241,10 @@ onMounted(async () => {
} }
.home__bar { .home__bar {
position: sticky; position: fixed;
top: 0; top: 0;
left: 0;
right: 0;
z-index: 20; z-index: 20;
background: rgba(255, 255, 255, 0.88); background: rgba(255, 255, 255, 0.88);
backdrop-filter: blur(14px); backdrop-filter: blur(14px);
@@ -303,6 +296,7 @@ onMounted(async () => {
} }
.home__body { .home__body {
padding-top: 62px;
padding-bottom: 90px; padding-bottom: 90px;
} }
+1 -1
View File
@@ -82,7 +82,7 @@ const pull = async () => {
} }
.newest__body { .newest__body {
padding-top: 16px; padding-top: 76px;
padding-bottom: 90px; padding-bottom: 90px;
} }
@@ -72,7 +72,7 @@ onMounted(async () => {
} }
.coll__body { .coll__body {
padding-top: 16px; padding-top: 76px;
padding-bottom: 90px; padding-bottom: 90px;
} }
+32 -7
View File
@@ -62,7 +62,12 @@
@change="hideFilters" @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"> <div v-if="availableCollections.length" class="search__collections">
<button <button
v-for="item in availableCollections" v-for="item in availableCollections"
@@ -92,7 +97,7 @@
reaches the end — the same incremental behaviour the previous reaches the end — the same incremental behaviour the previous
ScrollFetchSearch-based list had. --> ScrollFetchSearch-based list had. -->
<div v-if="hasMore" ref="sentinel" class="search__more"> <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> </div>
<section v-else-if="!loadingPage" class="search__empty"> <section v-else-if="!loadingPage" class="search__empty">
@@ -100,12 +105,14 @@
<p>محصولی با این مشخصات پیدا نشد.</p> <p>محصولی با این مشخصات پیدا نشد.</p>
</section> </section>
</container> </container>
</PullRefresh>
</Spinner> </Spinner>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { PullRefresh } from 'vant'
import Icon from '../../components/icon/Icon.vue' import Icon from '../../components/icon/Icon.vue'
import PageBar from '../../components/ui/PageBar.vue' import PageBar from '../../components/ui/PageBar.vue'
import Spinner from '../../components/Spinner.vue' import Spinner from '../../components/Spinner.vue'
@@ -121,6 +128,7 @@ const filters = useFiltersStore()
const nav = useNavStore() const nav = useNavStore()
const loadingPage = ref(false) const loadingPage = ref(false)
const refreshing = ref(false)
const allProducts = ref<ProductSummary[]>([]) const allProducts = ref<ProductSummary[]>([])
const showFilter = ref(false) const showFilter = ref(false)
const filterKeys = ref<string[]>([]) const filterKeys = ref<string[]>([])
@@ -203,6 +211,11 @@ async function loadProducts() {
} }
} }
async function onRefresh() {
await loadProducts()
refreshing.value = false
}
function search(value: string | undefined) { function search(value: string | undefined) {
filters.query.text = value filters.query.text = value
} }
@@ -245,18 +258,26 @@ onMounted(loadProducts)
.search__barExtra { .search__barExtra {
max-width: 768px; max-width: 768px;
margin: 0 auto; margin: 0 auto;
padding: 0 16px 11px; padding: 0 16px 0;
} }
.search__chips { .search__chips {
display: flex; display: flex;
gap: 8px; gap: 4px;
overflow-x: auto; overflow-x: auto;
margin: 11px -16px 0; margin: 8px 0px 8px -16px;
padding: 0 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; scroll-snap-type: x proximity;
} }
.search__chips::before,
.search__chips::after {
content: '';
flex: 0 0 16px;
}
.search__chip { .search__chip {
scroll-snap-align: start; scroll-snap-align: start;
flex: 0 0 auto; flex: 0 0 auto;
@@ -292,10 +313,14 @@ onMounted(loadProducts)
} }
.search__body { .search__body {
padding-top: 16px; padding-top: 160px;
padding-bottom: 90px; padding-bottom: 90px;
} }
.search__body > *:first-child {
margin-top: 16px;
}
.search__collections { .search__collections {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
+17 -4
View File
@@ -48,6 +48,7 @@
</div> </div>
</header> </header>
<PullRefresh v-model="refreshing" @refresh="onRefresh">
<container class="dash__body"> <container class="dash__body">
<!-- Signed in: real position from GET /customers/:id --> <!-- Signed in: real position from GET /customers/:id -->
<template v-if="customer"> <template v-if="customer">
@@ -117,6 +118,7 @@
<RateStrip :quote="goldPrice" /> <RateStrip :quote="goldPrice" />
</section> </section>
</container> </container>
</PullRefresh>
<DialogVant :timeOut="-1" v-model:show="data.notify" :closeOnClickOverlay="true"> <DialogVant :timeOut="-1" v-model:show="data.notify" :closeOnClickOverlay="true">
<template v-slot:img> <template v-slot:img>
@@ -150,6 +152,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { PullRefresh } from 'vant'
import Icon from '../../components/icon/Icon.vue' import Icon from '../../components/icon/Icon.vue'
import SectionHead from '../../components/home/SectionHead.vue' import SectionHead from '../../components/home/SectionHead.vue'
import RateStrip from '../../components/home/RateStrip.vue' import RateStrip from '../../components/home/RateStrip.vue'
@@ -165,6 +168,7 @@ const session = useSessionStore()
const customer = ref<CustomerDetail | null>(null) const customer = ref<CustomerDetail | null>(null)
const goldPrice = ref<GoldPriceQuote | 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 // 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 // customer owes gold. Labelled the same way AccountStrip does on the home
@@ -201,7 +205,7 @@ function logOut() {
router.push({ name: 'home' }) router.push({ name: 'home' })
} }
onMounted(async () => { async function loadDashboard() {
if (session.customerId) { if (session.customerId) {
customer.value = await api.getCustomer(session.customerId) customer.value = await api.getCustomer(session.customerId)
} }
@@ -210,7 +214,14 @@ onMounted(async () => {
} catch { } catch {
goldPrice.value = null goldPrice.value = null
} }
}) }
async function onRefresh() {
await loadDashboard()
refreshing.value = false
}
onMounted(loadDashboard)
</script> </script>
<style scoped lang="less"> <style scoped lang="less">
@@ -220,8 +231,10 @@ onMounted(async () => {
} }
.dash__bar { .dash__bar {
position: sticky; position: fixed;
top: 0; top: 0;
left: 0;
right: 0;
z-index: 20; z-index: 20;
background: rgba(255, 255, 255, 0.88); background: rgba(255, 255, 255, 0.88);
backdrop-filter: blur(14px); backdrop-filter: blur(14px);
@@ -269,7 +282,7 @@ onMounted(async () => {
} }
.dash__body { .dash__body {
padding-top: 16px; padding-top: 76px;
padding-bottom: 90px; padding-bottom: 90px;
} }
@@ -69,7 +69,7 @@ onMounted(async () => {
} }
.favs__body { .favs__body {
padding-top: 16px; padding-top: 76px;
padding-bottom: 90px; padding-bottom: 90px;
} }
+5 -3
View File
@@ -84,7 +84,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, reactive, ref } from 'vue' 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 { Form, FormItem, Input } from 'ant-design-vue'
import Icon from '../../components/icon/Icon.vue' import Icon from '../../components/icon/Icon.vue'
import PageBar from '../../components/ui/PageBar.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 emit = defineEmits<{ (e: 'register', value: boolean): void }>()
const router = useRouter() const router = useRouter()
const route = useRoute()
const session = useSessionStore() const session = useSessionStore()
const formModel = reactive({ username: undefined as string | undefined }) const formModel = reactive({ username: undefined as string | undefined })
@@ -152,7 +153,8 @@ const sendToken = async (_otpCode: string) => {
body.loading = true body.loading = true
if (!matchedCustomer) return if (!matchedCustomer) return
session.login(matchedCustomer.id, matchedCustomer.mobile) 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.text = 'با موفقیت وارد شدید'
notify.show = true notify.show = true
} finally { } finally {
@@ -172,7 +174,7 @@ const btnText = computed(() => (body.step === 1 ? 'دریافت رمز عبور'
} }
.auth__body { .auth__body {
padding-top: 26px; padding-top: 86px;
padding-bottom: 90px; 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> </PageBar>
<Spinner :spinning="loading" tip="درحال دریافت اطلاعات"> <Spinner :spinning="loading" tip="درحال دریافت اطلاعات">
<PullRefresh v-model="refreshing" @refresh="onRefresh">
<container class="ord__body"> <container class="ord__body">
<template v-if="filteredOrders.length"> <template v-if="filteredOrders.length">
<SectionHead title="سفارش‌های شما" :subtitle="`${filteredOrders.length} سفارش`" />
<div class="ord__list"> <div class="ord__list">
<OrderCard v-for="item in filteredOrders" :key="item.id" :value="item" @view="openDetail" /> <OrderCard v-for="item in filteredOrders" :key="item.id" :value="item" @view="openDetail" />
</div> </div>
@@ -61,6 +61,7 @@
<p>سفارشی با این مشخصات پیدا نشد.</p> <p>سفارشی با این مشخصات پیدا نشد.</p>
</section> </section>
</container> </container>
</PullRefresh>
</Spinner> </Spinner>
<Drawer <Drawer
@@ -144,10 +145,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { Drawer } from 'ant-design-vue' import { Drawer } from 'ant-design-vue'
import { PullRefresh } from 'vant'
import Icon from '../../components/icon/Icon.vue' import Icon from '../../components/icon/Icon.vue'
import PageBar from '../../components/ui/PageBar.vue' import PageBar from '../../components/ui/PageBar.vue'
import Spinner from '../../components/Spinner.vue' import Spinner from '../../components/Spinner.vue'
import SectionHead from '../../components/home/SectionHead.vue'
import SearchInput from '../../components/search/SearchInput.vue' import SearchInput from '../../components/search/SearchInput.vue'
import OrderCard from '../../components/product/OrderCard.vue' import OrderCard from '../../components/product/OrderCard.vue'
import OrderItemCard from '../../components/product/OrderItemCard.vue' import OrderItemCard from '../../components/product/OrderItemCard.vue'
@@ -177,6 +178,7 @@ const tabs = [
const activeTab = ref('ALL') const activeTab = ref('ALL')
const loading = ref(false) const loading = ref(false)
const refreshing = ref(false)
const allOrders = ref<OrderDetail[]>([]) const allOrders = ref<OrderDetail[]>([])
const searchTerm = ref<string | undefined>(undefined) const searchTerm = ref<string | undefined>(undefined)
@@ -213,6 +215,11 @@ function openDetail(item: OrderDetail) {
viewData.model = true viewData.model = true
} }
async function onRefresh() {
await loadOrders()
refreshing.value = false
}
onMounted(loadOrders) onMounted(loadOrders)
</script> </script>
@@ -225,15 +232,14 @@ onMounted(loadOrders)
.ord__barExtra { .ord__barExtra {
max-width: 768px; max-width: 768px;
margin: 0 auto; margin: 0 auto;
padding: 0 16px 11px; padding: 0 16px 0;
} }
.ord__chips { .ord__chips {
display: flex; display: flex;
gap: 8px; gap: 8px;
overflow-x: auto; overflow-x: auto;
margin: 11px -16px 0; margin: 8px 0px 8px -16px;
padding: 0 16px;
scroll-snap-type: x proximity; scroll-snap-type: x proximity;
} }
@@ -264,13 +270,14 @@ onMounted(loadOrders)
} }
.ord__body { .ord__body {
padding-top: 16px; padding-top: 160px;
padding-bottom: 90px; padding-bottom: 90px;
} }
.ord__list { .ord__list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding-top: 16px;
gap: 11px; gap: 11px;
} }
+1 -1
View File
@@ -103,7 +103,7 @@ onMounted(async () => {
} }
.prof__body { .prof__body {
padding-top: 20px; padding-top: 80px;
padding-bottom: 90px; padding-bottom: 90px;
} }
@@ -132,7 +132,7 @@ const register = async () => {
} }
.auth__body { .auth__body {
padding-top: 26px; padding-top: 86px;
padding-bottom: 90px; padding-bottom: 90px;
} }
@@ -157,7 +157,7 @@ onMounted(async () => {
} }
.tx__body { .tx__body {
padding-top: 16px; padding-top: 76px;
padding-bottom: 90px; padding-bottom: 90px;
} }
+3 -1
View File
@@ -33,9 +33,11 @@ export default defineConfig({
}), }),
], ],
server: { server: {
port: 5174, host: '0.0.0.0',
port: 5176,
proxy: { proxy: {
'/api': 'http://localhost:3000', '/api': 'http://localhost:3000',
'/uploads': 'http://localhost:3000',
}, },
}, },
css: { css: {