Initial admin edits
This commit is contained in:
@@ -11,6 +11,7 @@ import { useCartStore } from '@/stores/cart'
|
||||
import { useOrderStore } from '@/stores/order'
|
||||
import { useCatalogStore } from '@/stores/catalog'
|
||||
import { useNotificationStore } from '@/stores/notification'
|
||||
import { presentUnreadBroadcastNotifications } from '@/composables/presentBroadcastNotifications'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const cartStore = useCartStore()
|
||||
@@ -46,6 +47,7 @@ onMounted(async () => {
|
||||
orderStore.hydrate(),
|
||||
notificationStore.hydrate(),
|
||||
])
|
||||
presentUnreadBroadcastNotifications()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/* Compact app headers on very narrow phones (< 468px) */
|
||||
|
||||
@media (max-width: 467px) {
|
||||
:root {
|
||||
--header-compact-control: 36px;
|
||||
}
|
||||
|
||||
.page-nav {
|
||||
grid-template-columns: var(--header-compact-control) minmax(0, 1fr) minmax(0, auto);
|
||||
column-gap: 4px;
|
||||
padding-inline: 10px;
|
||||
padding-top: 6px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.page-nav .nav-btn {
|
||||
width: var(--header-compact-control);
|
||||
height: var(--header-compact-control);
|
||||
}
|
||||
|
||||
.page-nav__title {
|
||||
font-size: 0.9375rem;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.page-nav__trailing {
|
||||
min-height: var(--header-compact-control);
|
||||
max-width: calc(var(--header-compact-control) * 2 + 4px);
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.page-nav__trailing .auth-theme-toggle--inline {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
padding-inline: 10px;
|
||||
padding-top: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.page-header .back-btn {
|
||||
width: var(--header-compact-control);
|
||||
height: var(--header-compact-control);
|
||||
}
|
||||
|
||||
.page-header__trailing .auth-theme-toggle--inline {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 1.0625rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
font-size: 0.8125rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.home-header {
|
||||
gap: 4px;
|
||||
padding: 6px 10px 4px;
|
||||
}
|
||||
|
||||
.home-header__user {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.home-header__welcome {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.home-header__hello {
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.3;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.home-header__actions .auth-theme-toggle--inline {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.home-header .icon-btn {
|
||||
width: var(--header-compact-control);
|
||||
height: var(--header-compact-control);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.nav-filter,
|
||||
.nav-add-btn {
|
||||
width: var(--header-compact-control);
|
||||
height: var(--header-compact-control);
|
||||
}
|
||||
|
||||
.page-nav__trailing .edit-btn--compact {
|
||||
width: var(--header-compact-control);
|
||||
height: var(--header-compact-control);
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.page-nav__trailing .edit-btn--compact .edit-btn__label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.page-nav__trailing .edit-btn--compact svg {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useSlotBanners } from '@/composables/useSlotBanners'
|
||||
import { useTouchSwipe } from '@/composables/useTouchSwipe'
|
||||
import { followBannerLink } from '@/utils/bannerLink'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -19,6 +20,8 @@ const index = ref(0)
|
||||
|
||||
const banner = computed(() => banners.value[index.value] ?? null)
|
||||
|
||||
const bannerImageLink = computed(() => banner.value?.bannerLink?.trim() || '')
|
||||
|
||||
const bannerStyle = computed(() => {
|
||||
const url = banner.value?.imageUrl?.trim()
|
||||
if (!url) return undefined
|
||||
@@ -28,11 +31,14 @@ const bannerStyle = computed(() => {
|
||||
function goCta() {
|
||||
const link = banner.value?.ctaLink?.trim()
|
||||
if (!link) return
|
||||
if (/^https?:\/\//i.test(link)) {
|
||||
window.location.assign(link)
|
||||
return
|
||||
}
|
||||
router.push(link)
|
||||
followBannerLink(link, router)
|
||||
}
|
||||
|
||||
function onBannerSurfaceClick(event: MouseEvent) {
|
||||
if (!bannerImageLink.value) return
|
||||
const target = event.target as HTMLElement
|
||||
if (target.closest('.slot-banner__cta, .slot-banner__dots')) return
|
||||
followBannerLink(bannerImageLink.value, router)
|
||||
}
|
||||
|
||||
function setSlide(i: number) {
|
||||
@@ -53,9 +59,13 @@ const { onTouchStart, onTouchEnd } = useTouchSwipe({
|
||||
v-if="!hideWhenEmpty || banner"
|
||||
v-show="!loading && banner"
|
||||
class="slot-banner"
|
||||
:class="{ 'slot-banner--photo': !!banner?.imageUrl }"
|
||||
:class="{
|
||||
'slot-banner--photo': !!banner?.imageUrl,
|
||||
'slot-banner--linked': !!bannerImageLink,
|
||||
}"
|
||||
:style="bannerStyle"
|
||||
:aria-label="banner?.title || 'بنر'"
|
||||
@click="onBannerSurfaceClick"
|
||||
@touchstart.passive="onTouchStart"
|
||||
@touchend.passive="onTouchEnd"
|
||||
>
|
||||
@@ -74,7 +84,7 @@ const { onTouchStart, onTouchEnd } = useTouchSwipe({
|
||||
v-if="banner.showCta !== false && banner.ctaLabel"
|
||||
type="button"
|
||||
class="slot-banner__cta"
|
||||
@click="goCta"
|
||||
@click.stop="goCta"
|
||||
>
|
||||
{{ banner.ctaLabel }}
|
||||
</button>
|
||||
@@ -93,8 +103,8 @@ const { onTouchStart, onTouchEnd } = useTouchSwipe({
|
||||
:class="{ 'slot-banner__dot--active': i === index }"
|
||||
:aria-label="item.title"
|
||||
:aria-selected="i === index"
|
||||
@click="setSlide(i)"
|
||||
/>
|
||||
@click.stop="setSlide(i)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
@@ -120,6 +130,10 @@ const { onTouchStart, onTouchEnd } = useTouchSwipe({
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.slot-banner--linked {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.slot-banner--photo {
|
||||
min-height: 156px;
|
||||
aspect-ratio: 2.35 / 1;
|
||||
|
||||
@@ -152,13 +152,13 @@ defineExpose({ submit, saving })
|
||||
/>
|
||||
<BaseInput
|
||||
v-model="businessName"
|
||||
label="نام فروشگاه یا برند (اختیاری)"
|
||||
label="نام فروشگاه (اختیاری)"
|
||||
placeholder="مثلاً بوتیک رز"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<BaseButton block :loading="saving" :disabled="saving" @click="submit">
|
||||
{{ isEdit() ? 'ذخیره تغییرات' : 'ثبت مشتری' }}
|
||||
{{ isEdit() ? 'ثبت تغییرات' : 'ثبت مشتری' }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useFavoritesStore } from '@/stores/favorites'
|
||||
import { useCatalogRequest } from '@/composables/useCatalogRequest'
|
||||
import { formatNumber } from '@/utils/format'
|
||||
import { colorSwatchStyle } from '@/utils/colorSwatch'
|
||||
import { isProductUnavailable } from '@/utils/productAvailability'
|
||||
import IconChevronLeft from '@/components/icons/IconChevronLeft.vue'
|
||||
import IconBookmark from '@/components/icons/IconBookmark.vue'
|
||||
import AddressCompletionModal from '@/components/catalogs/AddressCompletionModal.vue'
|
||||
@@ -56,6 +57,7 @@ function toProductCard(item: ProductSuggestionDto): CatalogProductCard {
|
||||
catalogName: item.catalogName,
|
||||
catalogCode: item.catalogCode,
|
||||
color: item.color,
|
||||
isActive: item.productIsActive !== false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,7 +128,11 @@ function widthText(item: ProductSuggestionDto) {
|
||||
class="suggest-product__media"
|
||||
:style="item.imageUrl ? { backgroundImage: `url(${item.imageUrl})` } : undefined"
|
||||
>
|
||||
<span class="suggest-product__chip">کالیته پیشنهادی</span>
|
||||
<span
|
||||
v-if="isProductUnavailable({ isActive: item.productIsActive })"
|
||||
class="suggest-product__chip suggest-product__chip--oos"
|
||||
>ناموجود</span>
|
||||
<span v-else class="suggest-product__chip">کالیته پیشنهادی</span>
|
||||
<button
|
||||
type="button"
|
||||
class="suggest-product__save"
|
||||
@@ -292,8 +298,8 @@ function widthText(item: ProductSuggestionDto) {
|
||||
}
|
||||
|
||||
.suggest-product {
|
||||
flex: 0 0 min(88vw, 300px);
|
||||
width: min(88vw, 300px);
|
||||
flex: 0 0 min(82vw, 268px);
|
||||
width: min(82vw, 268px);
|
||||
scroll-snap-align: start;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 16px;
|
||||
@@ -305,7 +311,7 @@ function widthText(item: ProductSuggestionDto) {
|
||||
|
||||
.suggest-product__media {
|
||||
position: relative;
|
||||
height: 148px;
|
||||
height: 136px;
|
||||
background: linear-gradient(145deg, #e8ecf0, #d5dbe3);
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
@@ -324,6 +330,10 @@ function widthText(item: ProductSuggestionDto) {
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.suggest-product__chip--oos {
|
||||
background: #b91c1c;
|
||||
}
|
||||
|
||||
.suggest-product__save {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
@@ -392,7 +402,6 @@ function widthText(item: ProductSuggestionDto) {
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
padding: 8px 0;
|
||||
border-block: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.suggest-product__spec {
|
||||
@@ -496,12 +505,12 @@ html.dark .suggest-panel__all {
|
||||
}
|
||||
|
||||
.suggest-product {
|
||||
flex: 0 0 min(42%, 320px);
|
||||
width: min(42%, 320px);
|
||||
flex: 0 0 min(36%, 280px);
|
||||
width: min(36%, 280px);
|
||||
}
|
||||
|
||||
.suggest-product__media {
|
||||
height: 168px;
|
||||
height: 156px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -64,7 +64,7 @@ defineEmits<{
|
||||
<style scoped>
|
||||
.page-nav {
|
||||
display: grid;
|
||||
grid-template-columns: 40px 1fr auto;
|
||||
grid-template-columns: 40px minmax(0, 1fr) minmax(0, auto);
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
padding-inline: var(--page-padding);
|
||||
@@ -79,6 +79,10 @@ defineEmits<{
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
color: var(--color-text);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.page-nav__subtitle {
|
||||
@@ -95,6 +99,8 @@ defineEmits<{
|
||||
justify-content: flex-end;
|
||||
gap: 0;
|
||||
min-height: 40px;
|
||||
flex-shrink: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
@@ -126,6 +132,7 @@ defineEmits<{
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
color: inherit;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
@@ -162,6 +169,9 @@ defineEmits<{
|
||||
line-height: 1.35;
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
import { computed } from 'vue'
|
||||
import type { Order } from '@/types'
|
||||
import { formatCurrency, formatNumber, toPersianDigits } from '@/utils/format'
|
||||
import {
|
||||
settlementTypeLabel,
|
||||
usesCashReceiptPhase,
|
||||
usesCheckSettlement,
|
||||
} from '@/utils/settlement'
|
||||
|
||||
const props = defineProps<{
|
||||
order: Order
|
||||
@@ -11,10 +16,6 @@ const checksTotal = computed(() =>
|
||||
props.order.checks?.reduce((sum, check) => sum + check.amount, 0) ?? 0,
|
||||
)
|
||||
|
||||
function settlementLabel(type: 'cash' | 'check' | null) {
|
||||
if (!type) return 'در انتظار انتخاب مشتری'
|
||||
return type === 'cash' ? 'تسویه نقدی' : 'تسویه چکی'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -27,10 +28,10 @@ function settlementLabel(type: 'cash' | 'check' | null) {
|
||||
</div>
|
||||
<div class="tracking-summary__row">
|
||||
<span>نوع تسویه</span>
|
||||
<strong>{{ settlementLabel(order.settlementType) }}</strong>
|
||||
<strong>{{ settlementTypeLabel(order.settlementType) }}</strong>
|
||||
</div>
|
||||
|
||||
<template v-if="order.settlementType === 'check' && order.checks?.length">
|
||||
<template v-if="usesCheckSettlement(order.settlementType) && order.checks?.length">
|
||||
<div class="tracking-summary__row">
|
||||
<span>تعداد چکها</span>
|
||||
<strong>{{ toPersianDigits(String(order.checks.length)) }} فقره</strong>
|
||||
@@ -45,7 +46,7 @@ function settlementLabel(type: 'cash' | 'check' | null) {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="order.settlementType === 'cash' && order.cashReceipt">
|
||||
<template v-if="usesCashReceiptPhase(order.settlementType) && order.cashReceipt">
|
||||
<div class="tracking-summary__row">
|
||||
<span>مبلغ واریزی</span>
|
||||
<strong>{{ formatCurrency(order.cashReceipt.amount) }}</strong>
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { onClickOutside } from '@vueuse/core'
|
||||
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
|
||||
import IconHeadset from '@/components/icons/IconHeadset.vue'
|
||||
import IconPhone from '@/components/icons/IconPhone.vue'
|
||||
import IconMail from '@/components/icons/IconMail.vue'
|
||||
import { usePublicContact } from '@/composables/usePublicContact'
|
||||
import type { SupportLinkKind } from '@/utils/supportLinks'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** فاصله از نوار پایین موبایل */
|
||||
aboveBottomNav?: boolean
|
||||
}>(),
|
||||
{ aboveBottomNav: false },
|
||||
)
|
||||
|
||||
const { load, loaded, supportLinks, hasSupportLinks } = usePublicContact()
|
||||
|
||||
const open = ref(false)
|
||||
const rootRef = ref<HTMLElement | null>(null)
|
||||
const panelRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const LINK_ICON: Record<SupportLinkKind, string> = {
|
||||
whatsapp: 'vuesax-linear:message',
|
||||
telegram: 'vuesax-linear:sms',
|
||||
instagram: 'vuesax-linear:category',
|
||||
phone: 'call',
|
||||
email: 'mail',
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load()
|
||||
})
|
||||
|
||||
onClickOutside(rootRef, () => {
|
||||
if (open.value) open.value = false
|
||||
})
|
||||
|
||||
function toggleOpen() {
|
||||
if (!hasSupportLinks.value) return
|
||||
open.value = !open.value
|
||||
}
|
||||
|
||||
function onFabKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
toggleOpen()
|
||||
}
|
||||
}
|
||||
|
||||
function onEscape(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape' && open.value) {
|
||||
open.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('keydown', onEscape))
|
||||
onUnmounted(() => document.removeEventListener('keydown', onEscape))
|
||||
|
||||
watch(hasSupportLinks, (ok) => {
|
||||
if (!ok) open.value = false
|
||||
})
|
||||
|
||||
const showWidget = computed(() => loaded.value && hasSupportLinks.value)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="showWidget"
|
||||
ref="rootRef"
|
||||
class="support-widget"
|
||||
:class="{ 'support-widget--above-nav': props.aboveBottomNav }"
|
||||
>
|
||||
<Transition name="support-pop">
|
||||
<div
|
||||
v-if="open"
|
||||
ref="panelRef"
|
||||
class="support-widget__panel"
|
||||
role="dialog"
|
||||
aria-labelledby="support-widget-title"
|
||||
aria-modal="false"
|
||||
>
|
||||
<header class="support-widget__head">
|
||||
<h2 id="support-widget-title" class="support-widget__title">پشتیبانی</h2>
|
||||
<p class="support-widget__lead">از راههای زیر با ما در ارتباط باشید</p>
|
||||
<button
|
||||
type="button"
|
||||
class="support-widget__close"
|
||||
aria-label="بستن"
|
||||
@click="open = false"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
<ul class="support-widget__list">
|
||||
<li v-for="link in supportLinks" :key="link.kind">
|
||||
<a
|
||||
class="support-widget__link"
|
||||
:href="link.href"
|
||||
:target="link.kind === 'phone' || link.kind === 'email' ? undefined : '_blank'"
|
||||
:rel="link.kind === 'phone' || link.kind === 'email' ? undefined : 'noopener noreferrer'"
|
||||
>
|
||||
<span class="support-widget__icon" aria-hidden="true">
|
||||
<IconPhone v-if="link.kind === 'phone'" :size="20" color="var(--color-primary)" />
|
||||
<IconMail v-else-if="link.kind === 'email'" :size="20" color="var(--color-primary)" />
|
||||
<BasicIcon
|
||||
v-else
|
||||
:icon="LINK_ICON[link.kind]"
|
||||
:size="20"
|
||||
color="var(--color-primary)"
|
||||
/>
|
||||
</span>
|
||||
<span class="support-widget__link-text">
|
||||
<span class="support-widget__link-label">{{ link.label }}</span>
|
||||
<span class="support-widget__link-sub" dir="ltr">{{ link.subtitle }}</span>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="support-widget__fab"
|
||||
:class="{ 'support-widget__fab--open': open }"
|
||||
aria-label="پشتیبانی"
|
||||
:aria-expanded="open"
|
||||
aria-haspopup="dialog"
|
||||
@click="toggleOpen"
|
||||
@keydown="onFabKeydown"
|
||||
>
|
||||
<IconHeadset :size="26" color="#fff" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.support-widget {
|
||||
position: fixed;
|
||||
z-index: 110;
|
||||
inset-inline-end: 16px;
|
||||
bottom: calc(16px + env(safe-area-inset-bottom, 0));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 10px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.support-widget > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.support-widget--above-nav {
|
||||
bottom: calc(var(--nav-height, 56px) + 12px + env(safe-area-inset-bottom, 0));
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.support-widget {
|
||||
bottom: 24px;
|
||||
inset-inline-end: 24px;
|
||||
}
|
||||
|
||||
.support-widget--above-nav {
|
||||
bottom: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.support-widget__fab {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 6px 20px rgba(15, 23, 42, 0.22);
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.support-widget__fab:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.26);
|
||||
}
|
||||
|
||||
.support-widget__fab:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.support-widget__fab--open {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.support-widget__panel {
|
||||
width: min(300px, calc(100vw - 32px));
|
||||
padding: 14px 14px 10px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
box-shadow: var(--shadow-card, 0 12px 40px rgba(15, 23, 42, 0.14));
|
||||
transform-origin: bottom right;
|
||||
}
|
||||
|
||||
.support-widget__head {
|
||||
position: relative;
|
||||
padding-inline-end: 28px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.support-widget__title {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.support-widget__lead {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.support-widget__close {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
inset-inline-end: 0;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 1.25rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.support-widget__close:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.support-widget__list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.support-widget__link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
text-decoration: none;
|
||||
color: var(--color-text);
|
||||
background: var(--color-elevated, #f6f7f8);
|
||||
transition: background 0.12s ease;
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.support-widget__link:hover {
|
||||
background: var(--color-primary-soft);
|
||||
}
|
||||
|
||||
.support-widget__link:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.support-widget__icon {
|
||||
flex-shrink: 0;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-surface);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.support-widget__link-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.support-widget__link-label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.support-widget__link-sub {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.support-pop-enter-active,
|
||||
.support-pop-leave-active {
|
||||
transition:
|
||||
opacity 0.16s ease,
|
||||
transform 0.16s ease;
|
||||
}
|
||||
|
||||
.support-pop-enter-from,
|
||||
.support-pop-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(8px) scale(0.97);
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.support-widget__panel {
|
||||
position: fixed;
|
||||
inset-inline: 12px;
|
||||
bottom: calc(var(--nav-height, 56px) + 72px + env(safe-area-inset-bottom, 0));
|
||||
width: auto;
|
||||
max-height: min(50vh, 360px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
|
||||
import { onClickOutside } from '@vueuse/core'
|
||||
import {
|
||||
filterSelectOptionsBySearch,
|
||||
focusAdjacentSelectOption,
|
||||
focusSelectMenuOption,
|
||||
} from '@/utils/selectOptionSearch'
|
||||
|
||||
export type AppSelectOption = {
|
||||
value: string
|
||||
@@ -25,7 +30,7 @@ const props = withDefaults(
|
||||
placeholder: 'انتخاب کنید',
|
||||
ariaLabel: 'انتخاب',
|
||||
block: true,
|
||||
searchable: false,
|
||||
searchable: true,
|
||||
searchPlaceholder: 'جستجو…',
|
||||
disabled: false,
|
||||
},
|
||||
@@ -59,11 +64,33 @@ const displayLabel = computed(() => selectedOption.value?.label ?? props.placeho
|
||||
|
||||
const showPlaceholderStyle = computed(() => !selectedOption.value)
|
||||
|
||||
const filteredOptions = computed(() => {
|
||||
const q = searchQuery.value.trim().toLowerCase()
|
||||
if (!q) return props.options
|
||||
return props.options.filter((option) => option.label.toLowerCase().includes(q))
|
||||
})
|
||||
const filteredOptions = computed(() =>
|
||||
filterSelectOptionsBySearch(props.options, searchQuery.value),
|
||||
)
|
||||
|
||||
function onSearchKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
focusSelectMenuOption(menuRef.value, 'first')
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
focusSelectMenuOption(menuRef.value, 'last')
|
||||
}
|
||||
}
|
||||
|
||||
function onOptionKeydown(event: KeyboardEvent, value: string) {
|
||||
const target = event.currentTarget as HTMLElement
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
focusAdjacentSelectOption(target, 1)
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
focusAdjacentSelectOption(target, -1)
|
||||
} else if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
pick(value)
|
||||
}
|
||||
}
|
||||
|
||||
function updateMenuPosition() {
|
||||
const el = rootRef.value
|
||||
@@ -108,10 +135,10 @@ async function toggle() {
|
||||
if (props.disabled) return
|
||||
open.value = !open.value
|
||||
if (open.value) {
|
||||
searchQuery.value = ''
|
||||
await nextTick()
|
||||
updateMenuPosition()
|
||||
if (props.searchable) {
|
||||
searchQuery.value = ''
|
||||
await nextTick()
|
||||
searchInputRef.value?.focus()
|
||||
}
|
||||
@@ -182,6 +209,7 @@ onUnmounted(() => {
|
||||
v-if="open"
|
||||
ref="menuRef"
|
||||
class="app-select__menu"
|
||||
data-select-menu
|
||||
role="listbox"
|
||||
:aria-label="ariaLabel"
|
||||
:style="menuStyle"
|
||||
@@ -195,6 +223,7 @@ onUnmounted(() => {
|
||||
:placeholder="searchPlaceholder"
|
||||
@click.stop
|
||||
@keydown.esc.stop="open = false"
|
||||
@keydown="onSearchKeydown"
|
||||
/>
|
||||
</li>
|
||||
<li v-if="!filteredOptions.length" class="app-select__empty" role="presentation">
|
||||
@@ -205,9 +234,11 @@ onUnmounted(() => {
|
||||
type="button"
|
||||
class="app-select__option"
|
||||
:class="{ 'app-select__option--active': option.value === modelValue }"
|
||||
data-select-option
|
||||
role="option"
|
||||
:aria-selected="option.value === modelValue"
|
||||
@click="pick(option.value)"
|
||||
@keydown="onOptionKeydown($event, option.value)"
|
||||
>
|
||||
<span class="app-select__option-label">{{ option.label }}</span>
|
||||
<svg
|
||||
|
||||
+179
-65
@@ -1,106 +1,220 @@
|
||||
<script setup lang="ts">
|
||||
import { useAppToast } from '@/composables/useAppToast'
|
||||
import { nextTick, onUnmounted, ref, watch } from 'vue'
|
||||
import BaseButton from '@/components/ui/BaseButton.vue'
|
||||
import IconSuccessCircle from '@/components/icons/IconSuccessCircle.vue'
|
||||
import { useAppToast, type AppToastTone } from '@/composables/useAppToast'
|
||||
|
||||
const { message, tone, clear } = useAppToast()
|
||||
const { message, title, tone, clear } = useAppToast()
|
||||
const footerRef = ref<HTMLElement | null>(null)
|
||||
|
||||
function iconFallback(t: AppToastTone): string {
|
||||
if (t === 'error') return '!'
|
||||
if (t === 'warning') return '⚠'
|
||||
return 'i'
|
||||
}
|
||||
|
||||
const toneClass: Record<AppToastTone, string> = {
|
||||
success: 'message-modal--success',
|
||||
error: 'message-modal--error',
|
||||
info: 'message-modal--info',
|
||||
warning: 'message-modal--warning',
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
clear()
|
||||
}
|
||||
}
|
||||
|
||||
function lockScroll(open: boolean) {
|
||||
if (typeof document === 'undefined') return
|
||||
document.documentElement.style.overflow = open ? 'hidden' : ''
|
||||
document.body.style.overflow = open ? 'hidden' : ''
|
||||
}
|
||||
|
||||
watch(
|
||||
message,
|
||||
async (text, _, onCleanup) => {
|
||||
const open = !!text
|
||||
lockScroll(open)
|
||||
if (open) {
|
||||
document.addEventListener('keydown', onKeydown)
|
||||
onCleanup(() => document.removeEventListener('keydown', onKeydown))
|
||||
await nextTick()
|
||||
footerRef.value?.querySelector('button')?.focus()
|
||||
} else {
|
||||
document.removeEventListener('keydown', onKeydown)
|
||||
}
|
||||
},
|
||||
{ flush: 'post' },
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
lockScroll(false)
|
||||
document.removeEventListener('keydown', onKeydown)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="app-toast">
|
||||
<Transition name="message-modal-fade">
|
||||
<div
|
||||
v-if="message"
|
||||
class="app-toast"
|
||||
:class="tone === 'error' ? 'app-toast--error' : 'app-toast--success'"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
class="dialog-overlay message-modal-overlay"
|
||||
role="presentation"
|
||||
@click.self="clear"
|
||||
>
|
||||
<p class="app-toast__text">{{ message }}</p>
|
||||
<button type="button" class="app-toast__close" aria-label="بستن" @click="clear">
|
||||
×
|
||||
</button>
|
||||
<div
|
||||
class="dialog card message-modal"
|
||||
:class="toneClass[tone]"
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
:aria-labelledby="message ? 'message-modal-title' : undefined"
|
||||
:aria-describedby="message ? 'message-modal-body' : undefined"
|
||||
>
|
||||
<header class="message-modal__head">
|
||||
<span class="message-modal__icon" aria-hidden="true">
|
||||
<IconSuccessCircle v-if="tone === 'success'" :size="40" />
|
||||
<span v-else class="message-modal__icon-fallback">{{ iconFallback(tone) }}</span>
|
||||
</span>
|
||||
<h2 id="message-modal-title" class="message-modal__title">{{ title }}</h2>
|
||||
</header>
|
||||
|
||||
<div id="message-modal-body" class="message-modal__body">
|
||||
<p class="message-modal__text">{{ message }}</p>
|
||||
</div>
|
||||
|
||||
<footer ref="footerRef" class="message-modal__footer dialog-actions">
|
||||
<BaseButton block @click="clear">متوجه شدم</BaseButton>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-toast {
|
||||
position: fixed;
|
||||
top: calc(12px + env(safe-area-inset-top, 0px));
|
||||
inset-inline: var(--page-padding);
|
||||
.message-modal-overlay {
|
||||
z-index: 1200;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-md);
|
||||
padding: max(16px, env(safe-area-inset-top)) 16px max(16px, env(safe-area-inset-bottom));
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.app-toast--success {
|
||||
.message-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: min(85vh, 520px);
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-border);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.message-modal__head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 20px 20px 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.message-modal__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.message-modal--success .message-modal__icon {
|
||||
background: var(--color-success-light);
|
||||
border: 1px solid color-mix(in srgb, var(--color-success) 35%, transparent);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.app-toast--error {
|
||||
.message-modal--error .message-modal__icon {
|
||||
background: var(--color-danger-light);
|
||||
border: 1px solid color-mix(in srgb, var(--color-danger) 35%, transparent);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.app-toast__text {
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.55;
|
||||
text-align: start;
|
||||
.message-modal--warning .message-modal__icon {
|
||||
background: color-mix(in srgb, var(--color-warning, #f59e0b) 18%, transparent);
|
||||
color: var(--color-warning, #d97706);
|
||||
}
|
||||
|
||||
.app-toast__close {
|
||||
flex-shrink: 0;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin: -4px 0;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font-size: 1.25rem;
|
||||
.message-modal--info .message-modal__icon {
|
||||
background: var(--color-primary-light);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.message-modal__icon-fallback {
|
||||
font-size: 1.375rem;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.app-toast-enter-active,
|
||||
.app-toast-leave-active {
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
.message-modal__title {
|
||||
margin: 0;
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.app-toast-enter-from,
|
||||
.app-toast-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
.message-modal__body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 8px 20px 4px;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.message-modal__text {
|
||||
margin: 0;
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.75;
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
white-space: pre-line;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.message-modal__footer {
|
||||
flex-shrink: 0;
|
||||
margin: 0;
|
||||
padding: 16px 20px 20px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.app-toast {
|
||||
top: calc(var(--header-height) + 12px);
|
||||
max-width: min(560px, calc(100vw - 2 * var(--page-padding)));
|
||||
margin-inline: auto;
|
||||
inset-inline: 0;
|
||||
left: 50%;
|
||||
right: auto;
|
||||
transform: translateX(-50%);
|
||||
width: calc(100% - 2 * var(--page-padding));
|
||||
.message-modal {
|
||||
max-width: min(420px, calc(100vw - 48px));
|
||||
}
|
||||
|
||||
.app-toast-enter-from,
|
||||
.app-toast-leave-to {
|
||||
transform: translate(-50%, -8px);
|
||||
.message-modal__footer :deep(.btn) {
|
||||
max-width: 280px;
|
||||
margin-inline: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.message-modal-fade-enter-active,
|
||||
.message-modal-fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.message-modal-fade-enter-active .message-modal,
|
||||
.message-modal-fade-leave-active .message-modal {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.message-modal-fade-enter-from,
|
||||
.message-modal-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.message-modal-fade-enter-from .message-modal,
|
||||
.message-modal-fade-leave-to .message-modal {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useAppToast, type AppToastTone } from '@/composables/useAppToast'
|
||||
import { useNotificationStore } from '@/stores/notification'
|
||||
import type { NotificationTone } from '@/types'
|
||||
|
||||
const BROADCAST_EVENT = 'admin_broadcast'
|
||||
|
||||
/** جلوگیری از صف تکراری هنگام hydrate مجدد در همان نشست */
|
||||
const presentedInSession = new Set<string>()
|
||||
|
||||
function mapTone(tone: NotificationTone): AppToastTone {
|
||||
if (tone === 'danger') return 'error'
|
||||
if (tone === 'success' || tone === 'warning' || tone === 'info') return tone
|
||||
return 'info'
|
||||
}
|
||||
|
||||
export function resetBroadcastPresentation() {
|
||||
presentedInSession.clear()
|
||||
}
|
||||
|
||||
/** اعلانهای هدفمند ادمین (خواندهنشده) را در مدال «متوجه شدم» نمایش میدهد. */
|
||||
export function presentUnreadBroadcastNotifications() {
|
||||
const store = useNotificationStore()
|
||||
const toast = useAppToast()
|
||||
|
||||
const pending = store.items
|
||||
.filter((n) => !n.isRead && n.event === BROADCAST_EVENT)
|
||||
.filter((n) => !presentedInSession.has(n.id))
|
||||
.sort((a, b) => new Date(a.sentAt).getTime() - new Date(b.sentAt).getTime())
|
||||
|
||||
for (const item of pending) {
|
||||
presentedInSession.add(item.id)
|
||||
toast.show(item.body, {
|
||||
title: item.title,
|
||||
tone: mapTone(item.tone),
|
||||
onDismiss: () => {
|
||||
void store.markRead(item.id)
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,34 +1,91 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
export type AppToastTone = 'success' | 'error'
|
||||
export type AppToastTone = 'success' | 'error' | 'info' | 'warning'
|
||||
|
||||
type ToastPayload = {
|
||||
text: string
|
||||
title: string
|
||||
tone: AppToastTone
|
||||
onDismiss?: () => void
|
||||
}
|
||||
|
||||
const message = ref('')
|
||||
const title = ref('')
|
||||
const tone = ref<AppToastTone>('success')
|
||||
let hideTimer: number | undefined
|
||||
const queue: ToastPayload[] = []
|
||||
let onDismissCallback: (() => void) | undefined
|
||||
let previousFocus: HTMLElement | null = null
|
||||
|
||||
function defaultTitle(t: AppToastTone): string {
|
||||
switch (t) {
|
||||
case 'error':
|
||||
return 'خطا'
|
||||
case 'warning':
|
||||
return 'هشدار'
|
||||
case 'info':
|
||||
return 'اطلاع'
|
||||
default:
|
||||
return 'موفقیت'
|
||||
}
|
||||
}
|
||||
|
||||
function display(payload: ToastPayload) {
|
||||
message.value = payload.text
|
||||
title.value = payload.title
|
||||
tone.value = payload.tone
|
||||
onDismissCallback = payload.onDismiss
|
||||
}
|
||||
|
||||
export function useAppToast() {
|
||||
function show(
|
||||
text: string,
|
||||
options?: { tone?: AppToastTone; durationMs?: number },
|
||||
options?: {
|
||||
tone?: AppToastTone
|
||||
title?: string
|
||||
/** @deprecated مدال تا تأیید کاربر باز میماند؛ duration نادیده گرفته میشود. */
|
||||
durationMs?: number
|
||||
onDismiss?: () => void
|
||||
},
|
||||
) {
|
||||
message.value = text.trim()
|
||||
if (!message.value) return
|
||||
tone.value = options?.tone ?? 'success'
|
||||
const durationMs = options?.durationMs ?? 6000
|
||||
if (hideTimer) window.clearTimeout(hideTimer)
|
||||
hideTimer = window.setTimeout(() => {
|
||||
message.value = ''
|
||||
hideTimer = undefined
|
||||
}, durationMs)
|
||||
const trimmed = text.trim()
|
||||
if (!trimmed) return
|
||||
|
||||
const payload: ToastPayload = {
|
||||
text: trimmed,
|
||||
tone: options?.tone ?? 'success',
|
||||
title: options?.title?.trim() ?? defaultTitle(options?.tone ?? 'success'),
|
||||
onDismiss: options?.onDismiss,
|
||||
}
|
||||
|
||||
if (!message.value) {
|
||||
if (typeof document !== 'undefined') {
|
||||
previousFocus = document.activeElement as HTMLElement | null
|
||||
}
|
||||
display(payload)
|
||||
return
|
||||
}
|
||||
|
||||
queue.push(payload)
|
||||
}
|
||||
|
||||
function clear() {
|
||||
message.value = ''
|
||||
if (hideTimer) {
|
||||
window.clearTimeout(hideTimer)
|
||||
hideTimer = undefined
|
||||
title.value = ''
|
||||
const cb = onDismissCallback
|
||||
onDismissCallback = undefined
|
||||
cb?.()
|
||||
|
||||
if (queue.length) {
|
||||
const next = queue.shift()!
|
||||
display(next)
|
||||
return
|
||||
}
|
||||
|
||||
if (previousFocus && typeof previousFocus.focus === 'function') {
|
||||
previousFocus.focus()
|
||||
}
|
||||
previousFocus = null
|
||||
}
|
||||
|
||||
return { message, tone, show, clear }
|
||||
return { message, title, tone, show, clear }
|
||||
}
|
||||
|
||||
@@ -2,12 +2,14 @@ import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useCatalogStore } from '@/stores/catalog'
|
||||
import { useCartStore } from '@/stores/cart'
|
||||
import { useAppToast } from '@/composables/useAppToast'
|
||||
|
||||
export function useProductOrdering() {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const catalogStore = useCatalogStore()
|
||||
const cartStore = useCartStore()
|
||||
const appToast = useAppToast()
|
||||
const loading = ref(true)
|
||||
|
||||
const companyId = computed(() => route.params.companyId as string)
|
||||
@@ -28,7 +30,6 @@ export function useProductOrdering() {
|
||||
|
||||
const meterages = ref<Record<string, number>>({})
|
||||
const errors = ref<Record<string, string>>({})
|
||||
const toast = ref('')
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
@@ -70,6 +71,11 @@ export function useProductOrdering() {
|
||||
return
|
||||
}
|
||||
|
||||
if (!product.isActive) {
|
||||
errors.value = { ...errors.value, [productId]: 'این محصول ناموجود است.' }
|
||||
return
|
||||
}
|
||||
|
||||
const meterage = getMeterage(productId)
|
||||
if (meterage <= 0) {
|
||||
errors.value = { ...errors.value, [productId]: 'متراژ را وارد کنید.' }
|
||||
@@ -79,10 +85,7 @@ export function useProductOrdering() {
|
||||
const result = await cartStore.addItem(product, catalog.value.code, meterage)
|
||||
if (result.success) {
|
||||
errors.value = { ...errors.value, [productId]: '' }
|
||||
toast.value = `${product.name} به سبد اضافه شد.`
|
||||
setTimeout(() => {
|
||||
toast.value = ''
|
||||
}, 2000)
|
||||
appToast.show(`${product.name} به سبد اضافه شد.`, { tone: 'success' })
|
||||
} else {
|
||||
errors.value = { ...errors.value, [productId]: result.message ?? 'خطا' }
|
||||
}
|
||||
@@ -111,7 +114,6 @@ export function useProductOrdering() {
|
||||
products,
|
||||
meterages,
|
||||
errors,
|
||||
toast,
|
||||
loading,
|
||||
cartStore,
|
||||
adjustMeterage,
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { getPublicContact, telHref } from '@/services/settings'
|
||||
import { getPublicContact, telHref, type PublicContact } from '@/services/settings'
|
||||
import { buildSupportLinks } from '@/utils/supportLinks'
|
||||
|
||||
type ContactState = {
|
||||
supportPhone: string | null
|
||||
supportEmail: string | null
|
||||
}
|
||||
|
||||
const state = ref<ContactState | null>(null)
|
||||
const state = ref<PublicContact | null>(null)
|
||||
let loadPromise: Promise<void> | null = null
|
||||
|
||||
const emptyContact: PublicContact = {
|
||||
supportPhone: null,
|
||||
supportEmail: null,
|
||||
supportWhatsapp: null,
|
||||
supportTelegram: null,
|
||||
supportInstagram: null,
|
||||
defaultSettlementCash: true,
|
||||
defaultSettlementCheck: true,
|
||||
defaultSettlementCashCheck: false,
|
||||
}
|
||||
|
||||
function ensureLoaded() {
|
||||
if (state.value) return Promise.resolve()
|
||||
if (!loadPromise) {
|
||||
@@ -17,25 +24,50 @@ function ensureLoaded() {
|
||||
state.value = data
|
||||
})
|
||||
.catch(() => {
|
||||
state.value = { supportPhone: null, supportEmail: null }
|
||||
state.value = emptyContact
|
||||
})
|
||||
}
|
||||
return loadPromise
|
||||
}
|
||||
|
||||
export function invalidatePublicContact() {
|
||||
state.value = null
|
||||
loadPromise = null
|
||||
}
|
||||
|
||||
export function usePublicContact() {
|
||||
const loaded = computed(() => state.value !== null)
|
||||
const supportLinks = computed(() => buildSupportLinks(state.value))
|
||||
const hasSupportLinks = computed(() => supportLinks.value.length > 0)
|
||||
|
||||
const supportPhone = computed(() => state.value?.supportPhone ?? null)
|
||||
const supportEmail = computed(() => state.value?.supportEmail ?? null)
|
||||
const supportHref = computed(() => telHref(supportPhone.value))
|
||||
|
||||
const defaultSettlementCash = computed(
|
||||
() => state.value?.defaultSettlementCash ?? true,
|
||||
)
|
||||
const defaultSettlementCheck = computed(
|
||||
() => state.value?.defaultSettlementCheck ?? true,
|
||||
)
|
||||
const defaultSettlementCashCheck = computed(
|
||||
() => state.value?.defaultSettlementCashCheck ?? false,
|
||||
)
|
||||
|
||||
function load() {
|
||||
return ensureLoaded()
|
||||
}
|
||||
|
||||
return {
|
||||
load,
|
||||
loaded,
|
||||
supportPhone,
|
||||
supportEmail,
|
||||
supportHref,
|
||||
supportLinks,
|
||||
hasSupportLinks,
|
||||
defaultSettlementCash,
|
||||
defaultSettlementCheck,
|
||||
defaultSettlementCashCheck,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useRoute } from 'vue-router'
|
||||
import BottomNav from '@/components/layout/BottomNav.vue'
|
||||
import DesktopSidebar from '@/components/layout/DesktopSidebar.vue'
|
||||
import DesktopHeader from '@/components/layout/DesktopHeader.vue'
|
||||
import SupportWidget from '@/components/support/SupportWidget.vue'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
@@ -39,6 +40,8 @@ const isImmersiveAuth = computed(
|
||||
route.name === 'register' ||
|
||||
route.name === 'otp',
|
||||
)
|
||||
|
||||
const showSupportWidget = computed(() => route.name !== 'welcome')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -65,5 +68,6 @@ const isImmersiveAuth = computed(
|
||||
<BottomNav v-if="showNav" />
|
||||
</div>
|
||||
</div>
|
||||
<SupportWidget v-if="showSupportWidget" :above-bottom-nav="showNav" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -11,6 +11,7 @@ import './assets/styles/app-shell.css'
|
||||
import './assets/styles/responsive.css'
|
||||
import './assets/styles/desktop.css'
|
||||
import './assets/styles/desktop-pages.css'
|
||||
import './assets/styles/header-narrow.css'
|
||||
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
|
||||
@@ -45,6 +45,8 @@ export type BrowseProductDto = {
|
||||
rollMeters?: number
|
||||
imageUrl?: string
|
||||
imageUrls?: string[]
|
||||
stock?: number | null
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
const AGENT_UNAVAILABLE =
|
||||
@@ -126,7 +128,6 @@ export async function fetchBrowseProducts(query?: {
|
||||
}) {
|
||||
return apiRequest<BrowseProductDto[]>('/products', {
|
||||
query: {
|
||||
activeOnly: true,
|
||||
categoryId: query?.categoryId,
|
||||
season: query?.season,
|
||||
companyId: query?.companyId,
|
||||
@@ -141,6 +142,7 @@ export type AppBannerDto = {
|
||||
subtitle: string
|
||||
ctaLabel: string
|
||||
ctaLink: string
|
||||
bannerLink?: string
|
||||
imageUrl: string
|
||||
showTitle?: boolean
|
||||
showSubtitle?: boolean
|
||||
|
||||
@@ -3,6 +3,12 @@ import { apiRequest } from '@/services/http'
|
||||
export type PublicContact = {
|
||||
supportPhone: string | null
|
||||
supportEmail: string | null
|
||||
supportWhatsapp: string | null
|
||||
supportTelegram: string | null
|
||||
supportInstagram: string | null
|
||||
defaultSettlementCash?: boolean
|
||||
defaultSettlementCheck?: boolean
|
||||
defaultSettlementCashCheck?: boolean
|
||||
}
|
||||
|
||||
export function telHref(phone: string | null | undefined): string | undefined {
|
||||
@@ -11,6 +17,12 @@ export function telHref(phone: string | null | undefined): string | undefined {
|
||||
return digits ? `tel:${digits}` : undefined
|
||||
}
|
||||
|
||||
export function mailtoHref(email: string | null | undefined): string | undefined {
|
||||
const trimmed = email?.trim()
|
||||
if (!trimmed) return undefined
|
||||
return `mailto:${trimmed}`
|
||||
}
|
||||
|
||||
export function getPublicContact() {
|
||||
return apiRequest<PublicContact>('/settings/public', { auth: false })
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ export type ProductSuggestionDto = {
|
||||
widthCm?: number
|
||||
composition?: string
|
||||
isNew?: boolean
|
||||
productIsActive?: boolean
|
||||
note?: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
@@ -18,6 +18,10 @@ import { useCartStore } from './cart'
|
||||
import { useOrderStore } from './order'
|
||||
import { useCatalogStore } from './catalog'
|
||||
import { useNotificationStore } from './notification'
|
||||
import {
|
||||
presentUnreadBroadcastNotifications,
|
||||
resetBroadcastPresentation,
|
||||
} from '@/composables/presentBroadcastNotifications'
|
||||
import { useFavoritesStore } from './favorites'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
@@ -91,6 +95,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
useNotificationStore().hydrate(),
|
||||
useFavoritesStore().hydrate(),
|
||||
])
|
||||
presentUnreadBroadcastNotifications()
|
||||
} else {
|
||||
error.value = result.message
|
||||
}
|
||||
@@ -104,6 +109,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
useCartStore().resetLocal()
|
||||
useOrderStore().resetLocal()
|
||||
useNotificationStore().resetLocal()
|
||||
resetBroadcastPresentation()
|
||||
useFavoritesStore().resetLocal()
|
||||
}
|
||||
|
||||
|
||||
+12
-6
@@ -53,7 +53,6 @@ export const useCatalogStore = defineStore('catalog', () => {
|
||||
query: {
|
||||
catalogId,
|
||||
color: color && color !== 'all' ? color : undefined,
|
||||
activeOnly: true,
|
||||
},
|
||||
})
|
||||
const others = products.value.filter((p) => p.catalogId !== catalogId)
|
||||
@@ -61,13 +60,19 @@ export const useCatalogStore = defineStore('catalog', () => {
|
||||
return list
|
||||
}
|
||||
|
||||
async function loadBankAccounts(companyId: string) {
|
||||
const list = await apiRequest<BankAccount[]>(`/companies/${companyId}/bank-accounts`)
|
||||
async function loadPaymentBankAccounts() {
|
||||
const list = await apiRequest<BankAccount[]>('/companies/payment/bank-accounts')
|
||||
const recipientId = list[0]?.companyId ?? 'payment-recipient'
|
||||
bankAccounts.value = [
|
||||
...bankAccounts.value.filter((b) => b.companyId !== companyId),
|
||||
...bankAccounts.value.filter((b) => b.companyId !== recipientId),
|
||||
...list.filter((b) => b.isActive),
|
||||
]
|
||||
return bankAccounts.value.filter((b) => b.companyId === companyId)
|
||||
return list.filter((b) => b.isActive)
|
||||
}
|
||||
|
||||
/** @deprecated برای پرداخت از loadPaymentBankAccounts استفاده کنید */
|
||||
async function loadBankAccounts(_companyId: string) {
|
||||
return loadPaymentBankAccounts()
|
||||
}
|
||||
|
||||
function fetchCatalogs(companyId: string, query = ''): Catalog[] {
|
||||
@@ -80,7 +85,7 @@ export const useCatalogStore = defineStore('catalog', () => {
|
||||
}
|
||||
|
||||
function fetchProducts(catalogId: string, colorFilter?: string): Product[] {
|
||||
let list = products.value.filter((p) => p.catalogId === catalogId && p.isActive)
|
||||
let list = products.value.filter((p) => p.catalogId === catalogId)
|
||||
if (colorFilter && colorFilter !== 'all') {
|
||||
list = list.filter((p) => p.color.includes(colorFilter))
|
||||
}
|
||||
@@ -117,6 +122,7 @@ export const useCatalogStore = defineStore('catalog', () => {
|
||||
loadCatalogs,
|
||||
loadProducts,
|
||||
loadBankAccounts,
|
||||
loadPaymentBankAccounts,
|
||||
getActiveCompanies,
|
||||
getCompany,
|
||||
fetchCatalogs,
|
||||
|
||||
@@ -44,10 +44,12 @@ function mapProduct(p: {
|
||||
tags?: string[]
|
||||
isNew?: boolean
|
||||
subtitle?: string
|
||||
specs?: string
|
||||
rollMeters?: number
|
||||
imageUrl?: string
|
||||
imageUrls?: string[]
|
||||
stock?: number | null
|
||||
isActive?: boolean
|
||||
}): CatalogProductCard {
|
||||
return {
|
||||
id: p.id,
|
||||
@@ -68,6 +70,7 @@ function mapProduct(p: {
|
||||
p.imageUrls?.[0] || p.imageUrl || p.colorImages?.find(Boolean) || undefined,
|
||||
imageUrls: p.imageUrls,
|
||||
subtitle: p.subtitle,
|
||||
specs: p.specs?.trim() || undefined,
|
||||
rollMeters: p.rollMeters ?? 50,
|
||||
companyId: p.companyId,
|
||||
catalogId: p.catalogId,
|
||||
@@ -78,6 +81,7 @@ function mapProduct(p: {
|
||||
orderMultiplier: p.orderMultiplier ?? 10,
|
||||
basePrice: p.basePrice ?? undefined,
|
||||
stock: p.stock ?? null,
|
||||
isActive: p.isActive !== false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,10 +139,12 @@ export const useCrmStore = defineStore('crm', () => {
|
||||
tags?: string[]
|
||||
isNew?: boolean
|
||||
subtitle?: string
|
||||
specs?: string
|
||||
rollMeters?: number
|
||||
imageUrl?: string
|
||||
imageUrls?: string[]
|
||||
stock?: number | null
|
||||
isActive?: boolean
|
||||
}>(`/products/${id}`)
|
||||
return mapProduct(p)
|
||||
}
|
||||
|
||||
+5
-18
@@ -171,7 +171,8 @@ export const useOrderStore = defineStore('order', () => {
|
||||
method: 'POST',
|
||||
body: {
|
||||
settlementType,
|
||||
checkMode: settlementType === 'check' ? (checkMode ?? 'full') : undefined,
|
||||
checkMode:
|
||||
settlementType === 'check' ? (checkMode ?? 'full') : undefined,
|
||||
},
|
||||
})
|
||||
upsertOrder(order)
|
||||
@@ -243,24 +244,10 @@ export const useOrderStore = defineStore('order', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function getBankAccountsForOrder(order: Order) {
|
||||
const companyIds = [
|
||||
...new Set(
|
||||
[
|
||||
order.companyId,
|
||||
...order.items.map((item) => item.companyId).filter(Boolean),
|
||||
].filter(Boolean) as string[],
|
||||
),
|
||||
]
|
||||
/** واریز همیشه به حسابهای نادمی تکس است، مستقل از شرکت مالک محصول */
|
||||
async function getBankAccountsForOrder(_order: Order) {
|
||||
const catalog = useCatalogStore()
|
||||
const lists = await Promise.all(companyIds.map((id) => catalog.loadBankAccounts(id)))
|
||||
const byId = new Map<string, (typeof lists)[number][number]>()
|
||||
for (const list of lists) {
|
||||
for (const account of list) {
|
||||
byId.set(account.id, account)
|
||||
}
|
||||
}
|
||||
return [...byId.values()]
|
||||
return catalog.loadPaymentBankAccounts()
|
||||
}
|
||||
|
||||
async function getBankAccountsForCompany(companyId: string) {
|
||||
|
||||
@@ -103,6 +103,8 @@ export interface CatalogProductCard {
|
||||
isNew: boolean
|
||||
imageUrl?: string
|
||||
subtitle?: string
|
||||
/** مشخصات آزاد (specs-extra) */
|
||||
specs?: string
|
||||
rollMeters: number
|
||||
/** فیلدهای سفارش واقعی */
|
||||
companyId?: string
|
||||
@@ -114,6 +116,7 @@ export interface CatalogProductCard {
|
||||
orderMultiplier?: number
|
||||
basePrice?: number
|
||||
stock?: number | null
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
export interface HomeBanner {
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
export type SettlementType = 'cash' | 'check'
|
||||
export type SettlementType = 'cash' | 'check' | 'cash_check'
|
||||
export type CheckRegistrationMode = 'full' | 'brief'
|
||||
|
||||
export type OrderStatus =
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Router } from 'vue-router'
|
||||
|
||||
export function isValidBannerLink(value: string): boolean {
|
||||
const link = value.trim()
|
||||
if (!link) return true
|
||||
if (link.startsWith('/')) return /^\/[^\s]*$/.test(link)
|
||||
try {
|
||||
const url = new URL(link)
|
||||
return url.protocol === 'http:' || url.protocol === 'https:'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function followBannerLink(link: string, router: Router) {
|
||||
const trimmed = link.trim()
|
||||
if (!trimmed) return
|
||||
if (/^https?:\/\//i.test(trimmed)) {
|
||||
window.location.assign(trimmed)
|
||||
return
|
||||
}
|
||||
router.push(trimmed)
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { OrderStatus } from '@/types'
|
||||
|
||||
/** همتراز با slug شرکت دریافتکننده پرداخت در سرور */
|
||||
export const PAYMENT_RECIPIENT_COMPANY_SLUG = 'nademitex'
|
||||
|
||||
export const DEFAULT_MAX_METERAGE = 5000
|
||||
|
||||
export const ORDER_STATUS_LABELS: Record<OrderStatus, string> = {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Order, OrderItem, OrderStatus } from '@/types'
|
||||
import { usesCheckSettlement } from '@/utils/settlement'
|
||||
|
||||
/** متراژ مؤثر هر قلم: بعد از قیمتگذاری ادمین، مقدار نهایی (confirmed) است. */
|
||||
export function orderItemMeterage(
|
||||
@@ -49,8 +50,9 @@ export function canSubmitCashReceipt(order: Order | null | undefined): boolean {
|
||||
|
||||
export function canEnterChecks(order: Order | null | undefined): boolean {
|
||||
if (!isInvoicePriced(order) || !order) return false
|
||||
if (!usesCheckSettlement(order.settlementType)) return false
|
||||
if (CHECK_ENTRY_STATUSES.includes(order.status)) return true
|
||||
return order.settlementType === 'check' && order.status === 'check_in_progress'
|
||||
return order.status === 'check_in_progress'
|
||||
}
|
||||
|
||||
export function isAwaitingAdminPricing(order: Order | null | undefined): boolean {
|
||||
|
||||
@@ -159,6 +159,13 @@ function paymentStepLabels(settlementType: SettlementType | null | undefined): {
|
||||
approved: 'چکها تأیید شده',
|
||||
}
|
||||
}
|
||||
if (settlementType === 'cash_check') {
|
||||
return {
|
||||
payment: 'ثبت رسید نقدی',
|
||||
review: 'بررسی چکها',
|
||||
approved: 'چکها تأیید شده',
|
||||
}
|
||||
}
|
||||
return {
|
||||
payment: 'ثبت پرداخت / چک',
|
||||
review: 'بررسی پرداخت / چک',
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
/** Matches admin product `isActive`. Stock=0 keeps the existing detail-page label. */
|
||||
export function isProductUnavailable(product: {
|
||||
isActive?: boolean
|
||||
stock?: number | null
|
||||
}): boolean {
|
||||
if (product.isActive === false) return true
|
||||
return product.stock != null && product.stock <= 0
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { normalizeDigits } from '@/utils/format'
|
||||
|
||||
/** Normalize label/query for case-insensitive Persian-friendly select search. */
|
||||
export function normalizeForSelectSearch(text: string): string {
|
||||
return normalizeDigits(text)
|
||||
.toLowerCase()
|
||||
.replace(/ي/g, 'ی')
|
||||
.replace(/ك/g, 'ک')
|
||||
.normalize('NFKC')
|
||||
.trim()
|
||||
}
|
||||
|
||||
export function optionMatchesSelectSearch(label: string, query: string): boolean {
|
||||
const q = normalizeForSelectSearch(query)
|
||||
if (!q) return true
|
||||
return normalizeForSelectSearch(label).includes(q)
|
||||
}
|
||||
|
||||
export function filterSelectOptionsBySearch<T extends { label: string }>(
|
||||
options: T[],
|
||||
query: string,
|
||||
): T[] {
|
||||
const trimmed = query.trim()
|
||||
if (!trimmed) return options
|
||||
return options.filter((option) => optionMatchesSelectSearch(option.label, trimmed))
|
||||
}
|
||||
|
||||
export function focusSelectMenuOption(
|
||||
menuRoot: HTMLElement | null,
|
||||
which: 'first' | 'last',
|
||||
): void {
|
||||
if (!menuRoot) return
|
||||
const buttons = menuRoot.querySelectorAll<HTMLElement>('[data-select-option]')
|
||||
const target = which === 'first' ? buttons[0] : buttons[buttons.length - 1]
|
||||
target?.focus()
|
||||
}
|
||||
|
||||
export function focusAdjacentSelectOption(
|
||||
current: HTMLElement,
|
||||
direction: 1 | -1,
|
||||
): void {
|
||||
const menu = current.closest('[data-select-menu]')
|
||||
if (!menu) return
|
||||
const buttons = [...menu.querySelectorAll<HTMLElement>('[data-select-option]')]
|
||||
const index = buttons.indexOf(current)
|
||||
if (index < 0) return
|
||||
const next = buttons[index + direction]
|
||||
next?.focus()
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { OrderStatus, SettlementType } from '@/types'
|
||||
|
||||
export const SETTLEMENT_TYPE_LABELS: Record<SettlementType, string> = {
|
||||
cash: 'تسویه نقدی',
|
||||
check: 'تسویه چکی',
|
||||
cash_check: 'تسویه نقدی-چکی',
|
||||
}
|
||||
|
||||
export function settlementTypeLabel(
|
||||
type: SettlementType | null | undefined,
|
||||
): string {
|
||||
if (!type) return 'در انتظار انتخاب مشتری'
|
||||
return SETTLEMENT_TYPE_LABELS[type] ?? type
|
||||
}
|
||||
|
||||
export function usesCashReceiptPhase(
|
||||
type: SettlementType | null | undefined,
|
||||
): boolean {
|
||||
return type === 'cash' || type === 'cash_check'
|
||||
}
|
||||
|
||||
export function usesCheckSettlement(
|
||||
type: SettlementType | null | undefined,
|
||||
): boolean {
|
||||
return type === 'check' || type === 'cash_check'
|
||||
}
|
||||
|
||||
const CASH_PAYMENT_STATUSES: OrderStatus[] = [
|
||||
'awaiting_cash_payment',
|
||||
'cash_invoice_issued',
|
||||
'cash_payment_submitted',
|
||||
'awaiting_cash_confirmation',
|
||||
]
|
||||
|
||||
export function isInCashPaymentPhase(
|
||||
order: { settlementType: SettlementType | null; status: OrderStatus },
|
||||
): boolean {
|
||||
if (!usesCashReceiptPhase(order.settlementType)) return false
|
||||
if (order.settlementType === 'cash') {
|
||||
return CASH_PAYMENT_STATUSES.includes(order.status)
|
||||
}
|
||||
return CASH_PAYMENT_STATUSES.includes(order.status)
|
||||
}
|
||||
|
||||
export function isInCheckPhase(
|
||||
order: { settlementType: SettlementType | null; status: OrderStatus },
|
||||
): boolean {
|
||||
if (!usesCheckSettlement(order.settlementType)) return false
|
||||
if (order.settlementType === 'check') return true
|
||||
return !CASH_PAYMENT_STATUSES.includes(order.status)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { PublicContact } from '@/services/settings'
|
||||
import { mailtoHref, telHref } from '@/services/settings'
|
||||
|
||||
export type SupportLinkKind =
|
||||
| 'whatsapp'
|
||||
| 'telegram'
|
||||
| 'instagram'
|
||||
| 'phone'
|
||||
| 'email'
|
||||
|
||||
export type SupportLinkItem = {
|
||||
kind: SupportLinkKind
|
||||
label: string
|
||||
href: string
|
||||
subtitle: string
|
||||
}
|
||||
|
||||
function trimValue(value: string | null | undefined): string {
|
||||
return value?.trim() ?? ''
|
||||
}
|
||||
|
||||
function isHttpUrl(value: string): boolean {
|
||||
return /^https?:\/\//i.test(value)
|
||||
}
|
||||
|
||||
function whatsappHref(raw: string): string | undefined {
|
||||
if (!raw) return undefined
|
||||
if (isHttpUrl(raw)) return raw
|
||||
const digits = raw.replace(/[^\d]/g, '')
|
||||
if (!digits) return undefined
|
||||
return `https://wa.me/${digits}`
|
||||
}
|
||||
|
||||
function telegramHref(raw: string): string | undefined {
|
||||
if (!raw) return undefined
|
||||
if (isHttpUrl(raw)) return raw
|
||||
const handle = raw.replace(/^@/, '').replace(/^https?:\/\/(t\.me|telegram\.me)\//i, '').trim()
|
||||
if (!handle) return undefined
|
||||
return `https://t.me/${handle}`
|
||||
}
|
||||
|
||||
function instagramHref(raw: string): string | undefined {
|
||||
if (!raw) return undefined
|
||||
if (isHttpUrl(raw)) return raw
|
||||
const handle = raw.replace(/^@/, '').replace(/^instagram\.com\//i, '').trim()
|
||||
if (!handle) return undefined
|
||||
return `https://instagram.com/${handle}`
|
||||
}
|
||||
|
||||
export function buildSupportLinks(contact: PublicContact | null): SupportLinkItem[] {
|
||||
if (!contact) return []
|
||||
|
||||
const items: SupportLinkItem[] = []
|
||||
|
||||
const wa = trimValue(contact.supportWhatsapp)
|
||||
const waHref = whatsappHref(wa)
|
||||
if (waHref) {
|
||||
items.push({ kind: 'whatsapp', label: 'واتساپ', href: waHref, subtitle: wa })
|
||||
}
|
||||
|
||||
const tg = trimValue(contact.supportTelegram)
|
||||
const tgHref = telegramHref(tg)
|
||||
if (tgHref) {
|
||||
items.push({ kind: 'telegram', label: 'تلگرام', href: tgHref, subtitle: tg })
|
||||
}
|
||||
|
||||
const ig = trimValue(contact.supportInstagram)
|
||||
const igHref = instagramHref(ig)
|
||||
if (igHref) {
|
||||
items.push({ kind: 'instagram', label: 'اینستاگرام', href: igHref, subtitle: ig })
|
||||
}
|
||||
|
||||
const phone = trimValue(contact.supportPhone)
|
||||
const phoneHref = telHref(phone)
|
||||
if (phoneHref) {
|
||||
items.push({ kind: 'phone', label: 'تماس تلفنی', href: phoneHref, subtitle: phone })
|
||||
}
|
||||
|
||||
const email = trimValue(contact.supportEmail)
|
||||
const emailHref = mailtoHref(email)
|
||||
if (emailHref) {
|
||||
items.push({ kind: 'email', label: 'ایمیل', href: emailHref, subtitle: email })
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
import { formatNumber } from '@/utils/format'
|
||||
import { colorSwatchStyle } from '@/utils/colorSwatch'
|
||||
import { demoProductsFor, findMockCalite } from '@/data/caliteCollections'
|
||||
import { isProductUnavailable } from '@/utils/productAvailability'
|
||||
import { useAppToast } from '@/composables/useAppToast'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -32,6 +34,7 @@ const catalogStore = useCatalogStore()
|
||||
const crm = useCrmStore()
|
||||
const cartStore = useCartStore()
|
||||
const favorites = useFavoritesStore()
|
||||
const appToast = useAppToast()
|
||||
|
||||
const catalogId = computed(() => String(route.params.catalogId ?? ''))
|
||||
const catalog = ref<Catalog | null>(null)
|
||||
@@ -39,7 +42,6 @@ const products = ref<CatalogProductCard[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const adding = ref(false)
|
||||
const toast = ref('')
|
||||
const tab = ref<'all' | PatternKind>('all')
|
||||
|
||||
const title = computed(() => catalogDisplayTitle(catalog.value?.name || 'کالیته'))
|
||||
@@ -186,18 +188,18 @@ function toCartProduct(item: CatalogProductCard): Product {
|
||||
minMeterage: item.minMeterage ?? 30,
|
||||
orderMultiplier: item.orderMultiplier ?? 10,
|
||||
basePrice: item.basePrice,
|
||||
isActive: true,
|
||||
isActive: item.isActive !== false,
|
||||
}
|
||||
}
|
||||
|
||||
async function addAllToInquiry() {
|
||||
if (!products.value.length || adding.value) return
|
||||
adding.value = true
|
||||
toast.value = ''
|
||||
let added = 0
|
||||
let failed = ''
|
||||
try {
|
||||
for (const item of products.value) {
|
||||
if (isProductUnavailable(item)) continue
|
||||
const product = toCartProduct(item)
|
||||
const result = await cartStore.addItem(
|
||||
product,
|
||||
@@ -207,11 +209,16 @@ async function addAllToInquiry() {
|
||||
if (result.success) added += 1
|
||||
else failed = result.message || failed
|
||||
}
|
||||
toast.value = added
|
||||
const text = added
|
||||
? `${formatNumber(added)} طرح به سبد استعلام اضافه شد.`
|
||||
: failed || 'افزودن به سبد ناموفق بود.'
|
||||
if (added) {
|
||||
window.setTimeout(() => router.push('/cart'), 650)
|
||||
appToast.show(text, {
|
||||
tone: 'success',
|
||||
onDismiss: () => router.push('/cart'),
|
||||
})
|
||||
} else {
|
||||
appToast.show(text, { tone: failed ? 'error' : 'warning' })
|
||||
}
|
||||
} finally {
|
||||
adding.value = false
|
||||
@@ -348,6 +355,10 @@ async function addAllToInquiry() {
|
||||
class="pattern-card__photo"
|
||||
:style="pattern.imageUrl ? { backgroundImage: `url(${pattern.imageUrl})` } : undefined"
|
||||
>
|
||||
<span
|
||||
v-if="pattern.items.every((p) => isProductUnavailable(p))"
|
||||
class="pattern-card__oos"
|
||||
>ناموجود</span>
|
||||
<button
|
||||
type="button"
|
||||
class="pattern-card__fav"
|
||||
@@ -394,7 +405,6 @@ async function addAllToInquiry() {
|
||||
<small>{{ formatNumber(patterns.length) }} طرح انتخاب میشود</small>
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="toast" class="toast">{{ toast }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -662,6 +672,19 @@ async function addAllToInquiry() {
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
.pattern-card__oos {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
inset-inline-end: 8px;
|
||||
z-index: 1;
|
||||
padding: 3px 8px;
|
||||
border-radius: 999px;
|
||||
background: #b91c1c;
|
||||
color: #fff;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.pattern-card__fav {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
@@ -749,20 +772,6 @@ async function addAllToInquiry() {
|
||||
color: #8a847c;
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: calc(var(--nav-height) + 84px);
|
||||
z-index: 20;
|
||||
transform: translateX(-50%);
|
||||
padding: 8px 14px;
|
||||
border-radius: 999px;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
font-size: 0.75rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
html.dark .calite-detail {
|
||||
background: #0b0f14;
|
||||
color: #eef2f8;
|
||||
@@ -912,10 +921,6 @@ html.dark .calite-detail__footer.sticky-footer {
|
||||
margin-inline: auto;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.toast {
|
||||
bottom: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import IconSuccessCircle from '@/components/icons/IconSuccessCircle.vue'
|
||||
import { formatCurrency, formatDateTime } from '@/utils/format'
|
||||
import { useRequirePricedOrder } from '@/composables/useRequirePricedOrder'
|
||||
import type { BankAccount } from '@/types'
|
||||
import { PAYMENT_RECIPIENT_COMPANY_SLUG } from '@/utils/constants'
|
||||
|
||||
const router = useRouter()
|
||||
const orderStore = useOrderStore()
|
||||
@@ -16,7 +17,13 @@ const catalogStore = useCatalogStore()
|
||||
const bankAccounts = ref<BankAccount[]>([])
|
||||
|
||||
const { orderId, order } = useRequirePricedOrder()
|
||||
const companyName = computed(() => order.value?.companyName ?? '')
|
||||
|
||||
const paymentRecipientName = computed(() => {
|
||||
const fromCatalog = catalogStore.companies.find(
|
||||
(c) => c.slug === PAYMENT_RECIPIENT_COMPANY_SLUG,
|
||||
)?.name
|
||||
return fromCatalog ?? 'نادمی تکس'
|
||||
})
|
||||
|
||||
watch(
|
||||
orderId,
|
||||
@@ -49,12 +56,9 @@ const expiryMessage = computed(() => {
|
||||
return `مهلت پرداخت این فاکتور تا ${formatDateTime(expiry)} است.`
|
||||
})
|
||||
|
||||
const accountsSectionTitle = computed(() => {
|
||||
const name = companyName.value.replace(/^شرکت\s+/u, '')
|
||||
return name
|
||||
? `شماره حسابهای واریز — ${name}`
|
||||
: 'شماره حسابهای واریز'
|
||||
})
|
||||
const accountsSectionTitle = computed(
|
||||
() => `شماره حسابهای واریز — ${paymentRecipientName.value}`,
|
||||
)
|
||||
|
||||
function formatCardNumber(value: string) {
|
||||
const digits = value.replace(/\D/g, '')
|
||||
|
||||
@@ -49,7 +49,11 @@ const checks = computed<CheckData[]>(() => {
|
||||
|
||||
return orderStore
|
||||
.userOrders(authStore.user.id)
|
||||
.filter((o) => o.settlementType === 'check' && o.checks?.length)
|
||||
.filter(
|
||||
(o) =>
|
||||
(o.settlementType === 'check' || o.settlementType === 'cash_check') &&
|
||||
o.checks?.length,
|
||||
)
|
||||
.flatMap((o) => o.checks ?? [])
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useOrderStore } from '@/stores/order'
|
||||
import PageHeader from '@/components/layout/PageHeader.vue'
|
||||
@@ -11,9 +11,21 @@ import IconCheckBook from '@/components/icons/IconCheckBook.vue'
|
||||
import { formatCurrency } from '@/utils/format'
|
||||
import { isInvoicePriced } from '@/utils/orderInvoice'
|
||||
import { useRequirePricedOrder } from '@/composables/useRequirePricedOrder'
|
||||
import { usePublicContact } from '@/composables/usePublicContact'
|
||||
import type { SettlementType } from '@/types'
|
||||
|
||||
const router = useRouter()
|
||||
const orderStore = useOrderStore()
|
||||
const {
|
||||
load: loadPublicSettings,
|
||||
defaultSettlementCash,
|
||||
defaultSettlementCheck,
|
||||
defaultSettlementCashCheck,
|
||||
} = usePublicContact()
|
||||
|
||||
onMounted(() => {
|
||||
void loadPublicSettings()
|
||||
})
|
||||
|
||||
const { orderId, order, invoiceReady } = useRequirePricedOrder()
|
||||
const submitting = ref(false)
|
||||
@@ -31,7 +43,7 @@ const invoiceLabel = computed(() =>
|
||||
order.value?.finalAmount ? formatCurrency(order.value.finalAmount) : '—',
|
||||
)
|
||||
|
||||
async function selectType(type: 'cash' | 'check') {
|
||||
async function selectType(type: SettlementType) {
|
||||
if (submitting.value || !isInvoicePriced(order.value)) {
|
||||
formError.value = 'تا قبل از قیمتگذاری مدیریت، امکان انتخاب روش تسویه وجود ندارد.'
|
||||
return
|
||||
@@ -40,7 +52,7 @@ async function selectType(type: 'cash' | 'check') {
|
||||
formError.value = ''
|
||||
try {
|
||||
await orderStore.chooseSettlement(orderId.value, type)
|
||||
if (type === 'cash') {
|
||||
if (type === 'cash' || type === 'cash_check') {
|
||||
router.replace(`/orders/${orderId.value}/cash`)
|
||||
} else {
|
||||
orderStore.startCheckDraft(orderId.value)
|
||||
@@ -87,6 +99,7 @@ async function selectType(type: 'cash' | 'check') {
|
||||
|
||||
<div class="settlement-mobile-options">
|
||||
<button
|
||||
v-if="defaultSettlementCash"
|
||||
type="button"
|
||||
class="settlement-card"
|
||||
:disabled="submitting"
|
||||
@@ -99,6 +112,7 @@ async function selectType(type: 'cash' | 'check') {
|
||||
<p>پرداخت از طریق واریز به حساب شرکت</p>
|
||||
</button>
|
||||
<button
|
||||
v-if="defaultSettlementCheck"
|
||||
type="button"
|
||||
class="settlement-card"
|
||||
:disabled="submitting"
|
||||
@@ -110,6 +124,22 @@ async function selectType(type: 'cash' | 'check') {
|
||||
<h3>تسویه چکی</h3>
|
||||
<p>پرداخت بر اساس مبلغ فاکتور با ثبت چک</p>
|
||||
</button>
|
||||
<button
|
||||
v-if="defaultSettlementCashCheck"
|
||||
type="button"
|
||||
class="settlement-card"
|
||||
:disabled="submitting"
|
||||
@click="selectType('cash_check')"
|
||||
>
|
||||
<span
|
||||
class="settlement-card__icon settlement-card__icon--check settlement-card__icon--hybrid"
|
||||
>
|
||||
<IconCashSettlement :size="28" />
|
||||
<IconCheckBook :size="28" />
|
||||
</span>
|
||||
<h3>تسویه نقدی-چکی</h3>
|
||||
<p>ابتدا واریز نقدی، سپس ثبت چک برای مابقی تسویه</p>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -155,6 +185,7 @@ async function selectType(type: 'cash' | 'check') {
|
||||
|
||||
<div v-else class="desktop-settlement-grid">
|
||||
<button
|
||||
v-if="defaultSettlementCash"
|
||||
type="button"
|
||||
class="settlement-card settlement-card--desktop"
|
||||
:disabled="submitting"
|
||||
@@ -167,6 +198,7 @@ async function selectType(type: 'cash' | 'check') {
|
||||
<p>پرداخت از طریق واریز به حساب شرکت</p>
|
||||
</button>
|
||||
<button
|
||||
v-if="defaultSettlementCheck"
|
||||
type="button"
|
||||
class="settlement-card settlement-card--desktop"
|
||||
:disabled="submitting"
|
||||
@@ -178,6 +210,20 @@ async function selectType(type: 'cash' | 'check') {
|
||||
<h3>تسویه چکی</h3>
|
||||
<p>پرداخت بر اساس مبلغ فاکتور با ثبت چک</p>
|
||||
</button>
|
||||
<button
|
||||
v-if="defaultSettlementCashCheck"
|
||||
type="button"
|
||||
class="settlement-card settlement-card--desktop"
|
||||
:disabled="submitting"
|
||||
@click="selectType('cash_check')"
|
||||
>
|
||||
<span class="settlement-card__icon settlement-card__icon--check settlement-card__icon--hybrid">
|
||||
<IconCashSettlement :size="32" />
|
||||
<IconCheckBook :size="32" />
|
||||
</span>
|
||||
<h3>تسویه نقدی-چکی</h3>
|
||||
<p>ابتدا واریز نقدی، سپس ثبت چک برای مابقی تسویه</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -331,6 +377,10 @@ async function selectType(type: 'cash' | 'check') {
|
||||
background: var(--color-primary-light);
|
||||
}
|
||||
|
||||
.settlement-card__icon--hybrid {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.settlement-card h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 1.0625rem;
|
||||
|
||||
@@ -208,7 +208,10 @@ function customerStatusClass(status: CustomerStatus) {
|
||||
@back="router.push('/customers')"
|
||||
>
|
||||
<template v-if="tab === 'info'" #trailing>
|
||||
<button type="button" class="edit-btn" @click="openEdit">ویرایش</button>
|
||||
<button type="button" class="edit-btn edit-btn--compact" aria-label="ویرایش" @click="openEdit">
|
||||
<IconEditPen :size="18" />
|
||||
<span class="edit-btn__label">ویرایش</span>
|
||||
</button>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
@@ -853,6 +856,10 @@ function customerStatusClass(status: CustomerStatus) {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.edit-btn--compact svg {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.meeting-filters {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useMediaQuery } from '@vueuse/core'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useCrmStore } from '@/stores/crm'
|
||||
@@ -24,6 +25,8 @@ import PageSkeleton from '@/components/ui/PageSkeleton.vue'
|
||||
import UserAvatar from '@/components/ui/UserAvatar.vue'
|
||||
import HomeSuggestionsSection from '@/components/home/HomeSuggestionsSection.vue'
|
||||
import { useTouchSwipe } from '@/composables/useTouchSwipe'
|
||||
import { isProductUnavailable } from '@/utils/productAvailability'
|
||||
import { followBannerLink } from '@/utils/bannerLink'
|
||||
|
||||
const router = useRouter()
|
||||
const { goBack } = useGoBack('/')
|
||||
@@ -77,6 +80,7 @@ const suggestionsForHome = computed(() => {
|
||||
colorImages: item.colorImages?.some(Boolean)
|
||||
? item.colorImages
|
||||
: product?.colorImages,
|
||||
productIsActive: item.productIsActive ?? product?.isActive,
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -101,6 +105,8 @@ let bannerTimer: number | undefined
|
||||
|
||||
const banner = computed(() => banners.value[bannerIndex.value] ?? FALLBACK_BANNER)
|
||||
|
||||
const bannerImageLink = computed(() => banner.value.bannerLink?.trim() || '')
|
||||
|
||||
const heroHasCopy = computed(() => {
|
||||
const item = banner.value
|
||||
return (
|
||||
@@ -156,6 +162,12 @@ const { onTouchStart: onHeroTouchStart, onTouchEnd: onHeroTouchEnd } = useTouchS
|
||||
|
||||
const displayFirst = computed(() => auth.user?.firstName || 'کاربر')
|
||||
const displayLast = computed(() => auth.user?.lastName || '')
|
||||
|
||||
const isNarrowHeader = useMediaQuery('(max-width: 467px)')
|
||||
const homeAvatarSize = computed(() => (isNarrowHeader.value ? 36 : 44))
|
||||
const homeGreetingName = computed(() =>
|
||||
isNarrowHeader.value ? displayFirst.value : `${displayFirst.value} ${displayLast.value}`.trim(),
|
||||
)
|
||||
const homeLevel = computed(() => {
|
||||
if (agentLevel.value) return agentLevel.value
|
||||
const u = auth.user
|
||||
@@ -223,6 +235,13 @@ function goBanner() {
|
||||
go(banner.value.ctaLink.trim() || '/categories')
|
||||
}
|
||||
|
||||
function onHeroBannerClick(event: MouseEvent) {
|
||||
if (!bannerImageLink.value) return
|
||||
const target = event.target as HTMLElement
|
||||
if (target.closest('.hero-banner__cta, .hero-banner__dots')) return
|
||||
followBannerLink(bannerImageLink.value, router)
|
||||
}
|
||||
|
||||
async function toggleFavorite(productId: string) {
|
||||
favError.value = null
|
||||
try {
|
||||
@@ -244,11 +263,11 @@ async function toggleFavorite(productId: string) {
|
||||
class="home-header__avatar"
|
||||
:src="auth.user?.avatarUrl"
|
||||
:initials="displayFirst.charAt(0)"
|
||||
:size="44"
|
||||
:size="homeAvatarSize"
|
||||
/>
|
||||
<div>
|
||||
<div class="home-header__text">
|
||||
<p class="home-header__hello">
|
||||
سلام، {{ displayFirst }} {{ displayLast }}
|
||||
سلام، {{ homeGreetingName }}
|
||||
</p>
|
||||
<p class="home-header__welcome">خوش آمدید</p>
|
||||
</div>
|
||||
@@ -279,7 +298,9 @@ async function toggleFavorite(productId: string) {
|
||||
<template v-else>
|
||||
<section
|
||||
class="hero-banner"
|
||||
:class="{ 'hero-banner--linked': !!bannerImageLink }"
|
||||
:aria-label="banner.title"
|
||||
@click="onHeroBannerClick"
|
||||
@touchstart.passive="onHeroTouchStart"
|
||||
@touchend.passive="onHeroTouchEnd"
|
||||
>
|
||||
@@ -306,7 +327,7 @@ async function toggleFavorite(productId: string) {
|
||||
v-if="banner.showCta !== false && banner.ctaLabel"
|
||||
type="button"
|
||||
class="hero-banner__cta"
|
||||
@click="goBanner"
|
||||
@click.stop="goBanner"
|
||||
>
|
||||
{{ banner.ctaLabel }}
|
||||
</button>
|
||||
@@ -322,7 +343,7 @@ async function toggleFavorite(productId: string) {
|
||||
:class="{ 'dot--active': index === bannerIndex }"
|
||||
:aria-label="item.title"
|
||||
:aria-selected="index === bannerIndex"
|
||||
@click="setBanner(index)"
|
||||
@click.stop="setBanner(index)"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
@@ -409,7 +430,11 @@ async function toggleFavorite(productId: string) {
|
||||
:class="`product-card__image--${index % 3}`"
|
||||
:style="item.imageUrl ? { backgroundImage: `url(${item.imageUrl})` } : undefined"
|
||||
>
|
||||
<span v-if="item.isNew" class="product-card__badge">جدید</span>
|
||||
<span
|
||||
v-if="isProductUnavailable(item)"
|
||||
class="product-card__badge product-card__badge--oos"
|
||||
>ناموجود</span>
|
||||
<span v-else-if="item.isNew" class="product-card__badge">جدید</span>
|
||||
</div>
|
||||
<div class="product-card__body">
|
||||
<strong class="product-card__name">{{ item.name }}</strong>
|
||||
@@ -471,10 +496,15 @@ async function toggleFavorite(productId: string) {
|
||||
}
|
||||
|
||||
.home-header__avatar {
|
||||
flex-shrink: 0;
|
||||
background: var(--color-primary-soft);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.home-header__text {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.home-header__hello {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
@@ -518,6 +548,10 @@ async function toggleFavorite(productId: string) {
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.hero-banner--linked {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.hero-banner {
|
||||
--hero-image: #8a7a65;
|
||||
position: relative;
|
||||
@@ -928,6 +962,10 @@ html.dark .quick-card__label {
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.product-card__badge--oos {
|
||||
background: #b91c1c;
|
||||
}
|
||||
|
||||
.product-card__body {
|
||||
position: relative;
|
||||
display: flex;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ref, computed, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useMediaQuery } from '@vueuse/core'
|
||||
import { useOrderStore } from '@/stores/order'
|
||||
import type { Order } from '@/types'
|
||||
import BaseButton from '@/components/ui/BaseButton.vue'
|
||||
import BaseInput from '@/components/ui/BaseInput.vue'
|
||||
import CurrencyInput from '@/components/ui/CurrencyInput.vue'
|
||||
@@ -33,6 +34,13 @@ import OrderTrackingTimeline from '@/components/orders/OrderTrackingTimeline.vue
|
||||
import OrderStatusHero from '@/components/orders/OrderStatusHero.vue'
|
||||
import OrderTrackingSummary from '@/components/orders/OrderTrackingSummary.vue'
|
||||
import { canSubmitCashReceipt, isInvoicePriced, orderItemMeterage } from '@/utils/orderInvoice'
|
||||
import {
|
||||
isInCashPaymentPhase,
|
||||
isInCheckPhase,
|
||||
settlementTypeLabel,
|
||||
usesCashReceiptPhase,
|
||||
usesCheckSettlement,
|
||||
} from '@/utils/settlement'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -152,9 +160,18 @@ const trackingFooterLabel = computed(() =>
|
||||
order.value ? getTrackingFooterLabel(order.value.status) : 'مشاهده جزئیات سفارش',
|
||||
)
|
||||
|
||||
function settlementLabel(type: 'cash' | 'check' | null) {
|
||||
if (!type) return 'در انتظار انتخاب مشتری'
|
||||
return type === 'cash' ? 'تسویه نقدی' : 'تسویه چکی'
|
||||
function settlementLabel(type: Order['settlementType']) {
|
||||
return settlementTypeLabel(type)
|
||||
}
|
||||
|
||||
function showCashTracking(order: Order) {
|
||||
if (!usesCashReceiptPhase(order.settlementType)) return false
|
||||
return isInCashPaymentPhase(order) || !!order.cashReceipt
|
||||
}
|
||||
|
||||
function showCheckTracking(order: Order) {
|
||||
if (!usesCheckSettlement(order.settlementType)) return false
|
||||
return isInCheckPhase(order) || (order.checks?.length ?? 0) > 0
|
||||
}
|
||||
|
||||
type NextAction = { label: string; to?: string; receipt?: boolean }
|
||||
@@ -419,7 +436,7 @@ async function submitReceipt() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="order.settlementType === 'cash'">
|
||||
<template v-if="showCashTracking(order)">
|
||||
<div v-if="order.cashInvoiceExpiresAt" class="tracking-alert">
|
||||
اعتبار فاکتور: {{ formatDateTime(order.cashInvoiceExpiresAt) }}
|
||||
</div>
|
||||
@@ -454,7 +471,7 @@ async function submitReceipt() {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="order.settlementType === 'check' && order.checks">
|
||||
<template v-if="showCheckTracking(order) && order.checks">
|
||||
<div class="tracking-collapse__section">
|
||||
<h3 class="tracking-card__title">رأس چکها</h3>
|
||||
<div class="rass-display">{{ order.checkRassDays }} روز</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useCrmStore } from '@/stores/crm'
|
||||
import type { CatalogProductCard } from '@/types/crm'
|
||||
import { formatNumber } from '@/utils/format'
|
||||
import { colorSwatchStyle, colorVariantThumb, productListThumb } from '@/utils/colorSwatch'
|
||||
import { isProductUnavailable } from '@/utils/productAvailability'
|
||||
import IconBookmark from '@/components/icons/IconBookmark.vue'
|
||||
import IconChevronLeft from '@/components/icons/IconChevronLeft.vue'
|
||||
|
||||
@@ -87,7 +88,8 @@ async function remove(id: string) {
|
||||
<div class="product-item__head">
|
||||
<div class="product-item__title">
|
||||
<strong>{{ item.name }}</strong>
|
||||
<span v-if="item.isNew" class="new-tag">جدید</span>
|
||||
<span v-if="isProductUnavailable(item)" class="new-tag new-tag--oos">ناموجود</span>
|
||||
<span v-else-if="item.isNew" class="new-tag">جدید</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -200,6 +202,11 @@ async function remove(id: string) {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.new-tag--oos {
|
||||
background: rgba(185, 28, 28, 0.12);
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.bookmark {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
|
||||
@@ -27,6 +27,7 @@ import { useCatalogRequest } from '@/composables/useCatalogRequest'
|
||||
import AddressCompletionModal from '@/components/catalogs/AddressCompletionModal.vue'
|
||||
import ProductOrderModal from '@/components/products/ProductOrderModal.vue'
|
||||
import { useTouchSwipe } from '@/composables/useTouchSwipe'
|
||||
import { isProductUnavailable } from '@/utils/productAvailability'
|
||||
|
||||
const USAGE_CHIP_LABEL: Record<string, string> = {
|
||||
suit: 'پارچه کتشلواری',
|
||||
@@ -67,10 +68,9 @@ const {
|
||||
closeAddressModal,
|
||||
} = useCatalogRequest()
|
||||
const rollMeters = computed(() => product.value?.rollMeters ?? 50)
|
||||
const outOfStock = computed(() => {
|
||||
const stock = product.value?.stock
|
||||
return stock != null && stock <= 0
|
||||
})
|
||||
const outOfStock = computed(() =>
|
||||
product.value ? isProductUnavailable(product.value) : false,
|
||||
)
|
||||
const galleryImages = computed(() => {
|
||||
const urls = product.value?.imageUrls ?? []
|
||||
return urls.filter((url) => typeof url === 'string' && url.trim().length > 0)
|
||||
@@ -109,12 +109,8 @@ const minOrderMeters = computed(() => {
|
||||
const minRolls = computed(() =>
|
||||
Math.max(1, Math.ceil(minOrderMeters.value / rollMeters.value)),
|
||||
)
|
||||
const usageLabel = computed(
|
||||
() => USAGE_CHIP_LABEL[product.value?.categoryId ?? ''] || '—',
|
||||
)
|
||||
const seasonLabel = computed(
|
||||
() => SEASON_CHIP_LABEL[product.value?.season ?? ''] || '—',
|
||||
)
|
||||
const productSpecsText = computed(() => product.value?.specs?.trim() ?? '')
|
||||
const hasProductSpecs = computed(() => productSpecsText.value.length > 0)
|
||||
|
||||
const categoryChips = computed(() => {
|
||||
const p = product.value
|
||||
@@ -208,7 +204,7 @@ function toCartProduct(p: CatalogProductCard, colorIndex = selectedColor.value):
|
||||
minMeterage: min,
|
||||
orderMultiplier: step,
|
||||
basePrice: p.basePrice,
|
||||
isActive: true,
|
||||
isActive: p.isActive !== false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -431,7 +427,8 @@ function onAddressSubmit(address: string) {
|
||||
<div class="pd-body">
|
||||
<div class="title-row">
|
||||
<h1>{{ product.name }}</h1>
|
||||
<span v-if="product.isNew" class="new-badge">جدید</span>
|
||||
<span v-if="outOfStock" class="pd-stock-badge">ناموجود</span>
|
||||
<span v-else-if="product.isNew" class="new-badge">جدید</span>
|
||||
</div>
|
||||
<p class="code">کد محصول: {{ product.code }}</p>
|
||||
|
||||
@@ -467,17 +464,17 @@ function onAddressSubmit(address: string) {
|
||||
<strong>{{ product.weaveType || '—' }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="specs-more" @click="showAllSpecs = !showAllSpecs">
|
||||
{{ showAllSpecs ? 'بستن مشخصات' : 'مشاهده همه مشخصات' }}
|
||||
<svg class="specs-more__chevron" :class="{ 'is-open': showAllSpecs }" viewBox="0 0 12 12" aria-hidden="true">
|
||||
<path d="M3 4.5 6 8l3-3.5" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<div v-if="showAllSpecs" class="specs-extra">
|
||||
<p>فصل: {{ seasonLabel }}</p>
|
||||
<p>دسته: {{ usageLabel }}</p>
|
||||
<p>جمع سفارش: {{ formatNumber(orderMeters) }} متر</p>
|
||||
</div>
|
||||
<template v-if="hasProductSpecs">
|
||||
<button type="button" class="specs-more" @click="showAllSpecs = !showAllSpecs">
|
||||
{{ showAllSpecs ? 'بستن مشخصات' : 'مشاهده همه مشخصات' }}
|
||||
<svg class="specs-more__chevron" :class="{ 'is-open': showAllSpecs }" viewBox="0 0 12 12" aria-hidden="true">
|
||||
<path d="M3 4.5 6 8l3-3.5" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<div v-if="showAllSpecs" class="specs-extra">
|
||||
<p class="specs-extra__text">{{ productSpecsText }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<section class="pd-section pd-section--boxed">
|
||||
<div class="section-head">
|
||||
@@ -879,6 +876,11 @@ function onAddressSubmit(address: string) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.specs-extra__text {
|
||||
margin: 0;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.pd-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -16,7 +16,6 @@ const {
|
||||
products,
|
||||
meterages,
|
||||
errors,
|
||||
toast,
|
||||
cartStore,
|
||||
adjustMeterage,
|
||||
setMeterage,
|
||||
@@ -39,7 +38,6 @@ const {
|
||||
:errors="errors"
|
||||
:total-meterage="cartStore.totalMeterage"
|
||||
:item-count="cartStore.itemCount"
|
||||
:toast="toast"
|
||||
@back="goBack"
|
||||
@update:color-filter="colorFilter = $event"
|
||||
@adjust="adjustMeterage"
|
||||
@@ -62,7 +60,6 @@ const {
|
||||
:item-count="cartStore.itemCount"
|
||||
:total-meterage="cartStore.totalMeterage"
|
||||
:remaining-capacity="cartStore.remainingCapacity"
|
||||
:toast="toast"
|
||||
@update:color-filter="colorFilter = $event"
|
||||
@adjust="adjustMeterage"
|
||||
@set-meterage="setMeterage"
|
||||
|
||||
@@ -18,6 +18,8 @@ import { colorSwatchStyle, colorVariantThumb } from '@/utils/colorSwatch'
|
||||
import { categoryIconOf, usageCategoryIconKey } from '@/utils/categoryIcons'
|
||||
import { useSlotBanners } from '@/composables/useSlotBanners'
|
||||
import { useTouchSwipe } from '@/composables/useTouchSwipe'
|
||||
import { isProductUnavailable } from '@/utils/productAvailability'
|
||||
import { followBannerLink } from '@/utils/bannerLink'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -108,11 +110,16 @@ function seasonIconKey(item: SeasonCategory) {
|
||||
function goBannerCta() {
|
||||
const link = bannerCopy.value.ctaLink?.trim()
|
||||
if (!link) return
|
||||
if (/^https?:\/\//i.test(link)) {
|
||||
window.location.assign(link)
|
||||
return
|
||||
}
|
||||
router.push(link)
|
||||
followBannerLink(link, router)
|
||||
}
|
||||
|
||||
const bannerImageLink = computed(() => productsHero.value?.bannerLink?.trim() || '')
|
||||
|
||||
function onCatBannerClick(event: MouseEvent) {
|
||||
if (!bannerImageLink.value) return
|
||||
const target = event.target as HTMLElement
|
||||
if (target.closest('.cat-banner__cta, .cat-banner__dots')) return
|
||||
followBannerLink(bannerImageLink.value, router)
|
||||
}
|
||||
|
||||
const bannerCopy = computed(() => {
|
||||
@@ -494,8 +501,12 @@ onUnmounted(() => {
|
||||
<template v-else>
|
||||
<section
|
||||
class="cat-banner"
|
||||
:class="{ 'cat-banner--photo': !!bannerImageUrl }"
|
||||
:class="{
|
||||
'cat-banner--photo': !!bannerImageUrl,
|
||||
'cat-banner--linked': !!bannerImageLink,
|
||||
}"
|
||||
:style="bannerStyle"
|
||||
@click="onCatBannerClick"
|
||||
@touchstart.passive="onBannerTouchStart"
|
||||
@touchend.passive="onBannerTouchEnd"
|
||||
>
|
||||
@@ -513,7 +524,7 @@ onUnmounted(() => {
|
||||
v-if="bannerCopy.ctaLabel"
|
||||
type="button"
|
||||
class="cat-banner__cta"
|
||||
@click="goBannerCta"
|
||||
@click.stop="goBannerCta"
|
||||
>
|
||||
{{ bannerCopy.ctaLabel }}
|
||||
</button>
|
||||
@@ -539,7 +550,7 @@ onUnmounted(() => {
|
||||
:class="{ 'cat-banner__dot--active': i === productsHeroIndex }"
|
||||
:aria-label="item.title"
|
||||
:aria-selected="i === productsHeroIndex"
|
||||
@click="setProductsHeroSlide(i)"
|
||||
@click.stop="setProductsHeroSlide(i)"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
@@ -637,7 +648,8 @@ onUnmounted(() => {
|
||||
<div class="product-item__head">
|
||||
<div class="product-item__title">
|
||||
<strong>{{ item.name }}</strong>
|
||||
<span v-if="item.isNew" class="new-tag">جدید</span>
|
||||
<span v-if="isProductUnavailable(item)" class="new-tag new-tag--oos">ناموجود</span>
|
||||
<span v-else-if="item.isNew" class="new-tag">جدید</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -863,6 +875,10 @@ onUnmounted(() => {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.cat-banner--linked {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cat-banner {
|
||||
position: relative;
|
||||
touch-action: pan-y;
|
||||
@@ -1173,6 +1189,11 @@ onUnmounted(() => {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.new-tag--oos {
|
||||
background: rgba(185, 28, 28, 0.12);
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.bookmark {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
|
||||
@@ -18,7 +18,6 @@ defineProps<{
|
||||
itemCount: number
|
||||
totalMeterage: number
|
||||
remainingCapacity: number
|
||||
toast: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -69,10 +68,12 @@ const emit = defineEmits<{
|
||||
<div class="product-desktop-card__head">
|
||||
<h3>{{ product.name }}</h3>
|
||||
<span>کد: {{ product.code }}</span>
|
||||
<span v-if="!product.isActive" class="product-oos">ناموجود</span>
|
||||
</div>
|
||||
<p class="product-desktop-card__meta">
|
||||
حداقل سفارش: {{ formatMeterage(product.minMeterage) }}
|
||||
</p>
|
||||
<template v-if="product.isActive">
|
||||
<div class="meterage-input-row">
|
||||
<button
|
||||
type="button"
|
||||
@@ -110,6 +111,7 @@ const emit = defineEmits<{
|
||||
<IconCart :size="16" />
|
||||
افزودن
|
||||
</BaseButton>
|
||||
</template>
|
||||
<p v-if="errors[product.id]" class="error-text">{{ errors[product.id] }}</p>
|
||||
</div>
|
||||
</article>
|
||||
@@ -145,7 +147,6 @@ const emit = defineEmits<{
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div v-if="toast" class="product-desktop-toast">{{ toast }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -254,6 +255,11 @@ const emit = defineEmits<{
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.product-oos {
|
||||
color: #b91c1c;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.product-desktop-card__meta {
|
||||
margin: 0 0 10px;
|
||||
font-size: 0.8125rem;
|
||||
@@ -299,20 +305,6 @@ const emit = defineEmits<{
|
||||
margin: 6px 0 0;
|
||||
}
|
||||
|
||||
.product-desktop-toast {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--color-text);
|
||||
color: var(--color-bg);
|
||||
padding: 10px 20px;
|
||||
border-radius: var(--radius-sm);
|
||||
z-index: 150;
|
||||
box-shadow: var(--shadow-md);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.desktop-summary-panel__action :deep(.btn) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -17,7 +17,6 @@ defineProps<{
|
||||
errors: Record<string, string>
|
||||
totalMeterage: number
|
||||
itemCount: number
|
||||
toast: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -59,9 +58,10 @@ const emit = defineEmits<{
|
||||
<div class="product-mobile-card__head">
|
||||
<h3>{{ product.name }}</h3>
|
||||
<span>کد: {{ product.code }}</span>
|
||||
<span v-if="!product.isActive" class="product-oos">ناموجود</span>
|
||||
</div>
|
||||
<p class="product-mobile-card__meta">حداقل سفارش: {{ formatMeterage(product.minMeterage) }}</p>
|
||||
<div class="meterage-input-row">
|
||||
<div v-if="product.isActive" class="meterage-input-row">
|
||||
<button
|
||||
type="button"
|
||||
class="qty-btn"
|
||||
@@ -94,7 +94,7 @@ const emit = defineEmits<{
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<BaseButton block @click="emit('add', product.id)">افزودن</BaseButton>
|
||||
<BaseButton v-if="product.isActive" block @click="emit('add', product.id)">افزودن</BaseButton>
|
||||
<p v-if="errors[product.id]" class="error-text">{{ errors[product.id] }}</p>
|
||||
</article>
|
||||
</div>
|
||||
@@ -106,8 +106,6 @@ const emit = defineEmits<{
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="toast" class="toast">{{ toast }}</div>
|
||||
|
||||
<div v-if="itemCount > 0" class="sticky-footer">
|
||||
<CartMeterageFooter
|
||||
:total-meterage="totalMeterage"
|
||||
@@ -186,6 +184,11 @@ const emit = defineEmits<{
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.product-oos {
|
||||
color: #b91c1c !important;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.product-mobile-card__meta {
|
||||
margin: 0 0 12px;
|
||||
font-size: 0.8125rem;
|
||||
@@ -225,19 +228,4 @@ const emit = defineEmits<{
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: calc(var(--nav-height) + 72px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--color-text);
|
||||
color: var(--color-bg);
|
||||
padding: 10px 20px;
|
||||
border-radius: var(--radius-sm);
|
||||
z-index: 150;
|
||||
box-shadow: var(--shadow-md);
|
||||
font-size: 0.875rem;
|
||||
white-space: nowrap;
|
||||
max-width: calc(100% - 32px);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -262,7 +262,7 @@ async function save() {
|
||||
|
||||
<div class="profile-edit__actions">
|
||||
<BaseButton class="profile-edit__save" :loading="saving" @click="save">
|
||||
ذخیره تغییرات
|
||||
ثبت تغییرات
|
||||
</BaseButton>
|
||||
<p v-if="saved" class="profile-edit__saved">ذخیره شد ✓</p>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user