user-app: extend the redesign across the purchase path

Applies the home/product-detail design language (LESS colour tokens, BEM
class names, hairline-bordered cards, SectionHead, dashed empty states) to
the screens a shopper actually walks through: search, collection, newest,
cart and favorites.

Two shared pieces are extracted so the language stays consistent rather than
re-typed per screen:
  - components/ui/PageBar.vue — the sticky translucent bar with blur and a
    hairline, lifted from the home screen's own header so every other screen
    gets identical chrome instead of the old dark AppHeader.
  - components/product/ProductCard.vue — the catalogue workhorse card. It is
    deliberately a sibling of home/ShowcaseCard (which keeps its editorial "wide"
    variant and badge) and additionally surfaces stock, which the
    catalogue needs and the home screen does not.

Deleted as orphaned once nothing imported them: ProductList,
ProductVerticalFull, ProductVertical, ProductHorizontal, CollectionCard —
all Tailwind utility soup carried over from the legacy PWA.

Behaviour is preserved throughout; only chrome and cards change. The cart
still posts to the real POST /orders with wagePercent from each item's
pinned variant, still gates on a real session, and still keeps the note
drawer and success step. Search keeps its client-side filtering, the Filters
sheet, category chips and collection tiles. No rial price is printed
anywhere, for the same reason as the rest of the redesign: a finished
piece's price is the live gold rate x weight plus wage, computed at order
time and never stored on the product.

FIXED a regression this redesign introduced before it shipped: dropping
ScrollFetchSearch from the search grid meant all 205 catalogue products
mounted at once (205 cards, 205 images). Both search and newest now render
a 20-item window that grows as the user reaches the end.

Verified in a real browser against the running API and native database, not
just a green build: home 13 cards, search 20 -> 40 -> 60 -> 80 on scroll,
newest 20 -> 40 -> 60, cart empty state, favorites 3 cards behind a seeded
session. All images loaded (0 broken), no horizontal overflow, no console
errors beyond pre-existing vue3-lazyload noise that predates this work.
vue-tsc and vite build both clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
s.esmaeili
2026-09-15 01:01:19 +03:30
co-authored by Claude Opus 5
parent f2d83c53df
commit cc62975dd0
14 changed files with 1302 additions and 537 deletions
+197 -48
View File
@@ -1,54 +1,71 @@
<!--
Ported from legacy henza_pwa/src/components/product/cartItem.vue.
Dropped: the "اجرت ریالی" (Rial wage) block, which displayed
`selectedVariant.rial_pay` — no such per-variant Rial field exists anywhere
in our domain model (not a stubbed-off feature like promotions, genuinely
nothing to adapt), so there's nothing honest to show here.
Cart line item, restyled into the redesigned design language (see
components/home/* and HomeView): LESS colour tokens, BEM class names,
hairline borders, wine accent.
Ported originally from legacy henza_pwa/src/components/product/cartItem.vue.
Dropped there and still absent: the "اجرت ریالی" (Rial wage) block, which
displayed `selectedVariant.rial_pay` — no such per-variant Rial field exists
anywhere in our domain model, so there is nothing honest to show.
All cart behaviour is unchanged: quantity increment/decrement, remove at
qty 1, and the note affordance that opens the parent's drawer.
-->
<template>
<div class="border border-solid border-[#E5E7EB] bg-[#F9F9F9] block w-full rounded-[10px] p-[8px] flex">
<img v-lazy="image" class="w-[70px] h-[70px] m-auto" />
<div class="flex-1">
<h6 class="font-bold mb-2">{{ title }}</h6>
<div class="flex justify-between text-center mb-4">
<div class="flex-col justify-start">
<div class="mb-2 text-[11px]">وزن حدودی</div>
<div class="text-secondary text-[14px] font-bold">{{ selectedVariant?.rashWeightGrams }} گرم</div>
</div>
<div>
<div v-if="showDiscountWage" class="mb-2 text-[11px]">اجرت</div>
<div v-if="showDiscountWage" :class="{ 'text-[14px] font-bold text-secondary': true, 'line-through decoration-inherit ml-4': hasDiscountWage }">
{{ wage?.toFixed(2) }}% اجرت
</div>
<div class="text-[14px] font-bold text-danger" v-if="showDiscountWage">{{ hasDiscountWage?.toFixed(2) }}% اجرت</div>
</div>
<article class="ci">
<div class="ci__art">
<img v-if="image" v-lazy="image" :alt="title" />
<div v-else class="ci__artEmpty" aria-hidden="true">
<Icon icon="vuesax-linear:diamonds" size="20" />
</div>
<div class="flex justify-between">
<div class="mb-4">تعداد</div>
<div class="flex">
<Icon icon="vuesax-linear:add-square" class="cursor-pointer text-secondary" size="20" @click="increment" />
<span class="mx-[16px]">{{ value.qty }}</span>
<Icon icon="vuesax-linear:trash" @click="cart.remove(value)" v-if="value.qty === 1" class="cursor-pointer text-danger" size="20" />
<Icon icon="vuesax-linear:minus-square" @click="decrement" v-else class="cursor-pointer text-secondary" size="20" />
</div>
</div>
<Button class="w-full" type="primary" @click="emit('note', index)">
<template v-if="!value.note || value.note === ''">ثبت توضیحات</template>
<template v-else>ویرایش توضیحات</template>
</Button>
</div>
</div>
<div class="ci__main">
<h3 class="ci__title">{{ title }}</h3>
<div class="ci__meta">
<span class="ci__weight">{{ selectedVariant?.rashWeightGrams }} گرم</span>
<span v-if="wage != null" class="ci__wage">اجرت {{ wageLabel }}٪</span>
</div>
<div class="ci__row">
<div class="ci__qty">
<button type="button" class="ci__qtyBtn" aria-label="افزودن" @click="increment">
<Icon icon="vuesax-linear:add" size="16" />
</button>
<span class="ci__qtyValue">{{ value.qty }}</span>
<button
v-if="value.qty === 1"
type="button"
class="ci__qtyBtn ci__qtyBtn--danger"
aria-label="حذف"
@click="cart.remove(value)"
>
<Icon icon="vuesax-linear:trash" size="16" />
</button>
<button v-else type="button" class="ci__qtyBtn" aria-label="کاهش" @click="decrement">
<Icon icon="vuesax-linear:minus" size="16" />
</button>
</div>
<button type="button" class="ci__note" @click="emit('note', index)">
<Icon icon="vuesax-linear:note-text" size="15" />
{{ !value.note || value.note === '' ? 'توضیحات' : 'ویرایش توضیحات' }}
</button>
</div>
<p v-if="value.note" class="ci__noteText">{{ value.note }}</p>
</div>
</article>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { Button } from 'ant-design-vue'
import Icon from '../icon/Icon.vue'
import { useProduct } from '../../composables/useProduct'
import { useCartStore, type CartItem } from '../../stores/cartStore'
import { isUserLoggedIn } from '../../utils/checkAccess'
const props = withDefaults(defineProps<{ value: CartItem }>(), {})
const props = defineProps<{ value: CartItem }>()
const emit = defineEmits<{ (e: 'note', value: number): void }>()
const cart = useCartStore()
@@ -61,22 +78,154 @@ const decrement = () => {
cart.updateFeild(props.value.qty - 1, index, props.value, 'qty')
}
const { image, title, selectedVariant, wage, hasDiscountWage } = useProduct(props.value.product, cart.variant(props.value))
const { image, title, selectedVariant, wage } = useProduct(props.value.product, cart.variant(props.value))
const showDiscountWage = computed(() => false && hasDiscountWage.value && isUserLoggedIn())
const wageLabel = computed(() => {
const w = wage.value
if (w == null) return ''
return w % 1 === 0 ? String(w) : w.toFixed(2)
})
</script>
<style lang="less">
.description-item {
border-left: 1px solid @grey;
<style scoped lang="less">
.ci {
display: flex;
gap: 12px;
padding: 11px;
background: @canvas;
border: 1px solid @hairline;
border-radius: 18px;
}
.description-item:last-child {
padding-left: 0;
border-left: 0;
.ci__art {
flex: 0 0 76px;
width: 76px;
height: 76px;
border-radius: 13px;
overflow: hidden;
background: @hairlineSoft;
}
.description-item:first-child {
padding-right: 0;
.ci__art img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.ci__artEmpty {
width: 100%;
height: 100%;
display: grid;
place-items: center;
color: @inkFaint;
}
.ci__main {
flex: 1;
min-width: 0;
}
.ci__title {
margin: 0;
font-size: 13px;
font-weight: 700;
color: @ink;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ci__meta {
display: flex;
align-items: center;
gap: 7px;
padding-top: 5px;
}
.ci__weight {
font-size: 11.5px;
font-weight: 700;
color: @inkSoft;
}
.ci__wage {
font-size: 10.5px;
color: @inkFaint;
padding-inline-start: 7px;
border-inline-start: 1px solid @hairline;
}
.ci__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding-top: 10px;
}
.ci__qty {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px;
border-radius: 999px;
background: @hairlineSoft;
border: 1px solid @hairline;
}
.ci__qtyBtn {
width: 27px;
height: 27px;
border: 0;
cursor: pointer;
border-radius: 50%;
display: grid;
place-items: center;
color: @inkSoft;
background: @canvas;
transition: transform 0.15s ease, color 0.15s ease;
}
.ci__qtyBtn--danger {
color: @wine;
}
.ci__qtyBtn:active {
transform: scale(0.9);
}
.ci__qtyValue {
min-width: 22px;
text-align: center;
font-size: 12.5px;
font-weight: 700;
color: @ink;
}
.ci__note {
display: inline-flex;
align-items: center;
gap: 5px;
border: 0;
background: none;
cursor: pointer;
padding: 0;
font-family: inherit;
font-size: 11.5px;
font-weight: 700;
color: @wine;
white-space: nowrap;
}
.ci__noteText {
margin: 8px 0 0;
padding: 8px 10px;
border-radius: 11px;
background: @hairlineSoft;
font-size: 11.5px;
line-height: 1.7;
color: @inkMuted;
}
</style>
@@ -1,15 +0,0 @@
<!-- Ported from legacy henza_pwa/src/views/search/collection/components/CollectionCard.vue -->
<template>
<div class="flex flex-col items-center justify-center font-bold text-lg" style="color: #8c6a48">
<img class="w-[6rem] h-[6rem]" :src="image" />
<span>{{ minGram }} گرم</span>
</div>
</template>
<script setup lang="ts">
import { useProduct } from '../../composables/useProduct'
import type { ProductSummary } from '../../api/client'
const props = defineProps<{ item: ProductSummary }>()
const { image, min_gram: minGram } = useProduct(props.item)
</script>
@@ -0,0 +1,199 @@
<!--
Product card in the redesigned design language (components/home/*): LESS
colour tokens, BEM class names, hairline borders, wine accent.
This is the grid/reel card used everywhere outside the home screen (search,
collection, newest, favorites). It is deliberately a sibling of
components/home/ShowcaseCard.vue rather than a merge of the two: ShowcaseCard
is the home screen's editorial card (supports a `wide` 4:3 hero variant and a
badge), while this one is the catalogue workhorse and additionally surfaces
stock, which the catalogue needs and the home screen does not.
Replaces ProductVertical/ProductVerticalFull, whose markup was Tailwind
utility soup carried over from the legacy PWA.
WHAT IS SHOWN IS REAL — same rule as the rest of the redesign. Only fields
that genuinely exist on ProductSummary today: title, the selected variant's
rash weight and making-charge percent, stock (qty/isPreOrder) and the
catalogue image. No rial price is printed, because a finished piece's price
is the live gold rate x weight plus wage, computed at order time and never
stored on the product.
-->
<template>
<article class="pc" @click="open">
<div class="pc__art">
<img v-if="image" v-lazy="image" :alt="title" />
<div v-else class="pc__artEmpty" aria-hidden="true">
<Icon icon="vuesax-linear:diamonds" size="26" />
</div>
<button
type="button"
class="pc__fav"
:class="{ 'is-on': favorites.isFavorite(id) }"
:aria-label="favorites.isFavorite(id) ? 'حذف از علاقه‌مندی' : 'افزودن به علاقه‌مندی'"
@click.stop="favorites.toggle(id)"
>
<Icon :icon="favorites.isFavorite(id) ? 'vuesax-linear:heart-fill' : 'vuesax-linear:heart'" size="16" />
</button>
<span v-if="!is_exist" class="pc__flag">ناموجود</span>
<span v-else-if="isPreOrder && qty === 0" class="pc__flag pc__flag--soft">پیش‌سفارش</span>
</div>
<div class="pc__body">
<h3 class="pc__title">{{ title }}</h3>
<div class="pc__meta">
<span class="pc__weight">{{ min_gram }} گرم</span>
<span v-if="wage != null" class="pc__wage">اجرت {{ wageLabel }}٪</span>
</div>
</div>
</article>
<Drawer
v-model:visible="drawer"
placement="bottom"
:closable="false"
class="rtl"
height="100vh"
:bodyStyle="{ padding: 0, position: 'relative' }"
>
<SingleProduct v-if="drawer" :id="id" :key="id" @closeDrawer="drawer = false" />
</Drawer>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Drawer } from 'ant-design-vue'
import Icon from '../icon/Icon.vue'
import SingleProduct from './SingleProduct.vue'
import { useProduct } from '../../composables/useProduct'
import { useFavoritesStore } from '../../stores/favoritesStore'
import type { ProductSummary } from '../../api/client'
const props = defineProps<{ value: ProductSummary }>()
const favorites = useFavoritesStore()
const { image, id, title, min_gram, wage, is_exist, qty, isPreOrder } = useProduct(props.value)
const drawer = ref(false)
function open() {
drawer.value = true
}
const wageLabel = computed(() => {
const w = wage.value
if (w == null) return ''
return w % 1 === 0 ? String(w) : w.toFixed(2)
})
</script>
<style scoped lang="less">
.pc {
cursor: pointer;
background: @canvas;
border: 1px solid @hairline;
border-radius: 18px;
overflow: hidden;
transition: transform 0.2s ease, border-color 0.2s ease;
}
.pc:active {
transform: scale(0.985);
}
.pc__art {
position: relative;
aspect-ratio: 1 / 1;
background: @hairlineSoft;
}
.pc__art img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.pc__artEmpty {
width: 100%;
height: 100%;
display: grid;
place-items: center;
color: @inkFaint;
}
.pc__fav {
position: absolute;
top: 9px;
left: 9px;
width: 30px;
height: 30px;
display: grid;
place-items: center;
border: 0;
cursor: pointer;
border-radius: 50%;
color: @inkMuted;
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(6px);
transition: color 0.18s ease, transform 0.18s ease;
}
.pc__fav.is-on {
color: @wine;
}
.pc__fav:active {
transform: scale(0.9);
}
.pc__flag {
position: absolute;
top: 9px;
right: 9px;
padding: 4px 9px;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
color: #fff;
background: @ink;
}
.pc__flag--soft {
background: @wineSoft;
}
.pc__body {
padding: 11px 12px 13px;
}
.pc__title {
margin: 0;
font-size: 13px;
font-weight: 700;
color: @ink;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pc__meta {
display: flex;
align-items: center;
gap: 7px;
padding-top: 7px;
}
.pc__weight {
font-size: 11.5px;
font-weight: 700;
color: @inkSoft;
}
.pc__wage {
font-size: 10.5px;
color: @inkFaint;
padding-inline-start: 7px;
border-inline-start: 1px solid @hairline;
}
</style>
@@ -1,65 +0,0 @@
<!--
Ported from legacy henza_pwa/src/components/product/productHorizontal.vue.
See useProduct.ts for the honest gaps vs. legacy (no promotions/warranty/
stock backend data yet).
-->
<template>
<div @click="openDrawer" class="block flex items-stretch rounded-[10px] bg-light px-[12px] py-[12px] cursor-pointer mb-4" v-bind="$attrs">
<img v-lazy="image" class="ml-[12px] w-[70px]" />
<div class="flex-1" style="width: calc(100% - 82px)">
<div class="w-full title flex justify-between">
<h5 class="font-bold h-[25px] text-black flex-1 truncate mb-4">{{ title }}</h5>
<div v-if="favorites.isFavorite(id)">
<Icon icon="vuesax-linear:heart-fill" class="cursor-pointer text-secondary z-[2]" @click.stop="favorites.toggle(id)" size="25" />
</div>
<div v-else>
<Icon icon="vuesax-linear:heart" class="cursor-pointer text-secondary z-[2]" @click.stop="favorites.toggle(id)" size="25" />
</div>
</div>
<div class="font-bold text-[15px] text-secondary text-align-start mb-4">{{ min_gram }} گرم</div>
<div>
<div class="flex justify-between items-center">
<span
v-if="showDiscountWage"
:class="{ 'font-bold text-[15px] text-secondary text-align-start': true, 'line-through decoration-inherit ml-8': hasDiscountWage }"
>{{ wage != null && wage % 1 === 0 ? wage : wage?.toFixed(2) }}% اجرت</span
>
</div>
<div v-if="has_warranty" class="flex mt-2 font-bold">
<Icon icon="vuesax-linear:shield_dollar" color="#B49474" />
<div class="self-center text-[#B49474] mr-2">تضمین بهترین قیمت</div>
</div>
</div>
</div>
</div>
<Drawer v-model:visible="visible" placement="bottom" :closable="false" class="rtl" height="100vh" :bodyStyle="{ padding: 0, position: 'relative' }">
<SingleProduct :id="id" @closeDrawer="visible = false" />
</Drawer>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Drawer } from 'ant-design-vue'
import Icon from '../icon/Icon.vue'
import SingleProduct from './SingleProduct.vue'
import { useProduct } from '../../composables/useProduct'
import { useFavoritesStore } from '../../stores/favoritesStore'
import { isUserLoggedIn } from '../../utils/checkAccess'
import type { ProductSummary } from '../../api/client'
const props = withDefaults(defineProps<{ value: ProductSummary }>(), {})
const favorites = useFavoritesStore()
const { image, id, title, min_gram, wage, hasDiscountWage, has_warranty } = useProduct(props.value)
const visible = ref(false)
const openDrawer = () => {
visible.value = true
}
// legacy: myStore.bankerPermission && hasDiscountWage && isUserLoggedIn() — no
// banker/login state exists yet, so this always evaluates false for now.
const showDiscountWage = computed(() => false && hasDiscountWage.value && isUserLoggedIn())
</script>
@@ -1,44 +0,0 @@
<!--
Ported from legacy henza_pwa/src/components/product/productList.vue.
The "ciny"/collection tile grid (only shown on the /search route) now
receives real collection data (see catalog.service.ts's `collections`
include, added specifically to power this).
-->
<template>
<ScrollFetchSearch class="h-screen" :loading="loading" :flag="flag" :padding="padding" :is-fav="isFav" @fetch="emit('pull')">
<div v-if="$route.name === 'search'" class="grid grid-cols-2 gap-2">
<div
v-for="item in ciny"
:key="item.id"
class="w-full bg-light flex justify-center rounded-[8px] border-gray-200 border-[1px] p-3 items-center gap-2 cursor-pointer"
@click="$router.push({ path: `/collection/${item.id}`, query: { page: item.title } })"
>
<Icon icon="vuesax-linear:money-recive" class="cursor-pointer" size="25" />
<div class="text-lg font-bold mt-1">{{ item.title }}</div>
</div>
</div>
<div class="mt-4">
<ProductVerticalFull :class="index > 0 ? 'mt-[16px]' : undefined" v-for="(item, index) in value" :key="item.id" :value="item" />
</div>
</ScrollFetchSearch>
</template>
<script setup lang="ts">
import Icon from '../icon/Icon.vue'
import ProductVerticalFull from './ProductVerticalFull.vue'
import ScrollFetchSearch from '../ui/ScrollFetchSearch.vue'
import type { ProductSummary } from '../../api/client'
withDefaults(
defineProps<{
value: ProductSummary[]
ciny?: Array<{ id: string; title: string }>
loading: boolean
flag: boolean
padding?: string
isFav?: boolean
}>(),
{ ciny: () => [] },
)
const emit = defineEmits<{ (e: 'pull'): void }>()
</script>
@@ -1,75 +0,0 @@
<!--
Ported from legacy henza_pwa/src/components/product/productVertical.vue.
See useProduct.ts for the honest gaps vs. legacy.
-->
<template>
<div @click="openDrawer" class="block relative w-[280px] rounded-[10px] p-[8px] bg-light" v-bind="$attrs">
<Icon
v-if="favorites.isFavorite(id)"
icon="vuesax-linear:heart-fill"
class="cursor-pointer text-secondary absolute top-[15.5px] left-[14px]"
@click.stop="favorites.toggle(id)"
size="25"
/>
<Icon
v-else
icon="vuesax-linear:heart"
class="cursor-pointer text-secondary absolute top-[15.5px] left-[14px]"
@click.stop="favorites.toggle(id)"
size="25"
/>
<span
class="absolute top-[15.5px] right-[14px] text-danger text-[12px] px-[8px] py-[4px] rounded-[5px] border-[1px] border-danger"
v-if="hasDiscountWage"
>تخفیف</span
>
<img v-lazy="image" class="w-[200px] h-[200px] mt-[35px] m-auto" />
<div class="bg-white rounded-[5px] p-[16px] mt-[8px]">
<div class="flex mb-[16px]">
<div class="font-bold text-[15px] pl-2 text-secondary border-solid border-grey" :class="isUserLoggedIn() ? 'border-l' : ''">{{ min_gram }} گرم</div>
<span
v-if="showDiscountWage"
:class="{ 'font-bold text-[15px] text-secondary mr-2 text-align-start': true, 'line-through decoration-inherit ml-2': hasDiscountWage }"
>{{ wage?.toFixed(2) }}% اجرت</span
>
</div>
<h5 class="font-bold text-[16px] text-black m-0">{{ title }}</h5>
<div v-if="has_warranty" class="flex mt-2">
<Icon icon="vuesax-linear:shield_dollar" color="#B49474" />
<div class="self-center text-[#B49474] text-xl mr-2">تضمین بهترین قیمت</div>
</div>
<div v-else>
<div>&nbsp;</div>
<div>&nbsp;</div>
</div>
</div>
<Drawer v-model:visible="visible" placement="bottom" :closable="false" class="rtl" height="100vh" :bodyStyle="{ padding: 0, position: 'relative' }">
<SingleProduct :id="id" @closeDrawer="visible = false" />
</Drawer>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Drawer } from 'ant-design-vue'
import Icon from '../icon/Icon.vue'
import SingleProduct from './SingleProduct.vue'
import { useProduct } from '../../composables/useProduct'
import { useFavoritesStore } from '../../stores/favoritesStore'
import { isUserLoggedIn } from '../../utils/checkAccess'
import type { ProductSummary } from '../../api/client'
const props = withDefaults(defineProps<{ value: ProductSummary }>(), {})
const favorites = useFavoritesStore()
const { image, id, title, min_gram, wage, hasDiscountWage, has_warranty } = useProduct(props.value)
const visible = ref(false)
const openDrawer = () => {
visible.value = true
}
const showDiscountWage = computed(() => false && hasDiscountWage.value && isUserLoggedIn())
</script>
@@ -1,66 +0,0 @@
<!--
Ported from legacy henza_pwa/src/components/product/productVerticalFull.vue.
See useProduct.ts for the honest gaps vs. legacy.
-->
<template>
<div @click="openDrawer" class="block w-full rounded-[10px] p-[8px] bg-light cursor-pointer relative mb-4" v-bind="$attrs">
<img v-lazy="image" class="w-[277px] h-[277px] m-auto" />
<span
class="absolute top-[15.5px] right-[14px] text-danger text-[12px] px-[8px] py-[4px] rounded-[5px] border-[1px] border-danger"
v-if="hasDiscountWage"
>تخفیف</span
>
<div class="bg-white rounded-[5px] p-[16px] mt-[8px] relative">
<div class="flex w-full gap-4">
<h5 class="font-bold text-[17px] text-black mb-[16px] flex-1 truncate">{{ title }}</h5>
<Icon
v-if="favorites.isFavorite(id)"
icon="vuesax-linear:heart-fill"
class="cursor-pointer text-secondary"
@click.stop="favorites.toggle(id)"
size="25"
/>
<Icon v-else icon="vuesax-linear:heart" class="cursor-pointer text-secondary" @click.stop="favorites.toggle(id)" size="25" />
</div>
<div class="flex">
<div class="font-bold text-[15px] pl-2 text-secondary border-solid border-grey" :class="showDiscountWage ? 'border-l' : ''">{{ min_gram }} گرم</div>
<span
v-if="showDiscountWage"
:class="{ 'font-bold text-[15px] text-secondary mr-2 text-align-start': true, 'line-through decoration-inherit ml-4': hasDiscountWage }"
>{{ wage?.toFixed(2) }}% اجرت</span
>
</div>
<div v-if="has_warranty" class="flex mt-2">
<Icon icon="vuesax-linear:shield_dollar" color="#B49474" />
<div class="self-center text-[#B49474] text-xl mr-2">تضمین بهترین قیمت</div>
</div>
</div>
</div>
<Drawer v-model:visible="visible" placement="bottom" :closable="false" class="rtl" height="100vh" :bodyStyle="{ padding: 0, position: 'relative' }">
<SingleProduct :id="id" @closeDrawer="visible = false" />
</Drawer>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Drawer } from 'ant-design-vue'
import Icon from '../icon/Icon.vue'
import SingleProduct from './SingleProduct.vue'
import { useProduct } from '../../composables/useProduct'
import { useFavoritesStore } from '../../stores/favoritesStore'
import { isUserLoggedIn } from '../../utils/checkAccess'
import type { ProductSummary } from '../../api/client'
const props = withDefaults(defineProps<{ value: ProductSummary }>(), {})
const favorites = useFavoritesStore()
const { image, id, title, min_gram, wage, hasDiscountWage, has_warranty } = useProduct(props.value)
const visible = ref(false)
const openDrawer = () => {
visible.value = true
}
const showDiscountWage = computed(() => false && hasDiscountWage.value && isUserLoggedIn())
</script>
+115
View File
@@ -0,0 +1,115 @@
<!--
Sticky translucent page bar in the redesigned design language, extracted from
the home screen's own `.home__bar` so every other screen gets the identical
chrome instead of the old dark AppHeader.
Layout mirrors the home bar exactly: a leading action (usually back), a
centred title, and an optional trailing action via the `trailing` slot — so
the bar's height, blur, hairline and icon buttons stay consistent app-wide.
-->
<template>
<header class="bar">
<div class="bar__inner">
<button v-if="back" type="button" class="bar__btn" aria-label="بازگشت" @click="onBack">
<Icon icon="vuesax-linear:arrow-right-1" size="20" />
</button>
<span v-else class="bar__spacer" aria-hidden="true" />
<h1 class="bar__title">{{ title }}</h1>
<div class="bar__trailing">
<slot name="trailing">
<span class="bar__spacer" aria-hidden="true" />
</slot>
</div>
</div>
<slot name="below" />
</header>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
import Icon from '../icon/Icon.vue'
const props = withDefaults(
defineProps<{
title: string
/** Show the back button. */
back?: boolean
/** Route name to go to instead of history.back(). */
backTo?: string
}>(),
{ back: true },
)
const router = useRouter()
function onBack() {
if (props.backTo) router.push({ name: props.backTo })
else router.back()
}
</script>
<style scoped lang="less">
.bar {
position: sticky;
top: 0;
z-index: 20;
background: rgba(255, 255, 255, 0.88);
backdrop-filter: blur(14px);
border-bottom: 1px solid @hairline;
}
.bar__inner {
max-width: 768px;
margin: 0 auto;
padding: 11px 16px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.bar__title {
margin: 0;
font-size: 14.5px;
font-weight: 800;
color: @ink;
letter-spacing: -0.1px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bar__btn {
position: relative;
flex: 0 0 auto;
width: 38px;
height: 38px;
border: 0;
cursor: pointer;
border-radius: 12px;
display: grid;
place-items: center;
color: @ink;
background: @hairlineSoft;
transition: background 0.18s ease;
}
.bar__btn:active {
transform: scale(0.94);
}
.bar__trailing {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 8px;
}
.bar__spacer {
display: block;
width: 38px;
height: 38px;
}
</style>