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