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:
@@ -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',
|
||||
|
||||
@@ -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}`);
|
||||
@@ -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');
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user