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
@@ -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;