Initial admin edits

This commit is contained in:
2026-10-01 20:51:37 +03:30
parent adb0ce886f
commit 9c35450b31
183 changed files with 61993 additions and 350 deletions
+2
View File
@@ -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()
}
})
+123
View File
@@ -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;
}
}
+23 -9
View File
@@ -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>
+17 -8
View File
@@ -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>
+11 -1
View File
@@ -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>
+335
View File
@@ -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>
+38 -7
View File
@@ -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
View File
@@ -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)
},
})
}
}
+73 -16
View File
@@ -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 }
}
+8 -6
View File
@@ -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,
+40 -8
View File
@@ -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
View File
@@ -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>
+1
View File
@@ -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()
+3 -1
View File
@@ -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
+12
View File
@@ -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 })
}
+1
View File
@@ -15,6 +15,7 @@ export type ProductSuggestionDto = {
widthCm?: number
composition?: string
isNew?: boolean
productIsActive?: boolean
note?: string
createdAt: string
}
+6
View File
@@ -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
View File
@@ -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,
+6
View File
@@ -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
View File
@@ -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) {
+3
View File
@@ -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
View File
@@ -1,4 +1,4 @@
export type SettlementType = 'cash' | 'check'
export type SettlementType = 'cash' | 'check' | 'cash_check'
export type CheckRegistrationMode = 'full' | 'brief'
export type OrderStatus =
+23
View File
@@ -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)
}
+3
View File
@@ -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> = {
+3 -1
View File
@@ -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 {
+7
View File
@@ -159,6 +159,13 @@ function paymentStepLabels(settlementType: SettlementType | null | undefined): {
approved: 'چک‌ها تأیید شده',
}
}
if (settlementType === 'cash_check') {
return {
payment: 'ثبت رسید نقدی',
review: 'بررسی چک‌ها',
approved: 'چک‌ها تأیید شده',
}
}
return {
payment: 'ثبت پرداخت / چک',
review: 'بررسی پرداخت / چک',
+8
View File
@@ -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
}
+49
View File
@@ -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()
}
+51
View File
@@ -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)
}
+86
View File
@@ -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
}
+29 -24
View File
@@ -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) {
+11 -7
View File
@@ -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, '')
+5 -1
View File
@@ -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 ?? [])
})
+53 -3
View File
@@ -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;
+8 -1
View File
@@ -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;
+44 -6
View File
@@ -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;
+22 -5
View File
@@ -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 -1
View File
@@ -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;
+25 -23
View File
@@ -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;
-3
View File
@@ -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"
+30 -9
View File
@@ -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>
+1 -1
View File
@@ -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>