admin user and ui panel

This commit is contained in:
s.esmaeili
2026-09-21 08:32:31 +03:30
parent 79226c9f13
commit ef1d4638cc
341 changed files with 2270 additions and 657 deletions
+26 -1
View File
@@ -14,6 +14,13 @@ async function request<T>(path: string, options?: RequestInit): Promise<T> {
return res.json();
}
export interface ProductImageSummary {
id: string;
url: string;
sortOrder: number;
role: 'FEATURED' | 'FEATURED_MOBILE' | 'GALLERY' | 'GALLERY_MOBILE';
}
export interface ProductVariantSummary {
id: string;
title: string;
@@ -25,6 +32,12 @@ export interface ProductVariantSummary {
/// can read them.
qty?: number;
isPreOrder?: boolean;
/// The gallery image chosen for this variant in the admin panel. It is one of
/// the product's OWN images — the same ProductImage row, not a copy — so
/// several variants (e.g. different weights of one design) may share it.
/// Null when no picture was picked; callers fall back to the product image.
imageId?: string | null;
image?: ProductImageSummary | null;
}
export interface CategorySummary {
@@ -53,7 +66,9 @@ export interface ProductSummary {
/// GET /catalog/products.
productCode?: string | null;
variants: ProductVariantSummary[];
images: Array<{ url: string }>;
/// Returned sorted by sortOrder, so images[0] really is the product's main
/// photo rather than whichever row the database happened to hand back.
images: ProductImageSummary[];
category: CategorySummary | null;
brand: BrandSummary | null;
collections: Array<{ collectionId: string; collection: CollectionSummary }>;
@@ -132,6 +147,13 @@ export interface GoldPriceQuote {
quotedAt: string;
}
/** One admin-uploaded home banner slide — raw shape as saved by the admin
* panel's manage/Const.vue "اسلایدر موبایل" tab (image + optional link). */
export interface AdminSlide {
image: string;
link: string;
}
export const api = {
listProducts: () => request<ProductSummary[]>('/catalog/products'),
listCustomers: () => request<CustomerSummary[]>('/customers'),
@@ -141,4 +163,7 @@ export const api = {
createOrder: (dto: CreateOrderDto) => request<OrderSummary>('/orders', { method: 'POST', body: JSON.stringify(dto) }),
getCustomerOrders: (customerId: string) => request<OrderDetail[]>(`/customers/${customerId}/orders`),
getLatestGoldPrice: (karatPerMille = 750) => request<GoldPriceQuote>(`/pricing/latest?karatPerMille=${karatPerMille}`),
/** GET /home/slider — public, no auth. Empty array when the admin hasn't
* configured any slides (the caller falls back to a derived hero). */
getHomeSlider: () => request<AdminSlide[]>('/home/slider'),
};
@@ -1,27 +1,38 @@
<!--
Editorial hero for the home screen. Replaces the legacy swiper banner with a
self-contained, dependency-free slider: no swiper CSS to fight, no autoplay
jitter, and it keeps working when a slide has no artwork.
Editorial hero for the home screen. Self-contained, dependency-free slider:
no swiper CSS to fight, no autoplay jitter, and it keeps working when a
slide has no artwork.
Swipe support: the app sets `direction: rtl !important` on #app (see
src/App.vue), so — same fix as GallerySlider.vue — the track is an isolated
`direction: ltr` subtree (.showcase__track). Inside it, index 0 is first and
a positive translateX moves right, plain LTR arithmetic, regardless of the
page's own direction. The drag gesture itself is still read in screen space
(dragging left = next slide) so it feels natural to an RTL user; only the
track's internal layout/maths is pinned to LTR.
-->
<template>
<section class="showcase" v-if="slides.length">
<div class="showcase__frame">
<transition-group name="showcase-fade" tag="div" class="showcase__stage">
<article v-for="(slide, i) in slides" :key="slide.key" v-show="i === index" class="showcase__slide">
<img v-if="slide.image" :src="slide.image" alt="" class="showcase__art" />
<div class="showcase__scrim" aria-hidden="true"></div>
<div class="showcase__copy">
<span class="showcase__eyebrow">{{ slide.eyebrow }}</span>
<h2 class="showcase__title">{{ slide.title }}</h2>
<p class="showcase__sub">{{ slide.subtitle }}</p>
<button type="button" class="showcase__cta" @click="emit('select', slide)">
{{ slide.cta }}
<Icon icon="vuesax-linear:arrow-left-2" size="17" />
</button>
</div>
<div
ref="root"
class="showcase__frame"
:class="{ 'is-dragging': dragging }"
@pointerdown="onPointerDown"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
@pointercancel="onPointerCancel"
>
<div class="showcase__track" :style="trackStyle">
<article
v-for="(slide, i) in slides"
:key="slide.key"
class="showcase__slide"
:class="{ 'is-active': i === index }"
@click="onSlideClick(slide)"
>
<img v-if="slide.image" :src="slide.image" alt="" class="showcase__art" draggable="false" />
</article>
</transition-group>
</div>
</div>
<div class="showcase__dots" v-if="slides.length > 1">
@@ -39,8 +50,7 @@
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import Icon from '../icon/Icon.vue'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
export interface ShowcaseSlide {
key: string
@@ -50,6 +60,9 @@ export interface ShowcaseSlide {
cta: string
image?: string
categoryId?: string
/** Admin-uploaded slide's own destination (manage/Const.vue), when it has
* one. Takes priority over `categoryId` in the click handler. */
link?: string
}
const props = defineProps<{ slides: ShowcaseSlide[] }>()
@@ -75,6 +88,98 @@ onMounted(restart)
onBeforeUnmount(() => {
if (timer) clearInterval(timer)
})
// ── Drag / swipe ─────────────────────────────────────────────
const root = ref<HTMLElement | null>(null)
const dragging = ref(false)
const dragX = ref(0)
let pointerId: number | null = null
let startX = 0
let startY = 0
let trackWidth = 0
let isSwipe = false
const DRAG_COMMIT_RATIO = 0.18
const TAP_MAX_PX = 6
function onPointerDown(e: PointerEvent) {
if (props.slides.length < 2 || pointerId !== null) return
const el = root.value
if (!el) return
pointerId = e.pointerId
startX = e.clientX
startY = e.clientY
trackWidth = el.getBoundingClientRect().width || 1
dragX.value = 0
dragging.value = true
isSwipe = false
el.setPointerCapture?.(e.pointerId)
}
function onPointerMove(e: PointerEvent) {
if (!dragging.value || e.pointerId !== pointerId) return
const dx = e.clientX - startX
const dy = e.clientY - startY
if (!isSwipe && Math.abs(dx) > Math.abs(dy) && Math.abs(dx) > TAP_MAX_PX) isSwipe = true
if (isSwipe) dragX.value = dx
}
function endDrag() {
if (!dragging.value) return
const dx = dragX.value
const ratio = dx / trackWidth
dragging.value = false
pointerId = null
dragX.value = 0
// Screen-space drag direction: dragging left (negative dx) is "next slide",
// matching what the gesture means to the person doing it.
if (ratio <= -DRAG_COMMIT_RATIO && index.value < props.slides.length - 1) index.value++
else if (ratio >= DRAG_COMMIT_RATIO && index.value > 0) index.value--
if (isSwipe) restart()
isSwipe = false
}
function onPointerUp(e: PointerEvent) {
if (e.pointerId !== pointerId) return
endDrag()
}
function onPointerCancel(e: PointerEvent) {
if (e.pointerId !== pointerId) return
dragging.value = false
pointerId = null
dragX.value = 0
isSwipe = false
}
function onSlideClick(slide: ShowcaseSlide) {
// A drag also fires a trailing click on release; only treat it as a tap
// (i.e. open the slide) when the gesture never turned into a swipe.
if (isSwipe) return
emit('select', slide)
}
const trackStyle = computed(() => {
const base = -index.value * 100
const dragPercent = trackWidth ? (dragX.value / trackWidth) * 100 : 0
return {
transform: `translate3d(${base + dragPercent}%, 0, 0)`,
transition: dragging.value ? 'none' : undefined,
}
})
onBeforeUnmount(() => {
if (pointerId !== null && root.value) {
try {
root.value.releasePointerCapture(pointerId)
} catch {
/* element may already be detached */
}
}
})
</script>
<style scoped lang="less">
@@ -82,90 +187,80 @@ onBeforeUnmount(() => {
position: relative;
border-radius: 22px;
overflow: hidden;
background: @ink;
// Neutral rather than @ink: this only shows before artwork loads, and a
// dark fill read as an unwanted border wherever it was visible at all.
background: @hairlineSoft;
box-shadow: 0 26px 50px -34px rgba(20, 22, 26, 0.85);
aspect-ratio: 320 / 208;
touch-action: pan-y;
user-select: none;
-webkit-user-select: none;
// Forces its own compositing layer so `overflow: hidden` actually clips the
// translated track on Safari/iOS — without this, a sliver of the next
// slide peeks past the rounded edge because the browser composites the
// transformed child (.showcase__track) on a layer that ignores the
// parent's clip until it's promoted the same way.
transform: translateZ(0);
-webkit-transform: translateZ(0);
}
.showcase__stage {
position: relative;
display: block;
aspect-ratio: 320 / 208;
// Isolated from the page's `direction: rtl` — see the file header. Everything
// under this node uses plain LTR layout and LTR-signed transforms.
.showcase__track {
direction: ltr;
display: flex;
height: 100%;
width: 100%;
// A gentle, slightly-overshooting ease — softer settle than a linear/
// ease-out curve, which is what makes the swipe feel "alive" rather than
// mechanical. Slowed down a touch from the old 0.42s so the motion reads
// as a deliberate glide instead of a snap.
transition: transform 0.56s cubic-bezier(0.16, 0.86, 0.3, 1);
will-change: transform;
}
.showcase__frame.is-dragging .showcase__track {
transition: none;
}
.showcase__slide {
position: absolute;
inset: 0;
position: relative;
flex: 0 0 100%;
width: 100%;
height: 100%;
cursor: pointer;
// Clips its own image's scale-in (.showcase__art below) to this slide's own
// box, so the 1.06x zoom never bleeds into the neighbouring slide.
overflow: hidden;
}
.showcase__art {
position: absolute;
inset: 0;
// A plain block filling the slide, NOT an absolutely-positioned box.
// `position: absolute` + `inset` made the image's own box a second,
// independently-rounded measurement of the same area the frame already
// sizes via `aspect-ratio`; on real devices the two disagreed by a
// fraction of a pixel and the frame's dark background showed through as a
// hairline border around the artwork. Sizing it as ordinary in-flow
// content removes that second measurement entirely.
display: block;
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0.5;
transform: scale(1.04);
-webkit-user-drag: none;
user-select: none;
// Never settles below 1x: the "breathe in" flourish now runs from a larger
// scale down to exactly 1, so the image is always at least as big as the
// slide and can never reveal what sits behind it.
transform: scale(1.06);
transition: transform 0.56s cubic-bezier(0.16, 0.86, 0.3, 1);
}
.showcase__scrim {
position: absolute;
inset: 0;
background:
linear-gradient(255deg, rgba(122, 27, 51, 0.55) 0%, rgba(122, 27, 51, 0) 52%),
linear-gradient(to left, rgba(20, 22, 26, 0.2) 0%, rgba(20, 22, 26, 0.92) 72%);
.showcase__slide.is-active .showcase__art {
transform: scale(1);
}
.showcase__copy {
position: absolute;
inset: 0;
padding: 22px;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
color: #fff;
}
.showcase__eyebrow {
font-size: 10.5px;
letter-spacing: 1.6px;
color: rgba(255, 255, 255, 0.62);
padding-bottom: 9px;
}
.showcase__title {
font-size: 22px;
font-weight: 800;
line-height: 1.35;
color: #fff;
margin: 0 0 7px;
}
.showcase__sub {
font-size: 12.5px;
line-height: 1.85;
color: rgba(255, 255, 255, 0.7);
margin: 0 0 16px;
max-width: 30ch;
}
.showcase__cta {
display: inline-flex;
align-items: center;
gap: 7px;
border: 0;
cursor: pointer;
padding: 9px 16px;
border-radius: 999px;
background: #fff;
color: @ink;
font-size: 12.5px;
font-weight: 700;
font-family: inherit;
transition: transform 0.18s ease, background 0.18s ease;
}
.showcase__cta:active {
transform: scale(0.97);
.showcase__frame.is-dragging .showcase__art {
transition: none;
}
.showcase__dots {
@@ -183,19 +278,10 @@ onBeforeUnmount(() => {
cursor: pointer;
border-radius: 999px;
background: @hairline;
transition: all 0.25s ease;
transition: width 0.42s cubic-bezier(0.16, 0.86, 0.3, 1), background 0.3s ease;
}
.showcase__dot.is-active {
width: 20px;
background: @wine;
}
.showcase-fade-enter-active,
.showcase-fade-leave-active {
transition: opacity 0.55s ease;
}
.showcase-fade-enter-from,
.showcase-fade-leave-to {
opacity: 0;
}
</style>
@@ -30,7 +30,13 @@
<div class="ci__row">
<div class="ci__qty">
<button type="button" class="ci__qtyBtn" aria-label="افزودن" @click="increment">
<button
type="button"
class="ci__qtyBtn"
aria-label="افزودن"
:disabled="!canIncrement"
@click="increment"
>
<Icon icon="vuesax-linear:add" size="16" />
</button>
<span class="ci__qtyValue">{{ value.qty }}</span>
@@ -71,7 +77,13 @@ const emit = defineEmits<{ (e: 'note', value: number): void }>()
const cart = useCartStore()
const index = cart.find_index(props.value)
// Stock ceiling, same rule as the product page: uncapped for a pre-order
// variant, otherwise limited to what is on hand. The store clamps as well.
const maxQty = computed(() => cart.maxQty(props.value))
const canIncrement = computed(() => maxQty.value === null || props.value.qty < maxQty.value)
const increment = () => {
if (!canIncrement.value) return
cart.updateFeild(props.value.qty + 1, index, props.value, 'qty')
}
const decrement = () => {
@@ -102,22 +102,55 @@
</div>
</div>
<!-- Variants -->
<section v-if="variants && variants.length > 1" class="pd__block">
<h2 class="pd__blockTitle">انتخاب وزن</h2>
<div class="pd__variants no-scrollbar">
<!--
Variants. Each chip carries the variant's OWN gallery picture when the
admin picked one, so two variants of the same weight (say plain vs.
stone-set) are told apart at a glance instead of only by title — and
several weights of one design correctly show the same photo.
Shown from one variant up (not two): with a picture and a stock badge
the chip is worth seeing even when there is only one choice.
The row wraps rather than scrolling sideways: past ~4 chips a
horizontal strip hides the rest off-screen, and products here can have
10+ weights. Beyond two rows the extra chips collapse behind a toggle.
-->
<section v-if="variants && variants.length" class="pd__block">
<h2 class="pd__blockTitle">انتخاب تنوع</h2>
<div class="pd__variants">
<button
v-for="item in variants"
v-for="item in visibleVariants"
:key="item.id"
type="button"
class="pd__variant"
:class="{ 'is-on': selectedVariant?.id === item.id }"
:class="{
'is-on': selectedVariant?.id === item.id,
'is-out': !isOrderable(item),
}"
:disabled="!isOrderable(item)"
@click="selectedVariant = item"
>
<img
v-if="variantImage(item)"
class="pd__variantImg"
:src="variantImage(item)"
:alt="item.title"
/>
<span class="pd__variantTitle">{{ item.title }}</span>
<span class="pd__variantWeight">{{ Number(item.rashWeightGrams) }} گرم</span>
<span class="pd__variantStock" :class="stockChipClass(item)">
{{ stockChipLabel(item) }}
</span>
</button>
</div>
<button
v-if="variants.length > VARIANT_PREVIEW_COUNT"
type="button"
class="pd__variantsMore"
@click="showAllVariants = !showAllVariants"
>
{{ showAllVariants ? 'نمایش کمتر' : `نمایش همه (${variants.length})` }}
</button>
</section>
<!-- Specs -->
@@ -166,7 +199,13 @@
<!-- Action bar -->
<div class="pd__bar">
<div v-if="inCart" class="pd__qty">
<button type="button" class="pd__qtyBtn" aria-label="افزایش" @click="increment">
<button
type="button"
class="pd__qtyBtn"
aria-label="افزایش"
:disabled="!canIncrement"
@click="increment"
>
<Icon icon="vuesax-linear:add" size="16" />
</button>
<span class="pd__qtyValue">{{ cartQty }}</span>
@@ -264,8 +303,9 @@ import GallerySlider from './GallerySlider.vue'
import Spinner from '../Spinner.vue'
import NotifyVant from '../NotifyVant.vue'
import DialogVant from '../DialogVant.vue'
import { useProduct } from '../../composables/useProduct'
import { useProduct, isVariantOrderable } from '../../composables/useProduct'
import { useProductDetail } from '../../composables/useProductDetail'
import type { ProductVariantSummary } from '../../api/client'
import { useFavoritesStore } from '../../stores/favoritesStore'
import { useCartStore, type CartItem } from '../../stores/cartStore'
import { isUserLoggedIn } from '../../utils/checkAccess'
@@ -324,6 +364,34 @@ const qty = computed(() => productAdapter.value.qty.value)
const isPreOrder = computed(() => productAdapter.value.isPreOrder.value)
const is_exist = computed(() => productAdapter.value.is_exist.value)
// ── Variant chips ───────────────────────────────────────────────
// Two rows' worth is shown up front; the rest sit behind "نمایش همه" so a
// product with 10+ weights does not push the description off the screen.
const VARIANT_PREVIEW_COUNT = 6
const showAllVariants = ref(false)
const visibleVariants = computed(() =>
showAllVariants.value ? variants.value : variants.value.slice(0, VARIANT_PREVIEW_COUNT),
)
const isOrderable = (v: ProductVariantSummary) => isVariantOrderable(v)
/**
* The chip picture: the variant's own image when the admin picked one, else the
* product's main photo so chips never render as empty boxes. Variants sharing
* one image (same design, several weights) intentionally show the same picture.
*/
const variantImage = (v: ProductVariantSummary) =>
v.image?.url ?? productDetail.productRef.value?.images?.[0]?.url ?? ''
const stockChipLabel = (v: ProductVariantSummary) => {
if ((v.qty ?? 0) > 0) return 'موجود'
return v.isPreOrder ? 'پیش‌سفارش' : 'ناموجود'
}
const stockChipClass = (v: ProductVariantSummary) => {
if ((v.qty ?? 0) > 0) return 'is-in'
return v.isPreOrder ? 'is-pre' : 'is-none'
}
const body = computed(() => productDetail.productRef.value?.description ?? '')
const bodyIsLong = computed(() => body.value.length > 120)
const categoryTitle = computed(() => productDetail.productRef.value?.category?.title ?? '')
@@ -336,6 +404,16 @@ watch(slides, (list) => {
if (imageIndex.value > list.length - 1) imageIndex.value = 0
})
// Picking a variant scrolls the gallery to that variant's photo, so the chosen
// chip and the big picture always agree. Only moves when the variant actually
// has an image and it is one of the product's own slides.
watch(selectedVariant, (v) => {
const url = v?.image?.url
if (!url) return
const i = slides.value.indexOf(url)
if (i > -1) imageIndex.value = i
})
// ── Full-screen preview ─────────────────────────────────────────
// Tapping the inline gallery (a plain tap, not a swipe — GallerySlider tells
// the two apart itself and only emits `tap` for the former) opens a
@@ -385,7 +463,14 @@ const inCart = computed(() => cart.isInCart(storeProduct.value))
const cartIndex = computed(() => cart.find_index(storeProduct.value))
const cartQty = computed(() => (cartIndex.value > -1 ? cart.items[cartIndex.value].qty : 0))
const increment = () => cart.updateFeild(cartQty.value + 1, cartIndex.value, storeProduct.value, 'qty')
// Stock ceiling: a pre-order variant may exceed what is on hand, anything else
// stops at its qty. The store clamps too — this just keeps the button honest.
const cartMaxQty = computed(() => cart.maxQty(storeProduct.value))
const canIncrement = computed(() => cartMaxQty.value === null || cartQty.value < cartMaxQty.value)
const increment = () => {
if (!canIncrement.value) return
cart.updateFeild(cartQty.value + 1, cartIndex.value, storeProduct.value, 'qty')
}
const decrement = () => {
if (cartQty.value <= 1) {
cart.remove(storeProduct.value)
@@ -623,20 +708,22 @@ onBeforeUnmount(() => {
color: @ink;
}
/* Wraps instead of scrolling sideways — see the template comment: a product
may have 10+ weights and off-screen chips are chips nobody taps. */
.pd__variants {
display: flex;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
overflow-x: auto;
}
.pd__variant {
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 3px;
padding: 10px 14px;
padding: 8px;
border-radius: 14px;
cursor: pointer;
text-align: start;
text-align: center;
align-items: center;
background: @canvas;
border: 1px solid @hairline;
transition: border-color 0.18s ease, background 0.18s ease;
@@ -645,6 +732,54 @@ onBeforeUnmount(() => {
background: @ink;
border-color: @ink;
}
.pd__variantImg {
width: 100%;
aspect-ratio: 1;
object-fit: cover;
border-radius: 10px;
margin-bottom: 2px;
}
/* Out of stock and not pre-orderable: still listed, so the customer can see
the weight exists, but visibly dimmed and not tappable. */
.pd__variant.is-out {
opacity: 0.45;
cursor: not-allowed;
}
.pd__variantStock {
font-size: 9.5px;
font-weight: 700;
padding: 1px 6px;
border-radius: 999px;
margin-top: 1px;
}
.pd__variantStock.is-in {
color: #1a7f3c;
background: rgba(26, 127, 60, 0.1);
}
.pd__variantStock.is-pre {
color: #1f6feb;
background: rgba(31, 111, 235, 0.1);
}
.pd__variantStock.is-none {
color: @inkFaint;
background: rgba(0, 0, 0, 0.05);
}
.pd__variant.is-on .pd__variantStock {
color: #fff;
background: rgba(255, 255, 255, 0.16);
}
.pd__variantsMore {
margin-top: 10px;
width: 100%;
padding: 8px;
border-radius: 12px;
border: 1px solid @hairline;
background: transparent;
color: @ink;
font-size: 12px;
font-weight: 700;
cursor: pointer;
}
.pd__variantTitle {
font-size: 12.5px;
font-weight: 700;
+18 -2
View File
@@ -21,20 +21,36 @@
import { computed, ref, unref, type Ref } from 'vue'
import type { ProductSummary, ProductVariantSummary } from '../api/client'
/** A variant can be ordered when it has stock, or when it is flagged pre-order. */
export function isVariantOrderable(v: ProductVariantSummary): boolean {
return (v.qty ?? 0) > 0 || Boolean(v.isPreOrder)
}
function getCheapestVariant(variants: ProductVariantSummary[] | undefined): ProductVariantSummary | null {
if (!variants || variants.length === 0) return null
return variants.reduce((cheapest, v) =>
// Prefer something the customer can actually order: defaulting to an
// out-of-stock variant means the page opens on a disabled button even though
// other weights are available. Falls back to the full list when nothing is
// orderable, so the page still shows a selection.
const pool = variants.filter(isVariantOrderable)
const candidates = pool.length ? pool : variants
return candidates.reduce((cheapest, v) =>
parseFloat(v.defaultWagePercent) < parseFloat(cheapest.defaultWagePercent) ? v : cheapest,
)
}
export function useProduct(productItem: ProductSummary, initialVariant?: ProductVariantSummary) {
const product = ref<ProductSummary>(productItem)
const image = computed(() => product.value.images?.[0]?.url)
const id = computed(() => product.value.id)
const title = computed(() => product.value.title)
const variants = computed(() => product.value.variants)
const selectedVariant: Ref<ProductVariantSummary | null> = ref(initialVariant ?? getCheapestVariant(unref(variants)))
// The selected variant's own picture when the admin chose one, otherwise the
// product's main image. Several variants may legitimately resolve to the SAME
// image — one photo, several weights.
const image = computed(
() => selectedVariant.value?.image?.url ?? product.value.images?.[0]?.url,
)
const min_gram = computed(() => selectedVariant.value ? parseFloat(selectedVariant.value.rashWeightGrams) : 0)
const wage = computed(() => (selectedVariant.value ? parseFloat(selectedVariant.value.defaultWagePercent) : null))
const hasDiscountWage = ref<number | null>(null) // no promotions backend yet — see file header
+4 -1
View File
@@ -4,7 +4,10 @@ import VueLazyLoad from 'vue3-lazyload';
import App from './App.vue';
import { router } from './router';
import Container from './components/ui/Container.vue';
import Vector from './assets/img/Vector.svg';
// TODO: temporary placeholder — swap Vector.svg back in (or a proper loading
// mark) once one exists; montzar-logo.png is the brand logo, borrowed here
// just so lazy-loaded images have something other than a broken icon.
import Vector from './assets/img/montzar-logo.png';
// Global styles, ported from legacy henza_pwa/src/main.ts — same import order
// (ant-design-vue's own theme first, then our Less overrides on top). Legacy
+9 -2
View File
@@ -87,9 +87,16 @@ router.afterEach((to) => {
transition.setTransitionFlag(true);
}, 100);
// Unconditional on both branches: this hook is the single source of truth for
// tab-bar visibility. A screen may hide the bar for its own reasons while it
// is open (SearchView does, for its filter sheet), and this is what clears
// that override on the way out — the old `if (!nav.showNav)` guard made the
// same write, just phrased as if something else might own the flag.
const route = nav.navBarRoutes.find((x) => to.name === x.name);
if (route) {
nav.setActiveLink(route.index);
if (!nav.showNav) nav.setNavVisibility(true);
} else nav.setNavVisibility(false);
nav.setNavVisibility(true);
} else {
nav.setNavVisibility(false);
}
});
+38 -2
View File
@@ -54,13 +54,42 @@ export const useCartStore = defineStore('cart', {
}
},
addToCart(data: CartItem) {
// Refuse a variant that cannot be ordered — out of stock and not flagged
// pre-order. The same guard the admin cart applies (stores/cart.ts), kept
// here so it holds even if a caller forgets to disable its button.
const target = data.product?.variants?.find((v) => v.id === data.vid)
if (!target) return false
if (!((target.qty ?? 0) > 0 || target.isPreOrder)) return false
const index = this.find_index(data)
if (index === -1) this.items.push(data)
if (index === -1) this.items.push({ ...data, qty: this.capQty(data.qty, target) })
else {
this.items[index].product = data.product
this.items[index].qty = data.qty
this.items[index].qty = this.capQty(data.qty, target)
}
persist(this.items)
return true
},
/**
* Stock ceiling for a cart line. A pre-order variant is orderable beyond
* what is on hand (that is the point of pre-ordering), so it is uncapped;
* anything else stops at its on-hand quantity.
*/
capQty(want: number, variant: { qty?: number; isPreOrder?: boolean }) {
const n = Math.max(1, Math.floor(want) || 1)
if (variant.isPreOrder) return n
const max = variant.qty ?? 0
return max > 0 ? Math.min(n, max) : n
},
/** The ceiling for an item already in the cart; null when uncapped. */
maxQty(item: Pick<CartItem, 'pid' | 'vid'>) {
const i = this.find_index(item)
if (i === -1) return null
const v = this.variant(this.items[i])
if (!v || v.isPreOrder) return null
return (v.qty ?? 0) > 0 ? (v.qty as number) : null
},
remove(item: CartItem) {
const index = this.find_index(item)
@@ -72,6 +101,13 @@ export const useCartStore = defineStore('cart', {
updateFeild(value: unknown, index: number | null, item: Pick<CartItem, 'pid' | 'vid'>, field: keyof CartItem) {
if (!this.isInCart(item)) return false
const i = index ?? this.find_index(item)
// A quantity change is clamped to what is actually in stock (unless the
// variant is pre-orderable), so the ceiling holds no matter which screen
// drives the stepper.
if (field === 'qty') {
const v = this.variant(this.items[i])
value = v ? this.capQty(Number(value), v) : Math.max(1, Number(value) || 1)
}
;(this.items[i] as any)[field] = value
persist(this.items)
return true
+44 -4
View File
@@ -138,7 +138,7 @@ import type { HomeCategory } from '../stores/configStore'
import { useCartStore } from '../stores/cartStore'
import { useFiltersStore } from '../stores/filtersStore'
import { api } from '../api/client'
import type { GoldPriceQuote, ProductSummary } from '../api/client'
import type { AdminSlide, GoldPriceQuote, ProductSummary } from '../api/client'
const router = useRouter()
const memory = useConfigStore()
@@ -149,6 +149,10 @@ const loading = ref(true)
const refreshing = ref(false)
const products = ref<ProductSummary[]>([])
const goldPrice = ref<GoldPriceQuote | null>(null)
// Admin-managed banner slides (manage/Const.vue "اسلایدر موبایل"). null while
// unfetched/unavailable — distinct from "fetched, empty" — so `slides` below
// knows whether to wait or fall back to the category-derived hero.
const adminSlides = ref<AdminSlide[] | null>(null)
const productDrawer = ref(false)
const activeProductId = ref<string | null>(null)
@@ -157,9 +161,12 @@ const featured = computed<ProductSummary | null>(() => products.value[0] ?? null
const gridProducts = computed(() => products.value.slice(1, 5))
const newestProducts = computed(() => products.value.slice(5, 13))
// The hero is built from REAL categories, not a curated banner payload (none
// exists). Each slide deep-links into search filtered by that category, so the
// artwork is decorative but the destination is genuine.
// The hero prefers the admin-uploaded banner (manage/Const.vue "اسلایدر
// موبایل") when one has been configured; otherwise it falls back to a hero
// built from REAL categories, since no curated banner payload existed before
// that admin screen was wired up. Each fallback slide deep-links into search
// filtered by that category, so the artwork is decorative but the
// destination is genuine.
const SLIDE_COPY = [
{ eyebrow: 'مجموعه منتزر', cta: 'دیدن مجموعه' },
{ eyebrow: 'انتخاب سردبیر', cta: 'مشاهده' },
@@ -167,6 +174,20 @@ const SLIDE_COPY = [
]
const slides = computed<ShowcaseSlide[]>(() => {
if (adminSlides.value?.length) {
return adminSlides.value
.filter((s) => s.image)
.map((s, i) => ({
key: `${i}-${s.image}`,
eyebrow: 'مجموعه منتزر',
title: '',
subtitle: '',
cta: '',
image: s.image,
link: s.link || undefined,
}))
}
const cats = memory.cats.slice(0, 3)
if (!cats.length) return []
return cats.map((cat, i) => ({
@@ -196,6 +217,17 @@ function onCategory(cat: HomeCategory) {
}
function onSlide(slide: ShowcaseSlide) {
// Admin-uploaded slides (manage/Const.vue) carry their own destination
// instead of a categoryId — an absolute URL opens in a new tab, anything
// else is treated as an in-app path.
if (slide.link) {
if (/^https?:\/\//i.test(slide.link)) {
window.open(slide.link, '_blank', 'noopener')
} else {
router.push(slide.link)
}
return
}
if (slide.categoryId) {
filters.query.cats = [slide.categoryId]
}
@@ -221,6 +253,14 @@ async function loadData() {
} catch {
goldPrice.value = null
}
try {
adminSlides.value = await api.getHomeSlider()
} catch {
// No admin slides configured, or the endpoint failed — the category-
// derived hero below covers this silently, same as before this existed.
adminSlides.value = null
}
}
async function onRefresh() {
@@ -112,6 +112,7 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'
import { PullRefresh } from 'vant'
import Icon from '../../components/icon/Icon.vue'
import PageBar from '../../components/ui/PageBar.vue'
@@ -246,6 +247,22 @@ function onBarClick(e: MouseEvent) {
}
}
// The filter sheet deliberately hides the bottom tab bar while it is open, so
// its own fixed action bar ("اعمال فیلتر" / "حذف فیلترها") stays clickable —
// the tab bar sits at the same bottom edge with a higher z-index. But leaving
// the page with the sheet still open (browser/swipe back, or tapping a
// collection tile) never ran hideFilters, so that hide leaked: showNav stayed
// false, and on any destination outside the five tab routes the user was left
// with no tab bar at all until a full page refresh reset the store.
//
// Restoring it here rather than in onBeforeUnmount is deliberate: this guard
// runs BEFORE router.afterEach, which then writes the correct visibility for
// the destination and gets the last word. onBeforeUnmount runs after it, and
// would wrongly re-show the bar on full-page routes like /product/:id.
onBeforeRouteLeave(() => {
if (showFilter.value) nav.setNavVisibility(true)
})
onMounted(loadProducts)
</script>