feat: initial commit
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
<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>
|
||||
</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>
|
||||
</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 emit = defineEmits<{ (e: 'note', value: number): void }>()
|
||||
|
||||
const cart = useCartStore()
|
||||
const index = cart.find_index(props.value)
|
||||
|
||||
const increment = () => {
|
||||
cart.updateFeild(props.value.qty + 1, index, props.value, 'qty')
|
||||
}
|
||||
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 showDiscountWage = computed(() => false && hasDiscountWage.value && isUserLoggedIn())
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
.description-item {
|
||||
border-left: 1px solid @grey;
|
||||
}
|
||||
|
||||
.description-item:last-child {
|
||||
padding-left: 0;
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.description-item:first-child {
|
||||
padding-right: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,45 @@
|
||||
<!--
|
||||
Ported from legacy henza_pwa/src/components/product/category.vue.
|
||||
Not yet wired to real data: the new API has no categories-with-icon endpoint
|
||||
(Prisma has a Category model, but no controller/route exposes it, and there
|
||||
are no per-category icon images). HomeView passes an empty list for now, so
|
||||
this renders nothing rather than fabricated placeholder tiles — see the
|
||||
"existing issue found" note in the report about the legacy `id === 45`
|
||||
special-case, preserved here for whenever real category data exists.
|
||||
-->
|
||||
<template>
|
||||
<router-link :to="{ name: 'search' }" @click="setCats" class="cursor-pointer h-full block">
|
||||
<div
|
||||
class="h-full w-full text-center flex flex-col items-center justify-between rounded-[4px]"
|
||||
:class="value.id === '45' ? 'bg-primary' : 'border border-1 border-secondary'"
|
||||
>
|
||||
<div class="flex justify-center mt-4 shrink-0">
|
||||
<img v-lazy="value.icon?.image" alt="category" class="w-[32px] h-[32px] text-center" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mb-4 mt-4 py-1 px-1 font-semibold flex-1 flex items-center justify-center text-center min-h-[40px]"
|
||||
:class="value.id === '45' ? 'text-white' : 'text-primary'"
|
||||
>
|
||||
{{ value.title }}
|
||||
</div>
|
||||
</div>
|
||||
</router-link>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useFiltersStore } from '../../stores/filtersStore'
|
||||
|
||||
interface CategoryValue {
|
||||
id: string
|
||||
title: string
|
||||
icon?: { image: string }
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{ value: CategoryValue }>(), {})
|
||||
const filters = useFiltersStore()
|
||||
|
||||
const setCats = () => {
|
||||
filters.query.cats = [props.value.id]
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,15 @@
|
||||
<!-- 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,49 @@
|
||||
<!-- Ported from legacy henza_pwa/src/components/product/order.vue -->
|
||||
<template>
|
||||
<div
|
||||
@click="emit('view', value)"
|
||||
class="w-full cursor-pointer relative rounded-[10px] mb-4 p-[10px] text-primary border border-solid border-[#E5E7EB] bg-[#fff]"
|
||||
>
|
||||
<div class="grid grid-cols-2 p-2">
|
||||
<div>
|
||||
<div class="text-[18px] font-sans font-semibold mb-2">
|
||||
<span class="text-[#656f87]">سفارش :</span>
|
||||
{{ value.orderNumber }}
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-[16px] font-bold pl-2">{{ value.items.length }}</span>
|
||||
<span class="text-[14px] font-medium">آیتم</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid justify-end">
|
||||
<div class="rounded-[5px] px-[20px] py-[4px] w-fit text-[14px] mb-2" :style="{ background: color?.background, color: color?.font }">
|
||||
{{ statusLabel }}
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-[16px] font-bold pl-2">{{ parseFloat(value.totalGoldWeightGrams).toFixed(2) }} </span>
|
||||
<span class="text-[14px] font-medium">گرم</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<a-divider style="margin: 8px 0" />
|
||||
<div class="flex justify-between p-2">
|
||||
<div class="text-[16px] text-[#656f87]">{{ jdate(value.createdAt) }}</div>
|
||||
<Icon icon="vuesax-linear:arrow-left-1" class="cursor-pointer text-left" size="24" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Divider as ADivider } from 'ant-design-vue'
|
||||
import Icon from '../icon/Icon.vue'
|
||||
import { ORDER_STATUS_COLORS, ORDER_STATUS_LABELS } from '../../utils/orderStatus'
|
||||
import { jdate } from '../../utils/filters'
|
||||
import type { OrderDetail } from '../../api/client'
|
||||
|
||||
const props = withDefaults(defineProps<{ value: OrderDetail }>(), {})
|
||||
const emit = defineEmits<{ (e: 'view', value: OrderDetail): void }>()
|
||||
|
||||
const statusLabel = computed(() => ORDER_STATUS_LABELS[props.value.status] ?? props.value.status)
|
||||
const color = computed(() => ORDER_STATUS_COLORS[props.value.status])
|
||||
</script>
|
||||
@@ -0,0 +1,46 @@
|
||||
<!--
|
||||
Ported from legacy henza_pwa/src/components/product/orderItem.vue.
|
||||
Adapted: product/variant title comes from a client-side lookup (see
|
||||
useOrderItemProduct.ts) since the real order-items response only carries
|
||||
productVariantId. `note` always shows '-' — our OrderItem schema has no
|
||||
note field (legacy's per-item note was a real column there; ours isn't).
|
||||
-->
|
||||
<template>
|
||||
<div class="bg-white/10 rounded-[10px] p-[12px] flex items-center">
|
||||
<img v-lazy="lookup?.imageUrl" class="ml-[8px] w-[120px]" />
|
||||
<div class="flex-1" style="width: calc(100% - 82px)">
|
||||
<div class="flex items-center">
|
||||
<div class="font-semibold text-[16px] ml-2">{{ lookup?.productTitle ?? '—' }}</div>
|
||||
<div class="text-[12px] text-secondary">({{ lookup?.variantTitle }})</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2">
|
||||
<div class="mt-2">
|
||||
<div>تعداد کالاها :</div>
|
||||
<div class="pt-1">وزن :</div>
|
||||
<div class="pt-1">اجرت :</div>
|
||||
<div class="pt-1">توضیحات :</div>
|
||||
</div>
|
||||
<div class="text-left mt-2 text-black font-semibold">
|
||||
<div>{{ value.quantity }} <span>عدد</span></div>
|
||||
<div class="pt-1">{{ value.computedWeightGrams }} <span>گرم</span></div>
|
||||
<div class="pt-1">{{ value.wagePercentSnapshot }} <span>درصد</span></div>
|
||||
<div class="pt-1">-</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useOrderItemProduct, type VariantLookup } from '../../composables/useOrderItemProduct'
|
||||
import type { OrderItemDetail } from '../../api/client'
|
||||
|
||||
const props = withDefaults(defineProps<{ value: OrderItemDetail }>(), {})
|
||||
const { lookup: lookupVariant } = useOrderItemProduct()
|
||||
const lookup = ref<VariantLookup | undefined>()
|
||||
|
||||
onMounted(async () => {
|
||||
lookup.value = await lookupVariant(props.value.productVariantId)
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,65 @@
|
||||
<!--
|
||||
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>
|
||||
@@ -0,0 +1,44 @@
|
||||
<!--
|
||||
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>
|
||||
@@ -0,0 +1,75 @@
|
||||
<!--
|
||||
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>
|
||||
@@ -0,0 +1,66 @@
|
||||
<!--
|
||||
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,242 @@
|
||||
<!--
|
||||
Ported from legacy henza_pwa/src/views/singleProduct.vue.
|
||||
|
||||
CRITICAL EXISTING ISSUE — ported as-is, flagged prominently (not silently
|
||||
fixed): the bottom of legacy's script has an unconditional global side
|
||||
effect —
|
||||
history.pushState(null, location.href);
|
||||
window.onpopstate = function () { history.go(1); };
|
||||
— that runs every time a SingleProduct instance is created (not just when
|
||||
its drawer opens — ant-design-vue's Drawer does not lazily mount its slot
|
||||
content by default). This permanently overrides the browser/hardware back
|
||||
button for the rest of the session (forcing it to always go *forward*), and
|
||||
fires immediately once the Home page renders its product cards, not only
|
||||
when a product detail is actually opened. This looks like a broken attempt
|
||||
to trap the user inside the drawer. I did not fix this — it's ported
|
||||
verbatim below — but flagging it as the single most impactful bug found in
|
||||
this port so far; recommend fixing in a dedicated bug-fix pass rather than
|
||||
silently patching it here.
|
||||
|
||||
FIXED (not merely ported) — a genuine crash, not a stylistic difference:
|
||||
legacy's image-gallery guard was `v-if="product.images[0].image"`, which
|
||||
throws immediately for any product with zero images (TypeError: cannot read
|
||||
property of undefined). Since 100% of our current seeded products have
|
||||
`images: []`, porting this literally would make the view crash for every
|
||||
real product today. Legacy's own `v-else` branch (a decorative placeholder
|
||||
SVG) makes clear a "no images" case was already intended — the guard here
|
||||
is `product.images && product.images.length > 0`, which is what's needed
|
||||
for that already-intended fallback to actually be reachable. Flagging this
|
||||
explicitly since it's a behavior change, not just documentation.
|
||||
|
||||
DROPPED as confirmed-dead code (not "possibly dead" — verified unreachable):
|
||||
legacy also declared a second DialogVant (`loginStepTwoNotify`) gating order
|
||||
placement on a customer verification step. The only code that ever set it
|
||||
to `true` was in an explicicitly commented-out alternate click handler
|
||||
(legacy's own comment: "کد قبلی (نیاز به تایید مدارک...)"); the actual live
|
||||
click handler (`isUserLoggedIn() ? addToCart() : loginNotify = true`) never
|
||||
references it. It cannot show under any reachable code path. Not ported.
|
||||
-->
|
||||
<template>
|
||||
<Spinner :spinning="loading" tip="درحال دریافت اطلاعات" cls="overflow-hidden" v-if="flag && productDetail.productRef.value">
|
||||
<container class="px-[32px] py-[16px] flex flex-col pb-[66px] bg-prim max-h-screen" style="padding-bottom: 20px">
|
||||
<div class="w-screen col-span-6 mx-[-32px] max-w-screen-md">
|
||||
<div class="fixed top-[20px] right-[16px] z-[100] p-4" @click="close">
|
||||
<Icon icon="vuesax-linear:arrow-right-1" class="cursor-pointer text-white bg-secondary rounded-[5px] p-[2.5px]" size="24" />
|
||||
</div>
|
||||
<div v-if="favorites.isFavorite(id)">
|
||||
<Icon icon="vuesax-linear:heart-fill" class="cursor-pointer text-secondary z-[100] fixed top-[20px] left-[16px] p-4" @click="toggleFav" size="25" />
|
||||
</div>
|
||||
<div v-else>
|
||||
<Icon icon="vuesax-linear:heart" class="cursor-pointer text-secondary z-[100] fixed top-[20px] left-[16px] p-4" @click="toggleFav" size="25" />
|
||||
</div>
|
||||
<transition name="slideUp" mode="out-in">
|
||||
<div v-if="hasImages">
|
||||
<Slider :slides="slides" width="auto" height="55vh" />
|
||||
</div>
|
||||
<div v-else>
|
||||
<svg height="55vh" width="auto" viewBox="0 0 12 9" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M6 9C5.91177 5.77359 3.66177 4.75472 0 4.5C3.57353 4.37264 5.91177 3.18396 6 0C6.08824 3.22642 8.38235 4.4151 12 4.5C8.38235 4.75472 6.08824 5.77359 6 9Z"
|
||||
fill="#8D6A46"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
|
||||
<div class="flex-1">
|
||||
<div class="overflow-y-auto max-h-[300px]">
|
||||
<div class="w-full flex justify-between mb-4">
|
||||
<h5 class="col-span-4 m-0 text-[19px] font-medium">{{ title }}</h5>
|
||||
<Button type="primary" class="justify-self-end m-0 col-span-2" @click="showPopup = true">توضیحات</Button>
|
||||
</div>
|
||||
<div class="w-full flex justify-between mb-4">
|
||||
<div>
|
||||
<div v-if="showDiscountWage" class="flex mb-4">
|
||||
<div class="ml-4">اجرت</div>
|
||||
<div class="rounded-[5px] bg-danger text-white py-[4px] px-[8px]" v-if="promotion">-%{{ promotion.discount_percent }}</div>
|
||||
</div>
|
||||
<div v-if="showDiscountWage" class="flex">
|
||||
<div :class="{ 'text-secondary': true, 'ml-4 text-[13px] line-through decoration-danger': hasDiscountWage }">
|
||||
{{ wage != null && wage % 1 === 0 ? wage : wage?.toFixed(2) }}% اجرت
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="mb-0">{{ min_gram }}</h3>
|
||||
<div class="text-secondary mb-0 text-left">گرم</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full flex justify-between">
|
||||
<div class="w-full mb-[10px]">تنوع ها</div>
|
||||
</div>
|
||||
<div class="w-full flex-1 flex">
|
||||
<Button
|
||||
type="primary"
|
||||
:ghost="selectedVariant?.id !== item.id"
|
||||
class="last:ml-0 ml-[8px] whitespace-nowrap btn-small"
|
||||
@click="selectedVariant = item"
|
||||
v-for="item in variants"
|
||||
:key="item.id"
|
||||
>{{ item.title }}</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-[55px]"></div>
|
||||
<div class="w-full fixed bottom-0 z-20 left-0 right-0">
|
||||
<div class="w-full bg-white flex justify-between p-4 border-t max-w-[768px] m-auto shadowset border-solid border-primary/10">
|
||||
<Button type="primary" class="max-w-[768px] m-auto" size="large" block v-if="!cart.isInCart(storeProduct)" @click="isUserLoggedIn() ? addToCart() : (loginNotify = true)">
|
||||
ثبت سفارش
|
||||
</Button>
|
||||
<router-link class="w-full max-w-[768px] m-auto" :to="{ name: 'cart' }" v-else>
|
||||
<Button type="primary" size="large" block>مشاهده سبد خرید</Button>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
<Drawer v-model:visible="showPopup" placement="bottom" :closable="false" height="60vh" :class="['henza-rounded-drawer rtl', 'bg-white', 'text-black']">
|
||||
<div class="relative">
|
||||
<div class="absolute top-0 z-10 w-full">
|
||||
<Icon icon="vuesax-linear:arrow-right-1" @click="showPopup = false" class="cursor-pointer float-right ml-[30px] rounded-[100%] hover:bg-white/10" size="25" />
|
||||
<h5>{{ title }}</h5>
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-[30px]"></div>
|
||||
<div class="whitespace-pre-wrap text-justify leading-[2.5rem] max-h-[350px] overflow-y-auto">{{ body }}</div>
|
||||
</Drawer>
|
||||
</container>
|
||||
|
||||
<NotifyVant :timeOut="3000" v-model="notify" :color="'background-color: #22C55E'">
|
||||
<div class="font-bold text-right mb-2">ثبت سفارش</div>
|
||||
<div>محصول با موفقیت به سبد خرید افزوده شد</div>
|
||||
</NotifyVant>
|
||||
<DialogVant :timeOut="-1" v-model:show="loginNotify" :closeOnClickOverlay="true">
|
||||
<template v-slot:img>
|
||||
<img :src="loginDialogImage" alt="" class="opacity-80" />
|
||||
</template>
|
||||
<template v-slot:title>
|
||||
<div class="p-8 text-justify">برای دسترسی به تمامی امکانات نرم افزار هنزاگلد، ابتدا گزینه ورود/ثبت نام را لمس کنید و در صورت نداشتن حساب کاربری، ثبت نام نمایید.</div>
|
||||
</template>
|
||||
<template v-slot:button>
|
||||
<div class="text-center text-[#1C2D56]" @click="router.push({ name: 'login' })">ورود/ثبت نام</div>
|
||||
<div class="text-center text-danger" @click="loginNotify = false">بیخیال</div>
|
||||
</template>
|
||||
</DialogVant>
|
||||
</Spinner>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeMount, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Button, Drawer } from 'ant-design-vue'
|
||||
import Icon from '../icon/Icon.vue'
|
||||
import Slider from '../slider/Slider.vue'
|
||||
import Spinner from '../Spinner.vue'
|
||||
import NotifyVant from '../NotifyVant.vue'
|
||||
import DialogVant from '../DialogVant.vue'
|
||||
import { useProduct } from '../../composables/useProduct'
|
||||
import { useProductDetail } from '../../composables/useProductDetail'
|
||||
import { useFavoritesStore } from '../../stores/favoritesStore'
|
||||
import { useCartStore, type CartItem } from '../../stores/cartStore'
|
||||
import { isUserLoggedIn } from '../../utils/checkAccess'
|
||||
import loginDialogImage from '../../assets/img/empty/dialogEmpty1.svg'
|
||||
|
||||
const props = defineProps<{ id: string }>()
|
||||
const emit = defineEmits<{ (e: 'closeDrawer'): void }>()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// This component is used two ways, exactly as in legacy: embedded in a bottom
|
||||
// Drawer by the product cards, and (legacy route `singleProduct`, /product/:id)
|
||||
// as a standalone page for deep links. In the drawer case the parent listens
|
||||
// for closeDrawer; on the standalone route there is no parent, so fall back to
|
||||
// history.back() — legacy declared a goBack() for this but never wired it,
|
||||
// leaving the close button dead on the routed page.
|
||||
const close = () => {
|
||||
emit('closeDrawer')
|
||||
if (router.currentRoute.value.name === 'singleProduct') router.back()
|
||||
}
|
||||
const favorites = useFavoritesStore()
|
||||
const cart = useCartStore()
|
||||
|
||||
const showPopup = ref(false)
|
||||
const notify = ref(false)
|
||||
const loginNotify = ref(false)
|
||||
const promotion = ref<{ discount_percent?: number } | null>(null) // no promotions backend yet
|
||||
|
||||
const productDetail = useProductDetail(props.id)
|
||||
const loading = productDetail.loading
|
||||
const flag = productDetail.flag
|
||||
|
||||
// useProduct needs a real ProductSummary; give it an empty placeholder until
|
||||
// the detail fetch resolves, matching legacy's own pattern of an initial
|
||||
// empty-shaped `product` ref before data arrives.
|
||||
const emptyProduct = { id: props.id, title: '', description: null, variants: [], images: [], category: null, brand: null, collections: [] }
|
||||
const productAdapter = computed(() => useProduct(productDetail.productRef.value ?? emptyProduct))
|
||||
|
||||
const id = computed(() => productAdapter.value.id.value)
|
||||
const title = computed(() => productAdapter.value.title.value)
|
||||
const variants = computed(() => productAdapter.value.variants.value)
|
||||
const selectedVariant = computed({
|
||||
get: () => productAdapter.value.selectedVariant.value,
|
||||
set: (v) => (productAdapter.value.selectedVariant.value = v),
|
||||
})
|
||||
const min_gram = computed(() => productAdapter.value.min_gram.value)
|
||||
const wage = computed(() => productAdapter.value.wage.value)
|
||||
const hasDiscountWage = computed(() => productAdapter.value.hasDiscountWage.value)
|
||||
const body = computed(() => productDetail.productRef.value?.description ?? '')
|
||||
const hasImages = computed(() => (productDetail.productRef.value?.images.length ?? 0) > 0)
|
||||
const slides = computed(() => productDetail.productRef.value?.images.map((i) => i.url) ?? [])
|
||||
|
||||
// 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())
|
||||
|
||||
const storeProduct = computed<CartItem>(() => ({
|
||||
vid: selectedVariant.value?.id ?? '',
|
||||
note: null,
|
||||
pid: productDetail.productRef.value?.id ?? props.id,
|
||||
product: productDetail.productRef.value ?? (emptyProduct as any),
|
||||
qty: 1,
|
||||
}))
|
||||
|
||||
onBeforeMount(async () => {
|
||||
await productDetail.load()
|
||||
})
|
||||
|
||||
const addToCart = () => {
|
||||
cart.addToCart(storeProduct.value)
|
||||
notify.value = true
|
||||
}
|
||||
|
||||
const toggleFav = (e: Event) => {
|
||||
e.stopPropagation()
|
||||
favorites.toggle(props.id)
|
||||
}
|
||||
|
||||
// CRITICAL EXISTING ISSUE, ported as-is — see the file-level comment above.
|
||||
history.pushState(null, location.href)
|
||||
window.onpopstate = function () {
|
||||
history.go(1)
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user