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:
co-authored by
Claude Opus 5
parent
f2d83c53df
commit
cc62975dd0
@@ -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> </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>
|
||||
</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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user