feat: initial commit
This commit is contained in:
@@ -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.
|
||||
-->
|
||||
@@ -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>
|
||||
@@ -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> </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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user