feat: initial commit

This commit is contained in:
s.esmaeili
2026-09-09 06:46:27 +03:30
commit 0ed6bed028
214 changed files with 20177 additions and 0 deletions
@@ -0,0 +1,58 @@
<!--
Ported from legacy henza_pwa/src/components/appHeader.vue.
NOTE (existing issue, ported as-is per instructions — not fixed here):
the header's id is "" (empty) but onEnter() looks up getElementById('appHeader').
That lookup always returns null, so onEnter() never actually sets the header
height from this code path in practice. Preserved exactly; flagged separately.
-->
<template>
<transition name="shutter">
<header
id=""
class="overflow-hidden bg-[#1C2D56] rounded-bl-[32px] p-[20px] pl-[30px] w-full mb-[0px] z-10"
v-if="transition.transitionFlag"
>
<slot />
</header>
</transition>
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted } from 'vue'
import { useTransitionStore } from '../stores/transitionStore'
const transition = useTransitionStore()
function onEnter() {
const element = document.getElementById('appHeader')
if (element && element.offsetHeight) {
element.style.position = 'fixed'
element.style.top = '0'
transition.setHeaderHeight(element.offsetHeight + 16)
}
}
function onBeforeLeave() {
if (transition.headerHeight > 0) transition.setHeaderHeight(0)
}
onBeforeUnmount(() => onBeforeLeave())
onMounted(() => {
setTimeout(() => onEnter(), 1000)
})
</script>
<style lang="less">
.shutter-enter-active {
animation: shutter 1s cubic-bezier(0.33, 1, 0.68, 1);
}
@keyframes shutter {
0% {
max-height: 0;
opacity: 0;
color: transparent !important;
}
100% {
max-height: 120px;
opacity: 1;
}
}
</style>
@@ -0,0 +1,56 @@
<!--
Ported from legacy henza_pwa/src/components/DialogVant.vue.
EXISTING ISSUE, ported as-is (not fixed): this component doesn't declare a
`show`/`modelValue` prop, and the internal `watch(() => props.modelValue, ...)`
references a prop that was never declared (always undefined, so that watcher
effectively never fires — its `showNotify`/timeout auto-hide logic is dead
code). Actual visibility works anyway, because <van-dialog> is this
component's sole root element and Vue's automatic attribute fallthrough
passes the parent's `v-model:show` straight through to it. Preserved exactly.
-->
<template>
<van-dialog>
<div class="my-4">
<div class="w-full flex justify-center">
<slot name="img">{{ text2 }}</slot>
</div>
<div class="w-full flex justify-center text-[18px] font-semibold">
<slot name="title">{{ text1 }}</slot>
</div>
<div class="grid grid-cols-2 border-t border-solid border-[#E5E7EB] pt-4 mx-2 mt-6 font-semibold text-[15px]">
<slot name="button">{{ text3 }}</slot>
</div>
</div>
</van-dialog>
</template>
<script setup lang="ts">
import { Dialog as VanDialog } from 'vant'
withDefaults(
defineProps<{
timeOut: number
text1?: string
text2?: string
text3?: string
}>(),
{},
)
</script>
<style lang="less">
.van-dialog__confirm,
.van-dialog__cancel {
display: none !important;
}
.van-dialog__confirm .van-button__text {
color: #1c2d56;
font-weight: 600;
font-size: 15px;
}
.van-dialog__cancel .van-button__text {
color: #6b7280;
font-weight: 600;
font-size: 15px;
}
</style>
@@ -0,0 +1,56 @@
<!--
Ported from legacy henza_pwa/src/components/DialogVantOneButton.vue — the
single-action variant of DialogVant (one full-width button instead of a
two-column grid). Used by App.vue's "new version available" dialog.
Same structure as DialogVant.vue: `show` is not a declared prop; the parent's
v-model:show falls through onto <van-dialog>, which is this component's sole
root element. Legacy's internal watch on an undeclared `modelValue` prop was
dead code and is not carried over.
-->
<template>
<van-dialog>
<div class="my-4">
<div class="w-full flex justify-center">
<slot name="img">{{ text2 }}</slot>
</div>
<div class="w-full flex justify-center text-[18px] font-semibold">
<slot name="title">{{ text1 }}</slot>
</div>
<div class="border-t border-solid border-[#E5E7EB] pt-4 mx-2 mt-6 font-semibold text-[15px]">
<slot name="button">{{ text3 }}</slot>
</div>
</div>
</van-dialog>
</template>
<script setup lang="ts">
import { Dialog as VanDialog } from 'vant'
withDefaults(
defineProps<{
timeOut: number
text1?: string
text2?: string
text3?: string
}>(),
{},
)
</script>
<style lang="less">
.van-dialog__confirm,
.van-dialog__cancel {
display: none !important;
}
.van-dialog__confirm .van-button__text {
color: #1c2d56;
font-weight: 600;
font-size: 15px;
}
.van-dialog__cancel .van-button__text {
color: #6b7280;
font-weight: 600;
font-size: 15px;
}
</style>
@@ -0,0 +1,32 @@
<!-- Ported from legacy henza_pwa/src/components/empty.vue -->
<template>
<div class="flex justify-center items-center empty">
<Empty description="موردی یافت نشد" :image="image" class="h-[200px] w-[200px] opacity-70" />
</div>
</template>
<script setup lang="ts">
import { Empty } from 'ant-design-vue'
withDefaults(
defineProps<{
description?: string
image?: string
}>(),
{},
)
</script>
<style lang="less">
.ant-empty {
.ant-empty-description {
font-size: 16px !important;
font-weight: 600;
margin-top: -36px;
}
}
.empty {
min-height: calc(100vh - 350px);
vertical-align: middle;
}
</style>
@@ -0,0 +1,50 @@
<!-- Ported from legacy henza_pwa/src/components/NotifyVant.vue -->
<template>
<div v-if="modelValue" class="flex">
<van-notify v-model:show="show" :style="color">
<div class="w-full text-white my-2">
<div class="w-full flex content-end">
<Icon icon="vuesax-linear:info-circle" class="ml-[20px] text-white" size="25" />
<div>
<slot>{{ text }}</slot>
</div>
</div>
</div>
</van-notify>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { Notify as VanNotify } from 'vant'
import Icon from './icon/Icon.vue'
const show = ref(true)
const props = withDefaults(
defineProps<{
modelValue: boolean
timeOut: number
text?: string
color?: string
}>(),
{},
)
const emit = defineEmits<{ (e: 'update:modelValue', value: boolean): void }>()
watch(
() => props.modelValue,
() => {
if (props.timeOut !== -1) showNotify()
},
)
const showNotify = () => {
setTimeout(() => {
emit('update:modelValue', false)
}, props.timeOut)
}
</script>
<!--
Legacy also had a `.notify-enter-active`/`.notify-leave-active` style block
referencing an undefined `notify-in` @keyframes and unused by any
<transition name="notify"> in this component — genuinely dead, dropped.
-->
+124
View File
@@ -0,0 +1,124 @@
<!-- Ported from legacy henza_pwa/src/components/spinner.vue -->
<template>
<Spin :spinning="spinning" :tip="tip" :size="(size as 'small' | 'default' | 'large')" :delay="delay" class="!max-h-screen !fixed">
<template #indicator>
<svg v-if="!indicator" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 114.39 137.84">
<g>
<g>
<path
d="M26.69,62.76c18.5,0,29.8,6.86,30.14,23.67C57.17,69.62,68.47,62.76,87,62.76v0c-18.5,0-29.8-6.86-30.14-23.67-.34,16.81-11.64,23.67-30.14,23.67"
style="fill: #896b43"
/>
<path
d="M25.54,57.76v43.42q-.16,21.22-9.45,29.52-5.64,5-15.26,7.13L0,136.34a24.6,24.6,0,0,0,11.11-12.27q3.64-8.29,3.65-22.89V12.11a10,10,0,0,0-3.07-7.38A10.23,10.23,0,0,0,4.14,1.66h-2V0H38.31V1.66H36.15a10.14,10.14,0,0,0-7.22,2.9,10.73,10.73,0,0,0-3.4,7.21V54.89"
style="fill: #21284e"
/>
<path
d="M88.86,54.9V11.78a10.71,10.71,0,0,0-3.4-7.21,10.14,10.14,0,0,0-7.22-2.9H76.08V0h36.16V1.67h-2a10.27,10.27,0,0,0-7.55,3.07,10,10,0,0,0-3.07,7.38v89.07q0,14.6,3.65,22.89a24.64,24.64,0,0,0,11.11,12.27l-.83,1.49q-9.61-2.16-15.26-7.13-9.28-8.3-9.45-29.52V57.77"
style="fill: #21284e"
/>
</g>
</g>
</svg>
<div class="loading" v-else>
<div class="effect-1 effects"></div>
<div class="effect-2 effects"></div>
<div class="effect-3 effects"></div>
</div>
</template>
<div :class="cls">
<slot />
</div>
</Spin>
</template>
<script setup lang="ts">
import { Spin } from 'ant-design-vue'
withDefaults(
defineProps<{
tip?: string
size?: string
cls?: string
delay?: number
spinning: boolean
indicator?: boolean
}>(),
{ size: 'large' },
)
</script>
<style lang="less">
.loading * {
box-sizing: border-box;
}
.loading {
position: relative;
width: 50px;
height: 50px;
border-radius: 50%;
box-sizing: border-box;
border: 3px solid transparent;
}
.loading .effect-1,
.loading .effect-2 {
position: absolute;
width: 50px;
height: 50px;
border: 3px solid transparent;
border-left: 3px solid @secondary;
border-radius: 50%;
box-sizing: border-box;
}
.loading .effect-1 {
animation: rotate 1s ease infinite;
}
.loading .effect-2 {
animation: rotateOpacity 1s ease infinite 0.1s;
}
.loading .effect-3 {
position: absolute;
width: 50px;
height: 50px;
border: 3px solid transparent;
border-left: 3px solid @secondary;
animation: rotateOpacity 1s ease infinite 0.2s;
border-radius: 50%;
box-sizing: border-box;
}
.loading .effects {
transition: all 0.3s ease;
}
@keyframes rotate {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(1turn);
}
}
@keyframes rotateOpacity {
0% {
transform: rotate(0deg);
opacity: 0.1;
}
100% {
transform: rotate(1turn);
opacity: 1;
}
}
.ant-spin-text {
color: @primary;
}
.loading + .ant-spin-text {
margin-top: 24px;
}
</style>
+19
View File
@@ -0,0 +1,19 @@
<!-- Ported from legacy henza_pwa/src/components/viewAll.vue.
Legacy left `to` undeclared and relied on Vue's attribute fallthrough to
land it on the root <router-link>; declared explicitly here only because
vue-tsc's template type-checking (which legacy's own build never ran)
requires router-link's `to` to be a known prop — same runtime value ends
up bound either way, no behavior change. -->
<template>
<router-link :to="to" class="text-primary w-[280px] rounded-[10px] p-[8px] bg-light flex flex-col justify-center items-center">
<Icon icon="vuesax-linear:arrow-left-1" class="cursor-pointer" size="25" />
مشاهده همه
</router-link>
</template>
<script setup lang="ts">
import type { RouteLocationRaw } from 'vue-router'
import Icon from './icon/Icon.vue'
defineProps<{ to: RouteLocationRaw }>()
</script>
@@ -0,0 +1,34 @@
<template>
<span ref="refIcon" :style="{ color: color ?? undefined }" class="liman-icons flex" v-html="tempIcon"></span>
</template>
<script lang="ts" setup>
import { onMounted, ref } from 'vue'
import icons from './icons.json'
interface Props {
icon: string
color?: string | null
size?: string | number
}
const props = withDefaults(defineProps<Props>(), {
color: null,
})
const tempIcon = ref<string>()
const refIcon = ref<HTMLSpanElement>()
tempIcon.value = (icons as Record<string, string>)[`${props.icon}`]
onMounted(() => {
if (props.size) {
const svg = refIcon.value?.children[0] as SVGElement
svg.setAttribute('width', props.size as string)
svg.setAttribute('height', props.size as string)
}
})
</script>
<style scoped>
.liman-icons :deep(svg path) {
stroke: currentColor;
}
</style>
File diff suppressed because one or more lines are too long
@@ -0,0 +1,138 @@
<!-- Ported from legacy henza_pwa/src/components/input/otpInput.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import SingleOtpInput from './SingleOtpInput.vue'
const BACKSPACE = 8
const LEFT_ARROW = 37
const RIGHT_ARROW = 39
const DELETE = 46
const props = withDefaults(
defineProps<{
defaultFocus?: boolean
numInputs?: number
separator?: string
inputClasses?: string
inputType?: string
shouldAutoFocus?: boolean
placeholder?: string
disabled?: boolean
}>(),
{ numInputs: 4, shouldAutoFocus: false, disabled: false },
)
const emit = defineEmits<{
(e: 'on-change', value: string | number): void
(e: 'on-complete', value: string): void
}>()
const activeInput = ref(0)
const otp = ref<string[]>([])
const oldOtp = ref<string[]>([])
const handleOnFocus = (index: number) => {
if (props.defaultFocus && index < props.numInputs && !otp.value[index]) {
activeInput.value = otp.value.length
} else activeInput.value = index
}
const handleOnBlur = () => {
activeInput.value = -1
}
const checkFilledAllInputs = () => {
if (otp.value.join('').length === props.numInputs) {
return emit('on-complete', otp.value.join(''))
}
return 'Wait until the user enters the required number of characters'
}
const focusInput = (input: number) => {
activeInput.value = Math.max(Math.min(props.numInputs - 1, input), 0)
}
const focusNextInput = () => focusInput(activeInput.value + 1)
const focusPrevInput = () => focusInput(activeInput.value - 1)
const changeCodeAtFocus = (value: number | string) => {
oldOtp.value = Object.assign([], otp.value)
if (typeof value === 'string' && value === '') otp.value.splice(activeInput.value, 1)
else otp.value[activeInput.value] = value as string
if (oldOtp.value.join('') !== otp.value.join('')) {
emit('on-change', otp.value.join(''))
checkFilledAllInputs()
}
}
const handleOnPaste = (event: Event) => {
event.preventDefault()
const clipboardEvent = event as ClipboardEvent
const pastedData = (clipboardEvent.clipboardData?.getData('text/plain') ?? '').slice(0, props.numInputs - activeInput.value).split('')
if (props.inputType === 'number' && !pastedData.join('').match(/^\d+$/)) {
return 'Invalid pasted data'
}
const currentCharsInOtp = otp.value.slice(0, activeInput.value)
const combinedWithPastedData = currentCharsInOtp.concat(pastedData)
combinedWithPastedData.slice(0, props.numInputs).forEach((value, i) => {
otp.value[i] = value
})
focusInput(combinedWithPastedData.slice(0, props.numInputs).length)
return checkFilledAllInputs()
}
const handleOnChange = (value: number | string) => {
changeCodeAtFocus(value)
focusNextInput()
}
const handleOnKeyDown = (event: KeyboardEvent) => {
switch (event.keyCode) {
case BACKSPACE:
event.preventDefault()
changeCodeAtFocus('')
focusPrevInput()
break
case DELETE:
event.preventDefault()
changeCodeAtFocus('')
break
case LEFT_ARROW:
event.preventDefault()
focusPrevInput()
break
case RIGHT_ARROW:
event.preventDefault()
focusNextInput()
break
default:
break
}
}
</script>
<template>
<div>
<div class="flex ltr justify-around w-full">
<SingleOtpInput
v-for="(item, i) in numInputs"
:key="i"
:focus="activeInput === i"
:value="otp[i]"
:separator="separator"
:input-type="inputType"
:input-classes="inputClasses"
:is-last-child="i === numInputs - 1"
:should-auto-focus="shouldAutoFocus"
:placeholder="placeholder"
:disabled="disabled"
@on-change="handleOnChange"
@on-keydown="handleOnKeyDown"
@on-paste="handleOnPaste"
@on-focus="handleOnFocus(i)"
@on-blur="handleOnBlur"
/>
</div>
</div>
</template>
@@ -0,0 +1,117 @@
<!-- Ported from legacy henza_pwa/src/components/input/SingleOtpInput.vue -->
<template>
<div class="otp-single-input">
<input
:type="inputType"
:placeholder="placeholder"
:disabled="disabled"
ref="input"
min="0"
max="9"
maxlength="1"
pattern="[0-9]"
v-model="model"
:class="inputClasses"
@input="handleOnChange"
@keydown="handleOnKeyDown"
@paste="handleOnPaste"
@focus="handleOnFocus"
@blur="handleOnBlur"
/>
<span v-if="!isLastChild && separator">
<span v-html="separator"></span>
</span>
</div>
</template>
<script setup lang="ts">
import { onBeforeMount, ref, watch, type Ref } from 'vue'
const props = withDefaults(
defineProps<{
inputType?: string
value?: string | number
separator?: string
focus?: boolean
inputClasses?: string
shouldAutoFocus?: boolean
isLastChild?: boolean
placeholder?: string
disabled?: boolean
}>(),
{ inputType: 'tel' },
)
const emit = defineEmits<{
(e: 'on-change', value: string | number): void
(e: 'on-keydown', value: KeyboardEvent): void
(e: 'on-paste', value: Event): void
(e: 'on-focus'): void
(e: 'on-blur'): void
}>()
const model = ref(props.value || '')
const input = ref<HTMLInputElement | null>(null) as Ref<HTMLInputElement>
const handleOnChange = () => {
model.value = model.value.toString()
if (model.value.length > 1) {
model.value = model.value.slice(0, 1)
}
return emit('on-change', model.value)
}
const isCodeNumeric = (charCode: number) => (charCode >= 48 && charCode <= 57) || (charCode >= 96 && charCode <= 105)
const handleOnKeyDown = (event: KeyboardEvent) => {
if (props.disabled) {
event.preventDefault()
}
const keyEvent = event || window.event
const charCode = keyEvent.which ? keyEvent.which : keyEvent.keyCode
if (isCodeNumeric(charCode) || [8, 9, 13, 37, 39, 46, 86].includes(charCode)) {
emit('on-keydown', event)
} else {
keyEvent.preventDefault()
}
}
const handleOnPaste = (event: ClipboardEvent) => emit('on-paste', event)
const handleOnFocus = () => {
input.value.select()
return emit('on-focus')
}
const handleOnBlur = () => emit('on-blur')
watch(
() => props.value,
(newValue, oldValue) => {
if (newValue !== oldValue) {
model.value = newValue ?? ''
}
},
)
watch(
() => props.focus,
(newFocusValue, oldFocusValue) => {
if (oldFocusValue !== newFocusValue && input.value && props.focus) {
input.value.focus()
input.value.select()
}
},
)
onBeforeMount(() => {
if (input.value && props.focus && props.shouldAutoFocus) {
input.value.focus()
input.value.select()
}
})
</script>
<style lang="less">
.otp-single-input {
display: flex;
align-items: center;
}
</style>
@@ -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>&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>
@@ -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>
@@ -0,0 +1,108 @@
<!--
Ported from legacy henza_pwa/src/components/search/Filters.vue, adapted:
- Brand filter: real, but derived client-side from the product list (via
`products` prop) rather than a customer-specific `myStore.state.filterBrand`
list the old backend used to send.
- Gram-range filter: real, bounds derived from the actual min/max
rashWeightGrams across all variants (rather than a customer-specific
server-provided bound).
- DROPPED: color filter — no color concept exists anywhere in the new
Product/ProductVariant schema, not a stubbed-off feature like promotions,
genuinely nothing to filter by.
- DROPPED: "میزان اجرت" (wage-percent range) filter — gated behind
`myStore.bankerPermission` in legacy, i.e. banker-only. Consistent with
the architectural cut already made in DashboardView (no banker/type
concept in the new Customer model).
- DROPPED: discount toggle — no promotions data exists (see useProduct.ts).
-->
<template>
<section>
<container class="pt-[120px] overflow-y-scroll" padding="126px">
<DropDown text="فیلتر بر اساس برند">
<CheckboxGroup v-model:value="modelValue.brands" class="w-full">
<div class="w-full grid grid-cols-1 gap-[15px]">
<Checkbox :value="item.id" class="mt-[15px]" v-for="item in availableBrands" :key="item.id">{{ item.title }}</Checkbox>
</div>
</CheckboxGroup>
</DropDown>
<Divider class="border-grey"><img v-lazy="Vector" alt="svg" class="mx-[10px]" /></Divider>
<DropDown text="فیلتر بر اساس گرم">
<div class="px-[12px] pt-[15px]">
<Slider v-model:value="(modelValue.limit_gram as unknown as [number, number])" :step="0.01" range :min="gramBounds[0]" :max="gramBounds[1]" />
<div class="flex justify-between mt-[30px]" v-if="modelValue.limit_gram.length > 0">
<div><span class="text-lightGray">بیشترین : </span><span class="text-primary">{{ modelValue.limit_gram[1] }} گرم</span></div>
<div><span class="text-lightGray">کمترین : </span><span class="text-primary">{{ modelValue.limit_gram[0] }} گرم</span></div>
</div>
</div>
</DropDown>
<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 border-solid border-primary/10 shadowset">
<div class="w-full z-10 bg-white flex grid grid-cols-2 gap-4 max-w-[768px] m-auto">
<Button size="large" :type="('light' as any)" block @click="closeFilters(false)">پاک کردن فیلتر ها</Button>
<Button size="large" type="primary" class="btn-secondary" block @click="closeFilters(true)">اعمال فیلتر</Button>
</div>
</div>
</div>
</container>
</section>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { Button, Checkbox, CheckboxGroup, Divider, Slider } from 'ant-design-vue'
import DropDown from '../ui/DropDown.vue'
import Vector from '../../assets/img/Vector.svg'
import type { FiltersQuery } from '../../stores/filtersStore'
import type { ProductSummary } from '../../api/client'
const props = withDefaults(defineProps<{ modelValue: FiltersQuery; filterKeys: string[]; products: ProductSummary[] }>(), {})
const emit = defineEmits<{
(e: 'change', value: { resetFilters: boolean }): void
(e: 'update:filterKeys', value: string[]): void
}>()
const availableBrands = computed(() => {
const seen = new Map<string, { id: string; title: string }>()
for (const p of props.products) {
if (p.brand) seen.set(p.brand.id, p.brand)
}
return [...seen.values()]
})
const gramBounds = computed((): [number, number] => {
const weights = props.products.flatMap((p) => p.variants.map((v) => parseFloat(v.rashWeightGrams)))
if (weights.length === 0) return [0, 100]
return [Math.floor(Math.min(...weights)), Math.ceil(Math.max(...weights))]
})
const closeFilters = (sendQuery: boolean): void => {
const filterKeys: string[] = []
if (sendQuery) {
if (props.modelValue.brands.length > 0) filterKeys.push('brands')
if (
props.modelValue.limit_gram[0] !== undefined &&
props.modelValue.limit_gram[1] !== undefined &&
(props.modelValue.limit_gram[0] !== gramBounds.value[0] || props.modelValue.limit_gram[1] !== gramBounds.value[1])
) {
filterKeys.push('limit_gram')
}
}
if (sendQuery && filterKeys.length > 0) {
emit('update:filterKeys', filterKeys)
emit('change', { resetFilters: false })
} else {
emit('update:filterKeys', [])
emit('change', { resetFilters: true })
}
}
</script>
<style>
.shadowset {
box-shadow: 0 -4px 6px -1px rgb(0 0 0 / 0.1);
border-radius: 10px 10px 0 0;
}
</style>
@@ -0,0 +1,48 @@
<!--
Ported from legacy henza_pwa/src/components/search/SearchInput.vue.
EXISTING ISSUE, ported as-is: the debounce watcher checks `if (value)` (the
ref object itself, always truthy) instead of `if (value.value)`, so the "x"
clear icon shows after any input and never hides itself based on actual
emptiness — only the explicit clear() click correctly hides it.
-->
<template>
<div class="w-full relative">
<input
v-model="value"
class="w-full h-[44px] rounded-[5px] px-[44px] py-[10px] text-[15px] border-solid border-[1px] border-lightGray"
:placeholder="placeHolder"
@keyup.enter="onSearch"
/>
<Icon icon="vuesax-linear:search-normal-1" class="cursor-pointer absolute top-[11.82px] right-[12px] text-primary" size="25" @click="onSearch" />
<span class="absolute top-[10px] left-[12px] cursor-pointer text-primary">
<Icon icon="vuesax-linear:add" v-if="data.clear" class="ml-1 rotate-45" size="25" @click="clear" />
</span>
</div>
</template>
<script lang="ts" setup>
import { reactive, ref, watch } from 'vue'
import { useDebounce } from '@vueuse/core'
import Icon from '../icon/Icon.vue'
defineProps<{ placeHolder: string }>()
const emit = defineEmits<{ (e: 'search', value: string | undefined): void }>()
const data = reactive({ clear: false })
const value = ref<string | undefined>(undefined)
const onSearch = (): void => {
emit('search', value.value)
}
const clear = (): void => {
value.value = undefined
data.clear = false
emit('search', undefined)
}
const debounced = useDebounce(value, 700)
watch(debounced, () => {
if (value) data.clear = true
else data.clear = false
onSearch()
})
</script>
@@ -0,0 +1,146 @@
<!--
Ported from legacy henza_pwa/src/components/slider/homeSlider.vue.
`$prefix` (legacy global property, always the compile-time-constant 'henza'
from themeConfig) is inlined directly here rather than re-registered as a
global — same resolved value, no behavior change.
-->
<template>
<div>
<Swiper
:grabCursor="true"
:centeredSlides="true"
:space-between="5"
:loop="true"
:slidesPerView="1.3"
class="w-full home-slider"
:autoplay="data.autoplay"
:pagination="{ clickable: true }"
>
<SwiperSlide class="bg-center bg-cover h-auto" v-for="(item, index) in slides" :key="index">
<img @click="clickHandler(item)" v-lazy="item.src" width="100%" class="block w-full rounded-[10px] object-cover" :style="`height:${height}`" />
</SwiperSlide>
</Swiper>
<Drawer
v-model:visible="data.show"
placement="bottom"
:closable="false"
@close="setAutoPlay"
height="60%"
class="henza-rounded-drawer rtl text-white bg-primaryLighten whitespace-pre-wrap text[13px]"
>
{{ data.slideContent.text }}
</Drawer>
</div>
</template>
<script setup lang="ts">
import { Swiper, SwiperSlide } from 'swiper/vue'
import 'swiper/css'
import 'swiper/css/navigation'
import 'swiper/css/pagination'
import 'swiper/css/scrollbar'
import SwiperCore, { Autoplay, Navigation, Pagination } from 'swiper'
SwiperCore.use([Pagination, Navigation, Autoplay])
import { reactive } from 'vue'
import { Drawer } from 'ant-design-vue'
import { useRouter } from 'vue-router'
import { useFiltersStore } from '../../stores/filtersStore'
interface Slide {
text: string | undefined
type: string | undefined
src: string | undefined
link: string | undefined
}
interface Data {
show: boolean
slideContent: Slide
autoplay: { delay: number; disableOnInteraction: boolean } | boolean
}
withDefaults(defineProps<{ slides: Slide[]; height: string }>(), {})
const data: Data = reactive({
show: false,
slideContent: { text: undefined, type: undefined, src: undefined, link: undefined },
autoplay: { delay: 4000, disableOnInteraction: false },
})
const router = useRouter()
const filters = useFiltersStore()
const clickHandler = async (item: Slide) => {
if (item) {
data.slideContent = item
switch (item.type) {
case 'popup':
if (item && !(!item.text || item.text.length === 0)) data.show = true
break
case 'link':
if (item && !(!item.link || item.link.length === 0)) window.open(item.link)?.focus()
break
case 'discount':
if (!(!item.link || item.link.length === 0)) {
// EXISTING ISSUE, ported as-is: legacy sets `query.value.discount`,
// but the actual filter field is `has_discount` — this assignment
// never affected search filtering in legacy either. Not "fixed"
// here; flagged separately.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(filters.query as any).discount = true
await router.push({ name: 'search' })
}
break
default:
// code block
}
data.autoplay = false
}
}
const setAutoPlay = async () => {
data.autoplay = { delay: 4000, disableOnInteraction: false }
}
</script>
<style lang="less">
.home-slider {
&.swiper {
padding: 24px 0 24px 0;
}
.swiper-pagination-bullet {
transition: all 0.2s;
width: 6px;
height: 6px;
}
.swiper-pagination-bullet-active,
.swiper-pagination-bullet-active-main {
width: 24px;
border-radius: 10px;
}
.swiper-horizontal > .swiper-pagination-bullets,
.swiper-pagination-bullets.swiper-pagination-horizontal,
.swiper-pagination-custom,
.swiper-pagination-fraction {
bottom: 0 !important;
}
.swiper-pagination-bullets-dynamic * {
transform: unset !important;
}
.swiper-pagination-bullet-active {
background: @primary;
}
.swiper-slide {
transition: all 0.6s;
}
.swiper-slide:not(.swiper-slide-active) {
transform: scale(0.9);
}
}
</style>
@@ -0,0 +1,139 @@
<!--
Ported from legacy henza_pwa/src/components/slider/slider.vue.
EXISTING ISSUE, ported as-is (not fixed): `showPopup` takes (src, index) but
the template calls it with a single argument (`showPopup(item)`), and the
v-for doesn't even destructure an index. `index` is therefore always
undefined inside showPopup, so its "rotate the slides array to start at the
tapped image" logic never runs — tapping any thumbnail always opens the
full-screen zoom viewer starting from the first image, never the tapped one.
-->
<template>
<div>
<Swiper :grabCursor="true" :centeredSlides="true" :loop="false" :slidesPerView="1" class="w-full slider" :pagination="{ clickable: true }">
<SwiperSlide class="bg-center bg-cover h-auto" v-for="item in slides" :key="item">
<img @click="showPopup(item)" v-lazy="item" class="block w-auto object-cover z-0 pb-1" :style="`height:${height}`" />
</SwiperSlide>
<transition name="slideUp" mode="out-in">
<svg alt="هنزا" class="absolute bottom-0 z-[2] m-auto pointer-events-none w-full h-[90px]" v-if="flagSvg">
<path fill="#FFFFFF" class="w-full h-[90px]" :d="whiteShape" />
<path fill="#3E5279" class="w-full h-[90px]" :d="curvedShape" />
</svg>
</transition>
</Swiper>
<Drawer v-model:visible="show" placement="bottom" :closable="false" :bodyStyle="{ padding: 0 }" class="rtl whitespace-pre-wrap bg-white" height="100vh">
<div @click="show = false" class="fixed top-[30px] right-[20px] z-[5] pb-2 pl-5">
<Icon icon="vuesax-linear:arrow-right-1" @click="show = false" class="cursor-pointer text-white bg-secondary rounded-[5px] p-[2.5px]" size="28" />
</div>
<Swiper :grabCursor="true" :zoom="{ maxRatio: 1.2 }" :modules="[Zoom]" :centeredSlides="true" :loop="false" :slidesPerView="1" class="w-full slider" :key="show">
<SwiperSlide class="bg-center bg-cover h-auto" v-for="item in slides" :key="item">
<div class="swiper-zoom-container">
<img v-lazy="item" class="block object-contain w-[100vw] h-screen" />
</div>
</SwiperSlide>
</Swiper>
</Drawer>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeMount, ref } from 'vue'
import { Swiper, SwiperSlide } from 'swiper/vue'
import 'swiper/css'
import 'swiper/css/navigation'
import 'swiper/css/pagination'
import 'swiper/css/scrollbar'
import 'swiper/css/zoom'
import SwiperCore, { Navigation, Pagination, Zoom } from 'swiper'
import { Drawer } from 'ant-design-vue'
import Icon from '../icon/Icon.vue'
import { useWindowDataStore } from '../../stores/windowDataStore'
SwiperCore.use([Pagination, Navigation])
const props = withDefaults(defineProps<{ slides: string[]; height: string; width?: string }>(), {})
const show = ref(false)
const slides = ref<string[]>([...props.slides])
const showPopup = (src: string, index?: number) => {
slides.value = [...props.slides]
for (let i = 0; i < (index ?? 0); i++) {
const helper = slides.value.shift()
if (helper) slides.value.push(helper)
}
show.value = true
}
const flagSvg = ref(false)
onBeforeMount(() => {
setTimeout(() => {
flagSvg.value = true
}, 100)
})
const windowData = useWindowDataStore()
const shapeHeight = 90
const wcp1x = computed(() => Math.min(windowData.width, 768) - Math.min(windowData.width, 768) / 21.8878249)
const wcp2x = computed(() => Math.min(windowData.width, 768) - Math.min(windowData.width, 768) / 2.37406336)
const crcr1cp1x = computed(() => Math.min(windowData.width, 768) - Math.min(windowData.width, 768) / 1.56947374)
const crcr1cp2x = computed(() => Math.min(windowData.width, 768) - Math.min(windowData.width, 768) / 5.47609844)
const crcr2cp1x = computed(() => Math.min(windowData.width, 768) - Math.min(windowData.width, 768) / 15.8024691)
const crcr2cp2x = computed(() => Math.min(windowData.width, 768) - Math.min(windowData.width, 768) / 2.2606383)
const whiteShape = computed(() => `M0,${shapeHeight}H${Math.min(windowData.width, 768)}V0C${wcp1x.value},19.17,${wcp2x.value},119.08,0,49Z`)
const curvedShape = computed(
() => `M0,39.77C${crcr1cp1x.value},91.74,${crcr1cp2x.value},61.5,${Math.min(windowData.width, 768)},0,C${crcr2cp1x.value},29.34,${crcr2cp2x.value},112.54,0,57.1Z`,
)
</script>
<style lang="less">
.slider {
.swiper-pagination-bullet {
transition: all 0.2s;
width: 6px;
height: 6px;
}
.swiper-pagination-bullet-active,
.swiper-pagination-bullet-active-main {
width: 24px;
border-radius: 10px;
}
.swiper-horizontal > .swiper-pagination-bullets,
.swiper-pagination-bullets.swiper-pagination-horizontal,
.swiper-pagination-custom,
.swiper-pagination-fraction {
bottom: 40px !important;
}
.swiper-pagination-bullets-dynamic * {
transform: unset !important;
}
.swiper-pagination-bullet-active {
background: @primary;
}
.swiper-slide img {
margin: auto;
}
}
.slideUp-enter-active,
.slideUp-leave-active {
opacity: 1;
transition: all @transition-duration ease @transition-delay;
}
.slideUp-enter-from {
opacity: 0;
transform: translateY(100%);
}
.slideUp-leave-to {
opacity: 0;
transform: translateY(-100%);
}
</style>
@@ -0,0 +1,13 @@
<!-- Ported from legacy henza_pwa/src/components/ui/container.vue -->
<template>
<main class="px-[16px] max-w-screen-md m-auto relative no-scrollbar">
<slot />
</main>
</template>
<script setup lang="ts">
interface Props {
padding?: string
}
withDefaults(defineProps<Props>(), {})
</script>
@@ -0,0 +1,12 @@
<!-- Ported from legacy henza_pwa/src/components/content.vue -->
<template>
<main :style="{ marginTop: transition.transitionEnded ? `${transition.headerHeight}px` : 'unset' }">
<slot />
</main>
</template>
<script setup lang="ts">
import { useTransitionStore } from '../../stores/transitionStore'
const transition = useTransitionStore()
</script>
@@ -0,0 +1,41 @@
<!-- Ported from legacy henza_pwa/src/components/dropDown.vue -->
<template>
<div :class="`rounded-[5px] border border-solid text-primary px-[15px] py-[15px] ${show ? 'border-secondary' : 'border-lightGray/20'}`">
<div class="flex w-full justify-between cursor-pointer" @click="show = !show">
<h5 class="flex-1 m-0">
<slot name="text">{{ text }}</slot>
</h5>
<Icon icon="vuesax-linear:arrow-down" :class="`transition-all duration-200 ease-linear ${show ? 'rotate-180' : undefined}`" size="25" />
</div>
<transition name="drop" mode="out-in" appear>
<div class="w-full" style="overflow: hidden" v-if="show">
<slot />
</div>
</transition>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import Icon from '../icon/Icon.vue'
withDefaults(defineProps<{ text?: string }>(), {})
const show = ref(false)
</script>
<style lang="less">
.drop-enter-active {
animation: drop 0.2s cubic-bezier(0.33, 1, 0.68, 1);
}
.drop-leave-to {
animation: drop 0.2s cubic-bezier(0.33, 1, 0.68, 1) reverse;
}
@keyframes drop {
0% {
max-height: 0;
}
100% {
max-height: 120px;
}
}
</style>
@@ -0,0 +1,155 @@
<!-- Ported from legacy henza_pwa/src/components/ui/navigation.vue -->
<template>
<nav class="fixed bottom-0 w-full z-[10]" v-show="nav.showNav">
<div class="flex flex-row-reverse justify-around items-center absolute w-full h-[50px] z-[2] bottom-0">
<template v-for="item in nav.navBarRoutes" :key="item.index">
<router-link :to="{ name: item.name }" class="relative h-[50px]" @click="setLastPositionX(item)">
<div class="nav-item">
<Icon
:icon="item.icon"
class="cursor-pointer"
:style="{ transform: nav.activeLink === item.index ? `translateY(50px)` : undefined }"
size="25"
/>
</div>
</router-link>
</template>
</div>
<div class="active-nav-item absolute" :style="{ top: output[0] + 'px', left: output[1] + 'px' }">
<Icon v-if="activeItem?.icon === 'vuesax-linear:user'" icon="vuesax-linear:user" class="cursor-pointer" size="25" />
<Icon
v-if="activeItem?.icon === 'vuesax-linear:bag-happy'"
icon="vuesax-linear:bag-happy"
class="cursor-pointer"
size="25"
/>
<Icon
v-if="activeItem?.icon === 'vuesax-linear:search-normal-1'"
icon="vuesax-linear:search-normal-1"
class="cursor-pointer"
size="25"
/>
<Icon
v-if="activeItem?.icon === 'vuesax-linear:home-2'"
icon="vuesax-linear:home-2"
class="cursor-pointer"
size="25"
/>
</div>
<svg class="h-[50px] w-full">
<path id="nav" class="from-path" :d="shape" />
</svg>
</nav>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { gsap } from 'gsap'
import { TransitionPresets, useTransition } from '@vueuse/core'
import Icon from '../icon/Icon.vue'
import { useNavStore } from '../../stores/navStore'
import { useWindowDataStore } from '../../stores/windowDataStore'
const nav = useNavStore()
const windowData = useWindowDataStore()
const shape = ref('')
const centershape = [95, 45]
const width = computed(() => windowData.width)
const centerOfCentershape = computed(() => {
return (width.value / nav.navBarRoutes.length) * nav.activeLink - width.value / (nav.navBarRoutes.length * 2)
})
const draw = () => {
const drowData = {
cornnerCurve: 10,
height: 50,
centerTopCurve: 20,
centerBottpmCurve: 32,
centershapeWidth: centershape[0],
centershapeHeight: centershape[1],
startOfCentershape: centerOfCentershape.value - centershape[0] / 2,
endOfCentershape: centerOfCentershape.value + centershape[0] / 2,
}
return `
M0,0
H${drowData.startOfCentershape}
C${drowData.startOfCentershape + drowData.centerTopCurve}, 0, ${centerOfCentershape.value - drowData.centerBottpmCurve}, ${drowData.centershapeHeight}, ${centerOfCentershape.value}, ${drowData.centershapeHeight}
C${centerOfCentershape.value + drowData.centerBottpmCurve},${drowData.centershapeHeight},${drowData.endOfCentershape - drowData.centerTopCurve},0,${drowData.endOfCentershape},0
H${width.value}
V50
H0
Z`
}
watch(width, (val) => {
if (val > 0) {
shape.value = draw()
}
})
watch(
() => nav.$state,
() => {
if (!shape.value) {
shape.value = draw()
} else {
gsap.to('.from-path', { duration: 0.3, attr: { d: draw() } })
}
},
{ deep: true },
)
shape.value = draw()
let lastPositionX = width.value
const activeItem = computed(() => {
const item = nav.navBarRoutes.find((x) => x.index === nav.activeLink)
setActiveIcon()
return item
})
// Legacy called this with an `item` argument that the function body never
// used (plain JS silently ignores extra args); kept as an unused optional
// param so behavior stays identical under TypeScript.
const setLastPositionX = (_item?: unknown) => {
lastPositionX = centerOfCentershape.value - centershape[0] / 2 + 25
}
const setActiveIcon = () => {
const positionX = centerOfCentershape.value - centershape[0] / 2 + 25
baseVector.value = [50, positionX + (lastPositionX - positionX) / 2]
setTimeout(() => {
baseVector.value = [-20, positionX]
}, 150)
}
const baseVector = ref([0, 0])
const output = useTransition(baseVector, {
duration: 150,
transition: TransitionPresets.easeInOutCubic,
})
</script>
<style scoped lang="less">
#nav {
opacity: 0.4;
fill: #dddddd;
}
.nav-item {
display: flex;
justify-content: center;
align-items: center;
width: 45px;
height: 45px;
}
.active-nav-item {
.nav-item;
background: @primary;
color: @white;
border-radius: 100%;
}
</style>
@@ -0,0 +1,41 @@
<!-- Ported from legacy henza_pwa/src/components/ui/ScrollFetch.vue — the plain
infinite-scroll container (default ant Empty, no custom illustration). -->
<template>
<main
class="overflow-auto no-scrollbar w-[100vw] h-screen max-w-screen-md m-auto"
:style="{ paddingTop: padding || transition.headerHeight + 'px' }"
ref="scrollArea"
>
<slot></slot>
<Spinner cls="my-[10px]" :spinning="loading" :indicator="true" tip="درحال دریافت اطلاعات" />
<EmptyState v-if="flag" />
</main>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useInfiniteScroll } from '@vueuse/core'
import Spinner from '../Spinner.vue'
import EmptyState from '../EmptyState.vue'
import { useTransitionStore } from '../../stores/transitionStore'
withDefaults(
defineProps<{
loading: boolean
flag: boolean
padding?: string
}>(),
{},
)
const emit = defineEmits<{ (e: 'fetch'): void }>()
const transition = useTransitionStore()
const scrollArea = ref(null)
useInfiniteScroll(
scrollArea,
() => {
emit('fetch')
},
{ distance: 50 },
)
</script>
@@ -0,0 +1,48 @@
<!--
Ported from legacy henza_pwa/src/components/ui/ScrollFetchAfterSearch.vue.
Legacy imported emp_after_search.svg / emp_before_search.svg and an `isSearch`
prop to pick between them, but the live template used neither — it rendered
`ordersEmpty.svg` unconditionally. Ported as-is (unused imports dropped,
`isSearch` kept since it is part of the component's declared prop contract).
-->
<template>
<main
class="overflow-auto no-scrollbar w-[100vw] h-screen max-w-screen-md m-auto"
:style="{ paddingTop: padding || transition.headerHeight + 'px' }"
ref="scrollArea"
>
<slot></slot>
<Spinner cls="my-[10px]" :spinning="loading" :indicator="true" tip="درحال دریافت اطلاعات" />
<EmptyState v-if="flag && !loading" :image="ordersEmpty" />
</main>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useInfiniteScroll } from '@vueuse/core'
import Spinner from '../Spinner.vue'
import EmptyState from '../EmptyState.vue'
import ordersEmpty from '../../assets/img/empty/ordersEmpty.svg'
import { useTransitionStore } from '../../stores/transitionStore'
withDefaults(
defineProps<{
loading: boolean
flag: boolean
padding?: string
isSearch?: boolean
}>(),
{},
)
const emit = defineEmits<{ (e: 'fetch'): void }>()
const transition = useTransitionStore()
const scrollArea = ref(null)
useInfiniteScroll(
scrollArea,
() => {
emit('fetch')
},
{ distance: 50 },
)
</script>
@@ -0,0 +1,50 @@
<!--
Ported from legacy henza_pwa/src/components/ui/ScrollFetchSearch.vue.
EXISTING ISSUE, ported as-is: both the isFav and non-isFav empty states use
the same `favoritEmpty` image (legacy imported cartEmpty/emp_search/
emp_favorite too, but never actually used them in the template — those
unused imports are dropped here as genuinely dead code; the duplicate-image
*behavior* itself is preserved).
-->
<template>
<main
class="flex flex-col flex-1 overflow-auto no-scrollbar w-[100vw] max-w-screen-md m-auto"
:style="{ paddingTop: padding || transition.headerHeight + 'px' }"
ref="scrollArea"
>
<slot></slot>
<Spinner cls="my-[10px]" :spinning="loading" :indicator="true" tip="درحال دریافت اطلاعات" />
<EmptyState v-if="flag && !isFav" :image="favoritEmpty" />
<EmptyState v-if="flag && isFav" :image="favoritEmpty" />
</main>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useInfiniteScroll } from '@vueuse/core'
import EmptyState from '../EmptyState.vue'
import Spinner from '../Spinner.vue'
import favoritEmpty from '../../assets/img/empty/favoritEmpty.svg'
import { useTransitionStore } from '../../stores/transitionStore'
withDefaults(
defineProps<{
loading: boolean
flag: boolean
padding?: string
isFav?: boolean
}>(),
{},
)
const emit = defineEmits<{ (e: 'fetch'): void }>()
const transition = useTransitionStore()
const scrollArea = ref(null)
useInfiniteScroll(
scrollArea,
() => {
emit('fetch')
},
{ distance: 50 },
)
</script>