feat: initial commit
This commit is contained in:
@@ -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