7/7/405
This commit is contained in:
@@ -5,6 +5,7 @@ import { useIntervalFn } from '@vueuse/core'
|
||||
import { clamp } from 'lodash-es'
|
||||
import DefaultLayout from '@/layouts/DefaultLayout.vue'
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog.vue'
|
||||
import AppToast from '@/components/ui/AppToast.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useCartStore } from '@/stores/cart'
|
||||
import { useOrderStore } from '@/stores/order'
|
||||
@@ -69,4 +70,5 @@ useIntervalFn(
|
||||
</RouterView>
|
||||
</DefaultLayout>
|
||||
<ConfirmDialog />
|
||||
<AppToast />
|
||||
</template>
|
||||
|
||||
@@ -192,11 +192,11 @@
|
||||
|
||||
.desktop-settlement-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(280px, 380px));
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 20px;
|
||||
justify-content: center;
|
||||
max-width: 820px;
|
||||
margin-inline: auto;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
margin-inline: 0;
|
||||
}
|
||||
|
||||
.desktop-company-grid {
|
||||
|
||||
@@ -183,8 +183,8 @@ select {
|
||||
|
||||
.persian-date-input__trigger,
|
||||
.time-input__trigger,
|
||||
.search-field__input,
|
||||
.field-select__trigger {
|
||||
.app-select__trigger,
|
||||
.search-field__input {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
|
||||
@@ -235,7 +235,6 @@
|
||||
/* Checkout forms */
|
||||
.checkout-page__scroll,
|
||||
.cash-page__scroll,
|
||||
.settlement-page__scroll,
|
||||
.check-mode-page__scroll {
|
||||
max-width: var(--form-max-width);
|
||||
margin-inline: auto;
|
||||
|
||||
@@ -0,0 +1,507 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import type { ProductSuggestionDto } from '@/services/suggestionsApi'
|
||||
import type { CatalogProductCard } from '@/types/crm'
|
||||
import { useFavoritesStore } from '@/stores/favorites'
|
||||
import { useCatalogRequest } from '@/composables/useCatalogRequest'
|
||||
import { formatNumber } from '@/utils/format'
|
||||
import { colorSwatchStyle } from '@/utils/colorSwatch'
|
||||
import IconChevronLeft from '@/components/icons/IconChevronLeft.vue'
|
||||
import IconBookmark from '@/components/icons/IconBookmark.vue'
|
||||
import AddressCompletionModal from '@/components/catalogs/AddressCompletionModal.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
items: ProductSuggestionDto[]
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
const favorites = useFavoritesStore()
|
||||
const {
|
||||
requesting,
|
||||
addressModalOpen,
|
||||
addressError,
|
||||
requestCatalog,
|
||||
submitAddressAndRequest,
|
||||
closeAddressModal,
|
||||
} = useCatalogRequest()
|
||||
|
||||
const visibleSwatches = 4
|
||||
|
||||
function itemSubtitle(item: ProductSuggestionDto) {
|
||||
return item.note?.trim() || item.subtitle?.trim() || 'مشابه خریدهای قبلی شما'
|
||||
}
|
||||
|
||||
function extraColorCount(item: ProductSuggestionDto) {
|
||||
const total = item.colors?.length ?? 0
|
||||
return total > visibleSwatches ? total - visibleSwatches : 0
|
||||
}
|
||||
|
||||
function toProductCard(item: ProductSuggestionDto): CatalogProductCard {
|
||||
return {
|
||||
id: item.productId,
|
||||
name: item.productName,
|
||||
code: item.productCode,
|
||||
categoryId: '',
|
||||
season: 'all_season',
|
||||
widthCm: item.widthCm ?? 0,
|
||||
composition: item.composition ?? '',
|
||||
colors: item.colors ?? (item.color ? [item.color] : []),
|
||||
colorImages: item.colorImages,
|
||||
tags: [],
|
||||
isNew: Boolean(item.isNew),
|
||||
imageUrl: item.imageUrl,
|
||||
subtitle: item.subtitle,
|
||||
rollMeters: 1,
|
||||
catalogName: item.catalogName,
|
||||
catalogCode: item.catalogCode,
|
||||
color: item.color,
|
||||
}
|
||||
}
|
||||
|
||||
function openProduct(item: ProductSuggestionDto) {
|
||||
router.push({ path: '/product', query: { id: item.productId } })
|
||||
}
|
||||
|
||||
async function toggleFavorite(productId: string) {
|
||||
await favorites.toggle(productId)
|
||||
}
|
||||
|
||||
async function requestSwatch(item: ProductSuggestionDto) {
|
||||
await requestCatalog(toProductCard(item))
|
||||
}
|
||||
|
||||
const hasItems = computed(() => props.items.length > 0)
|
||||
|
||||
function compositionText(item: ProductSuggestionDto) {
|
||||
const value = item.composition?.trim()
|
||||
return value || '—'
|
||||
}
|
||||
|
||||
function widthText(item: ProductSuggestionDto) {
|
||||
if (item.widthCm) return `${formatNumber(item.widthCm)} cm`
|
||||
return '—'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="suggest-panel" aria-label="پیشنهاد اختصاصی">
|
||||
<header class="suggest-panel__head">
|
||||
<div class="suggest-panel__titles">
|
||||
<h3 class="suggest-panel__title">
|
||||
<span class="suggest-panel__sparkles" aria-hidden="true">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M12 2l1.2 4.2L17.5 8 13.2 9.2 12 13.5 10.8 9.2 6.5 8l4.3-1.8L12 2Z"
|
||||
fill="#c9a227"
|
||||
/>
|
||||
<path
|
||||
d="M19 14l.7 2.4 2.4.7-2.4.7-.7 2.4-.7-2.4-2.4-.7 2.4-.7.7-2.4Z"
|
||||
fill="#d4af37"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
پیشنهادهای اختصاصی برای شما
|
||||
</h3>
|
||||
<p class="suggest-panel__sub">بر اساس خریدهای قبلی و علاقهمندیهای شما</p>
|
||||
</div>
|
||||
<button type="button" class="suggest-panel__all" @click="router.push('/products')">
|
||||
مشاهده همه
|
||||
<IconChevronLeft :size="14" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<p v-if="!hasItems" class="suggest-panel__empty">
|
||||
هنوز پیشنهاد اختصاصی برای شما ثبت نشده است.
|
||||
</p>
|
||||
|
||||
<div v-else class="suggest-panel__row scroll-x">
|
||||
<article
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
class="suggest-product"
|
||||
@click="openProduct(item)"
|
||||
>
|
||||
<div
|
||||
class="suggest-product__media"
|
||||
:style="item.imageUrl ? { backgroundImage: `url(${item.imageUrl})` } : undefined"
|
||||
>
|
||||
<span class="suggest-product__chip">کالیته پیشنهادی</span>
|
||||
<button
|
||||
type="button"
|
||||
class="suggest-product__save"
|
||||
:aria-label="
|
||||
favorites.isFavorite(item.productId)
|
||||
? 'حذف از علاقهمندیها'
|
||||
: 'افزودن به علاقهمندیها'
|
||||
"
|
||||
:aria-pressed="favorites.isFavorite(item.productId)"
|
||||
@click.stop="toggleFavorite(item.productId)"
|
||||
>
|
||||
<IconBookmark
|
||||
:size="18"
|
||||
:filled="favorites.isFavorite(item.productId)"
|
||||
:color="favorites.isFavorite(item.productId) ? 'var(--color-primary)' : '#1f2937'"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="suggest-product__body">
|
||||
<h4 class="suggest-product__name">{{ item.productName }}</h4>
|
||||
<p class="suggest-product__desc">{{ itemSubtitle(item) }}</p>
|
||||
|
||||
<div v-if="item.colors?.length" class="suggest-product__swatches">
|
||||
<span
|
||||
v-for="(color, idx) in item.colors.slice(0, visibleSwatches)"
|
||||
:key="`${item.id}-${idx}`"
|
||||
class="suggest-product__swatch"
|
||||
:style="colorSwatchStyle(color, item.colorImages?.[idx])"
|
||||
/>
|
||||
<span v-if="extraColorCount(item)" class="suggest-product__swatch suggest-product__swatch--more">
|
||||
+{{ formatNumber(extraColorCount(item)) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="suggest-product__specs">
|
||||
<div class="suggest-product__spec">
|
||||
<span class="suggest-product__spec-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M12 4 20 8v8l-8 4-8-4V8l8-4Z" stroke-linejoin="round" />
|
||||
<path d="M4 10l8 4 8-4" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="suggest-product__spec-copy">
|
||||
<span class="suggest-product__spec-key">جنس</span>
|
||||
<span class="suggest-product__spec-val">{{ compositionText(item) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="suggest-product__spec-divider" aria-hidden="true" />
|
||||
<div class="suggest-product__spec">
|
||||
<span class="suggest-product__spec-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M6 4h12v16H6z" stroke-linejoin="round" />
|
||||
<path d="M9 8h6M9 12h4" stroke-linecap="round" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="suggest-product__spec-copy">
|
||||
<span class="suggest-product__spec-key">عرض</span>
|
||||
<span class="suggest-product__spec-val">{{ widthText(item) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="suggest-product__cta"
|
||||
:disabled="requesting"
|
||||
@click.stop="requestSwatch(item)"
|
||||
>
|
||||
درخواست کالیته
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<AddressCompletionModal
|
||||
:open="addressModalOpen"
|
||||
:error="addressError"
|
||||
:loading="requesting"
|
||||
@close="closeAddressModal()"
|
||||
@submit="submitAddressAndRequest"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.suggest-panel {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 20px;
|
||||
padding: 18px 16px 16px;
|
||||
box-shadow: 0 8px 28px rgba(16, 24, 40, 0.06);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.suggest-panel__head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.suggest-panel__titles {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.suggest-panel__title {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 1rem;
|
||||
font-weight: 800;
|
||||
line-height: 1.45;
|
||||
color: var(--color-accent-navy);
|
||||
}
|
||||
|
||||
.suggest-panel__sparkles {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.suggest-panel__sub {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.suggest-panel__all {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 8px 14px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: #eef1f4;
|
||||
color: var(--color-text-muted);
|
||||
font: inherit;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.suggest-panel__empty {
|
||||
margin: 0;
|
||||
padding: 20px 8px;
|
||||
text-align: center;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.suggest-panel__row {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
margin-inline: -8px;
|
||||
padding-inline: 8px;
|
||||
padding-bottom: 4px;
|
||||
scroll-snap-type: x proximity;
|
||||
scroll-padding-inline: 8px;
|
||||
}
|
||||
|
||||
.suggest-product {
|
||||
flex: 0 0 min(88vw, 300px);
|
||||
width: min(88vw, 300px);
|
||||
scroll-snap-align: start;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 16px;
|
||||
background: var(--color-surface);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 10px rgba(16, 24, 40, 0.04);
|
||||
}
|
||||
|
||||
.suggest-product__media {
|
||||
position: relative;
|
||||
height: 148px;
|
||||
background: linear-gradient(145deg, #e8ecf0, #d5dbe3);
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
.suggest-product__chip {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
inset-inline-start: 10px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--color-accent-navy);
|
||||
color: #fff;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.suggest-product__save {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
inset-inline-end: 10px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.suggest-product__body {
|
||||
padding: 12px 12px 14px;
|
||||
}
|
||||
|
||||
.suggest-product__name {
|
||||
margin: 0 0 4px;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 800;
|
||||
line-height: 1.4;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.suggest-product__desc {
|
||||
margin: 0 0 10px;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.45;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.suggest-product__swatches {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.suggest-product__swatch {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
flex-shrink: 0;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
.suggest-product__swatch--more {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #e8edf2;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.58rem;
|
||||
font-weight: 700;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.suggest-product__specs {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
padding: 8px 0;
|
||||
border-block: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.suggest-product__spec {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
font-size: 0.68rem;
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.suggest-product__spec-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.suggest-product__spec-key {
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.suggest-product__spec-val {
|
||||
font-size: 0.68rem;
|
||||
line-height: 1.35;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.suggest-product__spec-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
color: #9aa3ad;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.suggest-product__spec-icon svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.suggest-product__spec-divider {
|
||||
width: 1px;
|
||||
height: 22px;
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
.suggest-product__cta {
|
||||
width: 100%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 11px 12px;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
background: #eef1f4;
|
||||
color: var(--color-accent-navy);
|
||||
font: inherit;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.suggest-product__cta:disabled {
|
||||
opacity: 0.65;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
html.dark .suggest-panel__title {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
html.dark .suggest-product__cta,
|
||||
html.dark .suggest-panel__all {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #e8edf2;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.suggest-panel {
|
||||
padding: 22px 22px 20px;
|
||||
border-radius: 22px;
|
||||
}
|
||||
|
||||
.suggest-panel__title {
|
||||
font-size: 1.08rem;
|
||||
}
|
||||
|
||||
.suggest-panel__row {
|
||||
margin-inline: -12px;
|
||||
padding-inline: 12px;
|
||||
gap: 18px;
|
||||
scroll-padding-inline: 12px;
|
||||
}
|
||||
|
||||
.suggest-product {
|
||||
flex: 0 0 min(42%, 320px);
|
||||
width: min(42%, 320px);
|
||||
}
|
||||
|
||||
.suggest-product__media {
|
||||
height: 168px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
size?: number
|
||||
color?: string
|
||||
strokeWidth?: number
|
||||
}>(),
|
||||
{ size: 22, color: 'currentColor', strokeWidth: 1.6 },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
:width="size"
|
||||
:height="size"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
:stroke="color"
|
||||
:stroke-width="strokeWidth"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M12 3.5 20 8v8l-8 4.5L4 16V8l8-4.5Z" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,413 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
|
||||
import { onClickOutside } from '@vueuse/core'
|
||||
|
||||
export type AppSelectOption = {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: string
|
||||
options: AppSelectOption[]
|
||||
label?: string
|
||||
placeholder?: string
|
||||
hint?: string
|
||||
error?: string
|
||||
ariaLabel?: string
|
||||
block?: boolean
|
||||
searchable?: boolean
|
||||
searchPlaceholder?: string
|
||||
disabled?: boolean
|
||||
}>(),
|
||||
{
|
||||
placeholder: 'انتخاب کنید',
|
||||
ariaLabel: 'انتخاب',
|
||||
block: true,
|
||||
searchable: false,
|
||||
searchPlaceholder: 'جستجو…',
|
||||
disabled: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const open = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const rootRef = ref<HTMLElement | null>(null)
|
||||
const menuRef = ref<HTMLElement | null>(null)
|
||||
const searchInputRef = ref<HTMLInputElement | null>(null)
|
||||
const menuStyle = ref<Record<string, string>>({})
|
||||
|
||||
onClickOutside(
|
||||
rootRef,
|
||||
() => {
|
||||
open.value = false
|
||||
searchQuery.value = ''
|
||||
},
|
||||
{ ignore: [menuRef] },
|
||||
)
|
||||
|
||||
const selectedOption = computed(() =>
|
||||
props.options.find((option) => option.value === props.modelValue),
|
||||
)
|
||||
|
||||
const displayLabel = computed(() => selectedOption.value?.label ?? props.placeholder)
|
||||
|
||||
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))
|
||||
})
|
||||
|
||||
function updateMenuPosition() {
|
||||
const el = rootRef.value
|
||||
if (!el) return
|
||||
const rect = el.getBoundingClientRect()
|
||||
const gap = 6
|
||||
const maxMenu = Math.min(320, window.innerHeight * 0.5)
|
||||
const spaceBelow = window.innerHeight - rect.bottom - gap
|
||||
const spaceAbove = rect.top - gap
|
||||
const openUp = spaceBelow < Math.min(160, maxMenu) && spaceAbove > spaceBelow
|
||||
const maxHeight = Math.max(120, Math.min(maxMenu, openUp ? spaceAbove : spaceBelow))
|
||||
|
||||
menuStyle.value = {
|
||||
position: 'fixed',
|
||||
left: `${Math.max(8, Math.min(rect.left, window.innerWidth - Math.max(rect.width, 220) - 8))}px`,
|
||||
width: `${Math.max(rect.width, 220)}px`,
|
||||
zIndex: '1280',
|
||||
maxHeight: `${maxHeight}px`,
|
||||
...(openUp
|
||||
? {
|
||||
top: 'auto',
|
||||
bottom: `${window.innerHeight - rect.top + gap}px`,
|
||||
}
|
||||
: {
|
||||
top: `${rect.bottom + gap}px`,
|
||||
bottom: 'auto',
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
function bindPositionListeners(active: boolean) {
|
||||
if (active) {
|
||||
window.addEventListener('resize', updateMenuPosition)
|
||||
window.addEventListener('scroll', updateMenuPosition, true)
|
||||
} else {
|
||||
window.removeEventListener('resize', updateMenuPosition)
|
||||
window.removeEventListener('scroll', updateMenuPosition, true)
|
||||
}
|
||||
}
|
||||
|
||||
async function toggle() {
|
||||
if (props.disabled) return
|
||||
open.value = !open.value
|
||||
if (open.value) {
|
||||
await nextTick()
|
||||
updateMenuPosition()
|
||||
if (props.searchable) {
|
||||
searchQuery.value = ''
|
||||
await nextTick()
|
||||
searchInputRef.value?.focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function pick(value: string) {
|
||||
emit('update:modelValue', value)
|
||||
open.value = false
|
||||
searchQuery.value = ''
|
||||
}
|
||||
|
||||
watch(open, (isOpen) => {
|
||||
bindPositionListeners(isOpen)
|
||||
if (isOpen) {
|
||||
void nextTick(() => updateMenuPosition())
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
bindPositionListeners(false)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="rootRef"
|
||||
class="form-group app-select"
|
||||
:class="{
|
||||
'app-select--open': open,
|
||||
'app-select--block': block,
|
||||
'app-select--disabled': disabled,
|
||||
}"
|
||||
>
|
||||
<label v-if="label" class="form-label">{{ label }}</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="app-select__trigger"
|
||||
:class="{ 'app-select__trigger--error': error, 'is-open': open }"
|
||||
:aria-label="ariaLabel"
|
||||
:aria-expanded="open"
|
||||
aria-haspopup="listbox"
|
||||
:disabled="disabled"
|
||||
@click="toggle"
|
||||
>
|
||||
<span
|
||||
class="app-select__value"
|
||||
:class="{ 'app-select__value--placeholder': showPlaceholderStyle }"
|
||||
>
|
||||
{{ displayLabel }}
|
||||
</span>
|
||||
<span class="app-select__chevron" aria-hidden="true">
|
||||
<svg viewBox="0 0 12 12" width="12" height="12">
|
||||
<path
|
||||
d="M2.5 4.5 6 8l3.5-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<Teleport to="body">
|
||||
<ul
|
||||
v-if="open"
|
||||
ref="menuRef"
|
||||
class="app-select__menu"
|
||||
role="listbox"
|
||||
:aria-label="ariaLabel"
|
||||
:style="menuStyle"
|
||||
>
|
||||
<li v-if="searchable" class="app-select__search" role="presentation">
|
||||
<input
|
||||
ref="searchInputRef"
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
class="app-select__search-input"
|
||||
:placeholder="searchPlaceholder"
|
||||
@click.stop
|
||||
@keydown.esc.stop="open = false"
|
||||
/>
|
||||
</li>
|
||||
<li v-if="!filteredOptions.length" class="app-select__empty" role="presentation">
|
||||
موردی یافت نشد
|
||||
</li>
|
||||
<li v-for="option in filteredOptions" :key="option.value || '__empty__'" role="presentation">
|
||||
<button
|
||||
type="button"
|
||||
class="app-select__option"
|
||||
:class="{ 'app-select__option--active': option.value === modelValue }"
|
||||
role="option"
|
||||
:aria-selected="option.value === modelValue"
|
||||
@click="pick(option.value)"
|
||||
>
|
||||
<span class="app-select__option-label">{{ option.label }}</span>
|
||||
<svg
|
||||
v-if="option.value === modelValue"
|
||||
class="app-select__check"
|
||||
viewBox="0 0 24 24"
|
||||
width="16"
|
||||
height="16"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M5 12.5 9.5 17 19 7"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</Teleport>
|
||||
|
||||
<p v-if="error" class="input-error-text">{{ error }}</p>
|
||||
<p v-else-if="hint" class="form-hint">{{ hint }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-select {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.app-select--block {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.app-select__trigger {
|
||||
width: 100%;
|
||||
min-height: var(--btn-height);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font-family: inherit;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
text-align: start;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
box-shadow 0.15s,
|
||||
background 0.15s;
|
||||
}
|
||||
|
||||
.app-select__trigger:hover:not(:disabled) {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.app-select--open .app-select__trigger,
|
||||
.app-select__trigger:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px var(--color-primary-soft, rgba(59, 130, 246, 0.12));
|
||||
}
|
||||
|
||||
.app-select__trigger--error {
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
|
||||
.app-select__trigger:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.app-select__value {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.app-select__value--placeholder {
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.app-select__chevron {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
color: var(--color-text-muted);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.app-select--open .app-select__chevron {
|
||||
transform: rotate(180deg);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.app-select__menu {
|
||||
margin: 0;
|
||||
padding: 8px;
|
||||
list-style: none;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.14);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
overflow-y: auto;
|
||||
box-sizing: border-box;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.app-select__search {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: var(--color-surface);
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.app-select__search-input {
|
||||
width: 100%;
|
||||
height: 38px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 10px;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font: inherit;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.app-select__search-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.app-select__empty {
|
||||
padding: 12px;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.app-select__option {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 11px 12px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font-family: inherit;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
text-align: start;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.12s,
|
||||
color 0.12s;
|
||||
}
|
||||
|
||||
.app-select__option:hover {
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.app-select__option--active {
|
||||
background: var(--color-primary-soft, rgba(59, 130, 246, 0.1));
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.app-select__option-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.app-select__check {
|
||||
flex-shrink: 0;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.input-error-text {
|
||||
color: var(--color-danger);
|
||||
font-size: 0.8125rem;
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,106 @@
|
||||
<script setup lang="ts">
|
||||
import { useAppToast } from '@/composables/useAppToast'
|
||||
|
||||
const { message, tone, clear } = useAppToast()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="app-toast">
|
||||
<div
|
||||
v-if="message"
|
||||
class="app-toast"
|
||||
:class="tone === 'error' ? 'app-toast--error' : 'app-toast--success'"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<p class="app-toast__text">{{ message }}</p>
|
||||
<button type="button" class="app-toast__close" aria-label="بستن" @click="clear">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-toast {
|
||||
position: fixed;
|
||||
top: calc(12px + env(safe-area-inset-top, 0px));
|
||||
inset-inline: var(--page-padding);
|
||||
z-index: 1200;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.app-toast--success {
|
||||
background: var(--color-success-light);
|
||||
border: 1px solid color-mix(in srgb, var(--color-success) 35%, transparent);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.app-toast--error {
|
||||
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;
|
||||
}
|
||||
|
||||
.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;
|
||||
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;
|
||||
}
|
||||
|
||||
.app-toast-enter-from,
|
||||
.app-toast-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
|
||||
@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));
|
||||
}
|
||||
|
||||
.app-toast-enter-from,
|
||||
.app-toast-leave-to {
|
||||
transform: translate(-50%, -8px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -7,7 +7,12 @@ const props = defineProps<{
|
||||
timestamps?: Record<string, string | null | undefined>
|
||||
}>()
|
||||
|
||||
function stepState(index: number): 'done' | 'current' | 'pending' {
|
||||
function stepState(index: number): 'done' | 'current' | 'pending' | 'skipped' {
|
||||
// If current step is 'cancelled', mark previous steps as 'skipped'
|
||||
const currentStep = props.steps[props.currentIndex]
|
||||
const isCancelledFlow = currentStep?.id === 'cancelled'
|
||||
if (isCancelledFlow && index < props.currentIndex) return 'skipped'
|
||||
|
||||
if (index < props.currentIndex) return 'done'
|
||||
if (index === props.currentIndex) return 'current'
|
||||
return 'pending'
|
||||
@@ -29,7 +34,24 @@ function timestampFor(stepId: string) {
|
||||
<div class="tracking-timeline__marker-wrap">
|
||||
<div class="tracking-timeline__line" aria-hidden="true" />
|
||||
<div class="tracking-timeline__marker">
|
||||
<span v-if="stepState(index) === 'done'" class="tracking-timeline__check">✓</span>
|
||||
<svg
|
||||
v-if="stepState(index) === 'done'"
|
||||
viewBox="0 0 24 24"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
class="tracking-timeline__icon tracking-timeline__icon--check"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M20 6L9 17l-5-5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<svg
|
||||
v-else-if="stepState(index) === 'skipped'"
|
||||
viewBox="0 0 24 24"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
class="tracking-timeline__icon tracking-timeline__icon--cross"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M6 6l12 12M18 6L6 18" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -104,6 +126,8 @@ function timestampFor(stepId: string) {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
/* ensure padding/border are included in width/height so absolute centering is stable */
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.tracking-timeline__item--done .tracking-timeline__marker {
|
||||
@@ -122,13 +146,32 @@ function timestampFor(stepId: string) {
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
.tracking-timeline__check {
|
||||
color: var(--color-surface);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
/* New: skipped state (show cross, use danger color) */
|
||||
.tracking-timeline__item--skipped .tracking-timeline__marker {
|
||||
background: var(--color-surface);
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
/* remove old text-glyph selectors; SVGs are used and colored via .tracking-timeline__icon--* */
|
||||
|
||||
/* SVG sizing inside the marker */
|
||||
.tracking-timeline__icon {
|
||||
display: block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
/* center via parent's flexbox */
|
||||
position: static;
|
||||
margin: auto;
|
||||
transform: none;
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
/* color SVGs using currentColor based on the item state */
|
||||
.tracking-timeline__item--done .tracking-timeline__icon { color: var(--color-surface); }
|
||||
.tracking-timeline__item--skipped .tracking-timeline__icon { color: var(--color-danger, #c0392b); }
|
||||
.tracking-timeline__item--current .tracking-timeline__icon { color: var(--color-surface); }
|
||||
|
||||
.tracking-timeline__content {
|
||||
min-width: 0;
|
||||
padding: 2px 0 14px;
|
||||
|
||||
+290
-105
@@ -1,26 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { toPersianDigits } from '@/utils/format'
|
||||
import IconProgressClock from '@/components/icons/IconProgressClock.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue?: string
|
||||
label?: string
|
||||
placeholder?: string
|
||||
hint?: string
|
||||
error?: string
|
||||
disabled?: boolean
|
||||
}>()
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue?: string
|
||||
label?: string
|
||||
placeholder?: string
|
||||
hint?: string
|
||||
error?: string
|
||||
disabled?: boolean
|
||||
/** پنل با Teleport روی body برای مودال و اسکرول */
|
||||
teleport?: boolean
|
||||
}>(),
|
||||
{ teleport: true },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const rootRef = ref<HTMLElement | null>(null)
|
||||
const triggerRef = ref<HTMLButtonElement | null>(null)
|
||||
const panelRef = ref<HTMLElement | null>(null)
|
||||
const hourListRef = ref<HTMLElement | null>(null)
|
||||
const minuteListRef = ref<HTMLElement | null>(null)
|
||||
const isOpen = ref(false)
|
||||
const draftHour = ref(10)
|
||||
const draftMinute = ref(0)
|
||||
const panelStyle = ref<Record<string, string>>({})
|
||||
|
||||
const hours = Array.from({ length: 24 }, (_, i) => i)
|
||||
const minuteSteps = [0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55]
|
||||
|
||||
const parsed = computed(() => parseTime(props.modelValue ?? ''))
|
||||
@@ -42,6 +53,13 @@ watch(
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(isOpen, async (open) => {
|
||||
if (!open) return
|
||||
await nextTick()
|
||||
updatePanelPosition()
|
||||
scrollSelectionIntoView()
|
||||
})
|
||||
|
||||
function snapMinute(minute: number) {
|
||||
const snapped = Math.round(minute / 5) * 5
|
||||
return snapped === 60 ? 55 : snapped
|
||||
@@ -66,18 +84,20 @@ function padTime(hour: number, minute: number) {
|
||||
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function seedDraft() {
|
||||
if (parsed.value) {
|
||||
draftHour.value = parsed.value.hour
|
||||
draftMinute.value = snapMinute(parsed.value.minute)
|
||||
return
|
||||
}
|
||||
const now = new Date()
|
||||
draftHour.value = now.getHours()
|
||||
draftMinute.value = snapMinute(now.getMinutes())
|
||||
}
|
||||
|
||||
function togglePanel() {
|
||||
if (props.disabled) return
|
||||
if (!isOpen.value) {
|
||||
if (parsed.value) {
|
||||
draftHour.value = parsed.value.hour
|
||||
draftMinute.value = snapMinute(parsed.value.minute)
|
||||
} else {
|
||||
const now = new Date()
|
||||
draftHour.value = now.getHours()
|
||||
draftMinute.value = snapMinute(now.getMinutes())
|
||||
}
|
||||
}
|
||||
if (!isOpen.value) seedDraft()
|
||||
isOpen.value = !isOpen.value
|
||||
}
|
||||
|
||||
@@ -85,15 +105,12 @@ function closePanel() {
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
function stepHour(delta: number) {
|
||||
draftHour.value = (draftHour.value + delta + 24) % 24
|
||||
function selectHour(hour: number) {
|
||||
draftHour.value = hour
|
||||
}
|
||||
|
||||
function stepMinute(delta: number) {
|
||||
const index = minuteSteps.indexOf(draftMinute.value)
|
||||
const base = index < 0 ? 0 : index
|
||||
const next = (base + delta + minuteSteps.length) % minuteSteps.length
|
||||
draftMinute.value = minuteSteps[next] ?? 0
|
||||
function selectMinute(minute: number) {
|
||||
draftMinute.value = minute
|
||||
}
|
||||
|
||||
function confirmSelection() {
|
||||
@@ -111,18 +128,64 @@ function selectNow() {
|
||||
closePanel()
|
||||
}
|
||||
|
||||
function onDocumentClick(event: MouseEvent) {
|
||||
if (!rootRef.value?.contains(event.target as Node)) {
|
||||
closePanel()
|
||||
function scrollSelectionIntoView() {
|
||||
const hourBtn = hourListRef.value?.querySelector<HTMLElement>(
|
||||
`[data-hour="${draftHour.value}"]`,
|
||||
)
|
||||
const minuteBtn = minuteListRef.value?.querySelector<HTMLElement>(
|
||||
`[data-minute="${draftMinute.value}"]`,
|
||||
)
|
||||
hourBtn?.scrollIntoView({ block: 'center' })
|
||||
minuteBtn?.scrollIntoView({ block: 'center' })
|
||||
}
|
||||
|
||||
function updatePanelPosition() {
|
||||
const trigger = triggerRef.value
|
||||
if (!trigger || !props.teleport) return
|
||||
|
||||
const rect = trigger.getBoundingClientRect()
|
||||
const panelWidth = Math.min(Math.max(rect.width, 280), window.innerWidth - 16)
|
||||
const panelHeight = 268
|
||||
const margin = 8
|
||||
const spaceBelow = window.innerHeight - rect.bottom
|
||||
const openUp = spaceBelow < panelHeight + margin && rect.top > panelHeight + margin
|
||||
|
||||
let top = openUp ? rect.top - panelHeight - margin : rect.bottom + margin
|
||||
top = Math.max(margin, Math.min(top, window.innerHeight - panelHeight - margin))
|
||||
|
||||
let left = rect.right - panelWidth
|
||||
left = Math.max(margin, Math.min(left, window.innerWidth - panelWidth - margin))
|
||||
|
||||
panelStyle.value = {
|
||||
position: 'fixed',
|
||||
top: `${top}px`,
|
||||
left: `${left}px`,
|
||||
width: `${panelWidth}px`,
|
||||
zIndex: '1300',
|
||||
}
|
||||
}
|
||||
|
||||
function onDocumentClick(event: MouseEvent) {
|
||||
const target = event.target as Node
|
||||
if (rootRef.value?.contains(target)) return
|
||||
if (panelRef.value?.contains(target)) return
|
||||
closePanel()
|
||||
}
|
||||
|
||||
function onViewportChange() {
|
||||
if (isOpen.value) updatePanelPosition()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', onDocumentClick)
|
||||
window.addEventListener('resize', onViewportChange)
|
||||
window.addEventListener('scroll', onViewportChange, true)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', onDocumentClick)
|
||||
window.removeEventListener('resize', onViewportChange)
|
||||
window.removeEventListener('scroll', onViewportChange, true)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -131,10 +194,13 @@ onBeforeUnmount(() => {
|
||||
<label v-if="label" class="form-label">{{ label }}</label>
|
||||
|
||||
<button
|
||||
ref="triggerRef"
|
||||
type="button"
|
||||
class="input time-input__trigger"
|
||||
:class="{ 'input-error': error, 'is-open': isOpen }"
|
||||
:disabled="disabled"
|
||||
:aria-expanded="isOpen"
|
||||
aria-haspopup="dialog"
|
||||
@click.stop="togglePanel"
|
||||
>
|
||||
<span :class="{ 'time-input__placeholder': !displayValue }">
|
||||
@@ -145,43 +211,121 @@ onBeforeUnmount(() => {
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div v-if="isOpen" class="time-input__panel" @click.stop>
|
||||
<div class="time-input__picker" role="group" aria-label="انتخاب ساعت">
|
||||
<div class="time-input__spinner">
|
||||
<span class="time-input__spinner-label">ساعت</span>
|
||||
<button type="button" class="time-input__step" aria-label="یک ساعت بعد" @click="stepHour(1)">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M6 14l6-6 6 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<strong class="time-input__value">{{ toPersianDigits(String(draftHour).padStart(2, '0')) }}</strong>
|
||||
<button type="button" class="time-input__step" aria-label="یک ساعت قبل" @click="stepHour(-1)">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M6 10l6 6 6-6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<Teleport to="body" :disabled="!teleport || !isOpen">
|
||||
<div
|
||||
v-if="isOpen && teleport"
|
||||
ref="panelRef"
|
||||
class="time-input__panel time-input__panel--portal"
|
||||
:style="panelStyle"
|
||||
role="dialog"
|
||||
aria-label="انتخاب ساعت"
|
||||
@click.stop
|
||||
>
|
||||
<div class="time-input__picker" dir="ltr">
|
||||
<div class="time-input__column">
|
||||
<span class="time-input__column-label">ساعت</span>
|
||||
<div ref="hourListRef" class="time-input__scroll" role="listbox" aria-label="ساعت">
|
||||
<button
|
||||
v-for="hour in hours"
|
||||
:key="hour"
|
||||
type="button"
|
||||
class="time-input__option"
|
||||
:class="{ 'time-input__option--active': hour === draftHour }"
|
||||
:data-hour="hour"
|
||||
role="option"
|
||||
:aria-selected="hour === draftHour"
|
||||
@click="selectHour(hour)"
|
||||
>
|
||||
{{ toPersianDigits(String(hour).padStart(2, '0')) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="time-input__colon" aria-hidden="true">:</span>
|
||||
|
||||
<div class="time-input__column">
|
||||
<span class="time-input__column-label">دقیقه</span>
|
||||
<div ref="minuteListRef" class="time-input__scroll" role="listbox" aria-label="دقیقه">
|
||||
<button
|
||||
v-for="minute in minuteSteps"
|
||||
:key="minute"
|
||||
type="button"
|
||||
class="time-input__option"
|
||||
:class="{ 'time-input__option--active': minute === draftMinute }"
|
||||
:data-minute="minute"
|
||||
role="option"
|
||||
:aria-selected="minute === draftMinute"
|
||||
@click="selectMinute(minute)"
|
||||
>
|
||||
{{ toPersianDigits(String(minute).padStart(2, '0')) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="time-input__preview" dir="ltr">
|
||||
{{ formatTimeLabel(draftHour, draftMinute) }}
|
||||
</div>
|
||||
|
||||
<div class="time-input__footer">
|
||||
<button type="button" class="time-input__now" @click="selectNow">اکنون</button>
|
||||
<button type="button" class="time-input__confirm" @click="confirmSelection">تأیید</button>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<div
|
||||
v-if="isOpen && !teleport"
|
||||
ref="panelRef"
|
||||
class="time-input__panel"
|
||||
role="dialog"
|
||||
aria-label="انتخاب ساعت"
|
||||
@click.stop
|
||||
>
|
||||
<div class="time-input__picker" dir="ltr">
|
||||
<div class="time-input__column">
|
||||
<span class="time-input__column-label">ساعت</span>
|
||||
<div class="time-input__scroll" role="listbox" aria-label="ساعت">
|
||||
<button
|
||||
v-for="hour in hours"
|
||||
:key="`inline-${hour}`"
|
||||
type="button"
|
||||
class="time-input__option"
|
||||
:class="{ 'time-input__option--active': hour === draftHour }"
|
||||
role="option"
|
||||
:aria-selected="hour === draftHour"
|
||||
@click="selectHour(hour)"
|
||||
>
|
||||
{{ toPersianDigits(String(hour).padStart(2, '0')) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="time-input__colon" aria-hidden="true">:</span>
|
||||
|
||||
<div class="time-input__spinner">
|
||||
<span class="time-input__spinner-label">دقیقه</span>
|
||||
<button type="button" class="time-input__step" aria-label="پنج دقیقه بعد" @click="stepMinute(1)">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M6 14l6-6 6 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<strong class="time-input__value">
|
||||
{{ toPersianDigits(String(draftMinute).padStart(2, '0')) }}
|
||||
</strong>
|
||||
<button type="button" class="time-input__step" aria-label="پنج دقیقه قبل" @click="stepMinute(-1)">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M6 10l6 6 6-6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="time-input__column">
|
||||
<span class="time-input__column-label">دقیقه</span>
|
||||
<div class="time-input__scroll" role="listbox" aria-label="دقیقه">
|
||||
<button
|
||||
v-for="minute in minuteSteps"
|
||||
:key="`inline-${minute}`"
|
||||
type="button"
|
||||
class="time-input__option"
|
||||
:class="{ 'time-input__option--active': minute === draftMinute }"
|
||||
role="option"
|
||||
:aria-selected="minute === draftMinute"
|
||||
@click="selectMinute(minute)"
|
||||
>
|
||||
{{ toPersianDigits(String(minute).padStart(2, '0')) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="time-input__preview" dir="ltr">
|
||||
{{ formatTimeLabel(draftHour, draftMinute) }}
|
||||
</div>
|
||||
|
||||
<div class="time-input__footer">
|
||||
<button type="button" class="time-input__now" @click="selectNow">اکنون</button>
|
||||
<button type="button" class="time-input__confirm" @click="confirmSelection">تأیید</button>
|
||||
@@ -207,6 +351,7 @@ onBeforeUnmount(() => {
|
||||
font-size: 0.9375rem;
|
||||
color: var(--color-text);
|
||||
transition: border-color 0.15s;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.time-input__trigger {
|
||||
@@ -226,6 +371,7 @@ onBeforeUnmount(() => {
|
||||
.time-input__trigger.is-open,
|
||||
.time-input__trigger:focus-visible {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px var(--color-primary-soft, rgba(59, 130, 246, 0.15));
|
||||
}
|
||||
|
||||
.time-input__trigger:disabled {
|
||||
@@ -263,76 +409,111 @@ onBeforeUnmount(() => {
|
||||
|
||||
.time-input__panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
left: 0;
|
||||
z-index: 320;
|
||||
padding: 8px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 12px;
|
||||
border-radius: 14px;
|
||||
background: var(--color-surface);
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);
|
||||
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.14);
|
||||
}
|
||||
|
||||
.time-input__panel--portal {
|
||||
position: fixed;
|
||||
right: auto;
|
||||
left: auto;
|
||||
top: auto;
|
||||
}
|
||||
|
||||
.time-input__picker {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.time-input__spinner {
|
||||
.time-input__column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 4px 6px 6px;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.time-input__column-label {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.time-input__scroll {
|
||||
max-height: 168px;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
background: var(--color-bg);
|
||||
padding: 4px;
|
||||
scroll-snap-type: y proximity;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.time-input__spinner-label {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.2;
|
||||
.time-input__scroll::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.time-input__value {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 24px;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text);
|
||||
.time-input__scroll::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.time-input__step {
|
||||
width: 28px;
|
||||
height: 24px;
|
||||
.time-input__option {
|
||||
width: 100%;
|
||||
min-height: 36px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
color: var(--color-text);
|
||||
font: inherit;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
scroll-snap-align: center;
|
||||
transition:
|
||||
background 0.12s,
|
||||
color 0.12s;
|
||||
}
|
||||
|
||||
.time-input__step:hover {
|
||||
background: var(--color-primary-soft);
|
||||
color: var(--color-primary);
|
||||
.time-input__option:hover {
|
||||
background: var(--color-primary-soft, rgba(59, 130, 246, 0.08));
|
||||
}
|
||||
|
||||
.time-input__option--active {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.time-input__colon {
|
||||
font-size: 0.9375rem;
|
||||
align-self: center;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1;
|
||||
padding-top: 12px;
|
||||
padding-top: 18px;
|
||||
}
|
||||
|
||||
.time-input__preview {
|
||||
margin-top: 10px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
background: var(--color-primary-light, rgba(59, 130, 246, 0.08));
|
||||
color: var(--color-primary);
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.time-input__footer {
|
||||
@@ -340,8 +521,8 @@ onBeforeUnmount(() => {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
@@ -350,22 +531,26 @@ onBeforeUnmount(() => {
|
||||
background: transparent;
|
||||
color: var(--color-primary);
|
||||
font: inherit;
|
||||
font-size: 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
padding: 4px 0;
|
||||
padding: 6px 4px;
|
||||
}
|
||||
|
||||
.time-input__confirm {
|
||||
min-height: 32px;
|
||||
padding: 0 14px;
|
||||
min-height: 36px;
|
||||
padding: 0 18px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
border-radius: 10px;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
font: inherit;
|
||||
font-size: 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.time-input__confirm:hover {
|
||||
filter: brightness(0.96);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
export type AppToastTone = 'success' | 'error'
|
||||
|
||||
const message = ref('')
|
||||
const tone = ref<AppToastTone>('success')
|
||||
let hideTimer: number | undefined
|
||||
|
||||
export function useAppToast() {
|
||||
function show(
|
||||
text: string,
|
||||
options?: { tone?: AppToastTone; durationMs?: number },
|
||||
) {
|
||||
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)
|
||||
}
|
||||
|
||||
function clear() {
|
||||
message.value = ''
|
||||
if (hideTimer) {
|
||||
window.clearTimeout(hideTimer)
|
||||
hideTimer = undefined
|
||||
}
|
||||
}
|
||||
|
||||
return { message, tone, show, clear }
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ref } from 'vue'
|
||||
import { addressesApi } from '@/services/addresses'
|
||||
import { ApiError } from '@/services/http'
|
||||
import { useAppToast } from '@/composables/useAppToast'
|
||||
import { useCatalogRequestsStore } from '@/stores/catalogRequests'
|
||||
import type { CatalogProductCard } from '@/types/crm'
|
||||
|
||||
@@ -9,9 +10,9 @@ const SUCCESS_MESSAGE =
|
||||
|
||||
export function useCatalogRequest() {
|
||||
const requestsStore = useCatalogRequestsStore()
|
||||
const appToast = useAppToast()
|
||||
|
||||
const requesting = ref(false)
|
||||
const successMessage = ref('')
|
||||
const addressModalOpen = ref(false)
|
||||
const pendingProduct = ref<CatalogProductCard | null>(null)
|
||||
const addressError = ref('')
|
||||
@@ -38,10 +39,7 @@ export function useCatalogRequest() {
|
||||
addressError.value = result.message
|
||||
return false
|
||||
}
|
||||
successMessage.value = SUCCESS_MESSAGE
|
||||
window.setTimeout(() => {
|
||||
successMessage.value = ''
|
||||
}, 6000)
|
||||
appToast.show(SUCCESS_MESSAGE)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -94,7 +92,6 @@ export function useCatalogRequest() {
|
||||
|
||||
return {
|
||||
requesting,
|
||||
successMessage,
|
||||
addressModalOpen,
|
||||
addressError,
|
||||
requestCatalog,
|
||||
|
||||
@@ -13,7 +13,6 @@ const showNav = computed(() => {
|
||||
'login',
|
||||
'register',
|
||||
'otp',
|
||||
'settlement',
|
||||
'check-mode',
|
||||
'check-form',
|
||||
'check-list',
|
||||
@@ -25,7 +24,6 @@ const showNav = computed(() => {
|
||||
|
||||
const isWideGuest = computed(() => {
|
||||
const wideRoutes = [
|
||||
'settlement',
|
||||
'check-mode',
|
||||
'check-form',
|
||||
'check-list',
|
||||
|
||||
+2
-2
@@ -189,7 +189,7 @@ const router = createRouter({
|
||||
path: '/orders/:id/settlement',
|
||||
name: 'settlement',
|
||||
component: () => import('@/views/checkout/SettlementTypeView.vue'),
|
||||
meta: { requiresAuth: true, hideNav: true },
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/orders/:id/cash',
|
||||
@@ -274,7 +274,7 @@ router.beforeEach(async (to) => {
|
||||
const authed = isAuthenticated()
|
||||
|
||||
if (to.meta.requiresAuth && !authed) {
|
||||
return { name: 'login', query: { redirect: to.fullPath } }
|
||||
return { name: 'welcome', query: { redirect: to.fullPath } }
|
||||
}
|
||||
|
||||
if (to.meta.guest && authed) {
|
||||
|
||||
@@ -10,6 +10,10 @@ export type ProductSuggestionDto = {
|
||||
color?: string
|
||||
subtitle?: string
|
||||
imageUrl?: string
|
||||
colors?: string[]
|
||||
colorImages?: Array<string | null>
|
||||
widthCm?: number
|
||||
composition?: string
|
||||
isNew?: boolean
|
||||
note?: string
|
||||
createdAt: string
|
||||
|
||||
@@ -41,7 +41,8 @@ const SERVER_TO_UI: Record<string, CatalogRequestStatus> = {
|
||||
ready_to_ship: 'preparing',
|
||||
delivered: 'delivered',
|
||||
returned: 'delivered',
|
||||
cancelled: 'submitted',
|
||||
// Map server "cancelled" to UI "cancelled" so user sees cancellation
|
||||
cancelled: 'cancelled',
|
||||
}
|
||||
|
||||
function readAll(): CatalogRequest[] {
|
||||
|
||||
@@ -11,6 +11,9 @@ import IconHome from '@/components/icons/IconHome.vue'
|
||||
import IconShirt from '@/components/icons/IconShirt.vue'
|
||||
import IconSuit from '@/components/icons/IconSuit.vue'
|
||||
import IconTrousers from '@/components/icons/IconTrousers.vue'
|
||||
import IconRuler from '@/components/icons/IconRuler.vue'
|
||||
import IconComposition from '@/components/icons/IconComposition.vue'
|
||||
import IconWeave from '@/components/icons/IconWeave.vue'
|
||||
|
||||
export const CATEGORY_ICON_MAP: Record<string, Component> = {
|
||||
bolt: IconBolt,
|
||||
@@ -25,6 +28,10 @@ export const CATEGORY_ICON_MAP: Record<string, Component> = {
|
||||
shirt: IconShirt,
|
||||
suit: IconSuit,
|
||||
trousers: IconTrousers,
|
||||
ruler: IconRuler,
|
||||
composition: IconComposition,
|
||||
weight: IconShoppingBag,
|
||||
weave: IconWeave,
|
||||
}
|
||||
|
||||
const USAGE_ICON_BY_ID: Record<string, string> = {
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import type { Order, OrderStatus } from '@/types'
|
||||
import type { Order, OrderItem, OrderStatus } from '@/types'
|
||||
|
||||
/** متراژ مؤثر هر قلم: بعد از قیمتگذاری ادمین، مقدار نهایی (confirmed) است. */
|
||||
export function orderItemMeterage(
|
||||
item: Pick<OrderItem, 'requestedMeterage' | 'confirmedMeterage'>,
|
||||
): number {
|
||||
return item.confirmedMeterage ?? item.requestedMeterage
|
||||
}
|
||||
|
||||
/** سفارش وقتی قابل تسویه/چک است که مبلغ فاکتور توسط ادمین تعیین شده باشد. */
|
||||
export function isInvoicePriced(order: Order | null | undefined): boolean {
|
||||
|
||||
@@ -15,6 +15,7 @@ export const SIMPLE_CATALOG_STATUS_STEPS: SimpleTimelineStep[] = [
|
||||
{ id: 'ready_to_ship', label: 'آماده ارسال' },
|
||||
{ id: 'shipped', label: 'ارسال شد' },
|
||||
{ id: 'returned', label: 'تحویل گرفته شد' },
|
||||
{ id: 'cancelled', label: 'درخواست لغو شد' },
|
||||
]
|
||||
|
||||
const STATUS_TO_STEP_INDEX: Record<CatalogServerStatus, number> = {
|
||||
@@ -23,7 +24,7 @@ const STATUS_TO_STEP_INDEX: Record<CatalogServerStatus, number> = {
|
||||
ready_to_ship: 1,
|
||||
delivered: 2,
|
||||
returned: 3,
|
||||
cancelled: 0,
|
||||
cancelled: 4,
|
||||
}
|
||||
|
||||
export function resolveCatalogServerStatus(request: CatalogRequest): CatalogServerStatus {
|
||||
@@ -64,5 +65,6 @@ export function getSimpleCatalogStatusTimestamps(
|
||||
ready_to_ship: ['ready_to_ship', 'delivered', 'returned'].includes(status) ? updated : null,
|
||||
shipped: ['delivered', 'returned'].includes(status) ? updated : null,
|
||||
returned: status === 'returned' ? updated : null,
|
||||
cancelled: status === 'cancelled' ? updated : null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,9 +77,14 @@ function goToCategories() {
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<button v-else type="button" class="empty-cta" @click="goToCategories">
|
||||
<div v-else class="empty-cta">
|
||||
شما هیچ کالیته ای درخواست نکرده اید، جهت درخواست کالیته و مشاهده فیزیکی پارچه ها کلیک کنید
|
||||
</button>
|
||||
<button type="button" class="desk-new-btn" @click="goToCategories">
|
||||
مشاهده محصولات
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -56,7 +56,7 @@ async function selectType(type: 'cash' | 'check') {
|
||||
|
||||
<template>
|
||||
<!-- Mobile -->
|
||||
<div class="page settlement-mobile-page mobile-only">
|
||||
<div class="page settlement-page mobile-only">
|
||||
<PageHeader
|
||||
title="انتخاب نوع تسویه"
|
||||
subtitle="پس از صدور فاکتور، نوع تسویه را انتخاب کنید"
|
||||
@@ -64,7 +64,7 @@ async function selectType(type: 'cash' | 'check') {
|
||||
centered
|
||||
@back="router.push(`/orders/${orderId}`)"
|
||||
/>
|
||||
<div class="page__scroll">
|
||||
<div class="page__scroll page__scroll--with-nav settlement-page__scroll">
|
||||
<FormErrorBanner :message="formError" />
|
||||
|
||||
<div v-if="!invoiceReady" class="settlement-waiting">
|
||||
@@ -116,18 +116,31 @@ async function selectType(type: 'cash' | 'check') {
|
||||
</div>
|
||||
|
||||
<!-- Desktop -->
|
||||
<div class="desktop-checkout-shell desktop-only">
|
||||
<div class="desktop-page settlement-desktop desktop-only">
|
||||
<CheckoutStepper :current-step="4" />
|
||||
|
||||
<header class="desktop-page__head">
|
||||
<h1 class="desktop-page__title">انتخاب نوع تسویه</h1>
|
||||
<p class="desktop-page__subtitle">
|
||||
<template v-if="invoiceReady">
|
||||
مبلغ فاکتور {{ invoiceLabel }}{{ order ? ` · سفارش ${order.orderNumber}` : '' }}
|
||||
پس از صدور فاکتور، روش تسویه را انتخاب کنید.
|
||||
</template>
|
||||
<template v-else>پس از قیمتگذاری مدیریت فعال میشود</template>
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div
|
||||
v-if="invoiceReady"
|
||||
class="settlement-invoice settlement-invoice--desktop"
|
||||
role="status"
|
||||
>
|
||||
<div class="settlement-invoice--desktop__main">
|
||||
<span class="settlement-invoice__label">مبلغ فاکتور</span>
|
||||
<strong class="settlement-invoice__value">{{ invoiceLabel }}</strong>
|
||||
</div>
|
||||
<span v-if="order" class="settlement-invoice__meta">سفارش {{ order.orderNumber }}</span>
|
||||
</div>
|
||||
|
||||
<FormErrorBanner :message="formError" />
|
||||
|
||||
<div v-if="!invoiceReady" class="settlement-waiting settlement-waiting--desktop">
|
||||
@@ -170,9 +183,33 @@ async function selectType(type: 'cash' | 'check') {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.settlement-mobile-page {
|
||||
.settlement-page {
|
||||
background: var(--color-bg);
|
||||
padding: 12px 0 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.settlement-page__scroll {
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.settlement-desktop {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.settlement-invoice--desktop {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.settlement-invoice--desktop__main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.settlement-waiting {
|
||||
@@ -262,11 +299,13 @@ async function selectType(type: 'cash' | 'check') {
|
||||
}
|
||||
|
||||
.settlement-card--desktop {
|
||||
min-height: 220px;
|
||||
min-height: 240px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
box-shadow 0.15s;
|
||||
box-shadow 0.15s,
|
||||
transform 0.15s;
|
||||
}
|
||||
|
||||
.settlement-card--desktop:hover:not(:disabled) {
|
||||
|
||||
+63
-150
@@ -22,6 +22,7 @@ import { useGoBack } from '@/composables/useGoBack'
|
||||
import AuthThemeToggle from '@/components/ui/AuthThemeToggle.vue'
|
||||
import PageSkeleton from '@/components/ui/PageSkeleton.vue'
|
||||
import UserAvatar from '@/components/ui/UserAvatar.vue'
|
||||
import HomeSuggestionsSection from '@/components/home/HomeSuggestionsSection.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const { goBack } = useGoBack('/')
|
||||
@@ -32,6 +33,7 @@ const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const newest = ref<CatalogProductCard[]>([])
|
||||
const suggestions = ref<ProductSuggestionDto[]>([])
|
||||
const browseProducts = ref<CatalogProductCard[]>([])
|
||||
const favError = ref<string | null>(null)
|
||||
const agentLevel = ref<{
|
||||
label: string
|
||||
@@ -62,6 +64,22 @@ const agentActions: QuickAction[] = [
|
||||
|
||||
const actions = computed(() => (auth.isAgent ? agentActions : customerActions))
|
||||
|
||||
const suggestionsForHome = computed(() => {
|
||||
const byId = new Map(browseProducts.value.map((p) => [p.id, p]))
|
||||
return suggestions.value.map((item) => {
|
||||
const product = byId.get(item.productId)
|
||||
return {
|
||||
...item,
|
||||
composition: item.composition?.trim() || product?.composition?.trim() || '',
|
||||
widthCm: item.widthCm ?? product?.widthCm,
|
||||
colors: item.colors?.length ? item.colors : product?.colors ?? [],
|
||||
colorImages: item.colorImages?.some(Boolean)
|
||||
? item.colorImages
|
||||
: product?.colorImages,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const FALLBACK_BANNER: AppBannerDto = {
|
||||
id: 'fallback',
|
||||
slot: 'home_hero',
|
||||
@@ -147,7 +165,10 @@ onMounted(async () => {
|
||||
error.value = null
|
||||
try {
|
||||
const [products, homeBanners] = await Promise.all([
|
||||
crm.loadBrowseProducts(),
|
||||
crm.loadBrowseProducts().then((list) => {
|
||||
browseProducts.value = list
|
||||
return list
|
||||
}),
|
||||
fetchBanners('home_hero').catch(() => [] as AppBannerDto[]),
|
||||
favorites.hydrate(),
|
||||
])
|
||||
@@ -240,7 +261,10 @@ async function toggleFavorite(productId: string) {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="page__scroll page__scroll--with-nav home-scroll">
|
||||
<div
|
||||
class="page__scroll page__scroll--with-nav home-scroll"
|
||||
:class="{ 'home-scroll--customer': !auth.isAgent }"
|
||||
>
|
||||
<p v-if="error" class="home-error">{{ error }}</p>
|
||||
<PageSkeleton v-else-if="loading" variant="home" />
|
||||
<template v-else>
|
||||
@@ -345,37 +369,11 @@ async function toggleFavorite(productId: string) {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-else class="suggest-card" aria-label="پیشنهاد اختصاصی">
|
||||
<div class="suggest-card__head">
|
||||
<h3 class="suggest-card__title">پیشنهاد اختصاصی ما برای شما</h3>
|
||||
<p v-if="!suggestions.length" class="suggest-card__empty">
|
||||
هنوز پیشنهاد اختصاصی برای شما ثبت نشده است.
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="suggestions.length" class="suggest-card__row scroll-x">
|
||||
<article
|
||||
v-for="(item, index) in suggestions"
|
||||
:key="item.id"
|
||||
class="suggest-card__item"
|
||||
@click="go(`/product?id=${item.productId}`)"
|
||||
>
|
||||
<div
|
||||
class="suggest-card__image"
|
||||
:class="`suggest-card__image--${index % 3}`"
|
||||
:style="item.imageUrl ? { backgroundImage: `url(${item.imageUrl})` } : undefined"
|
||||
>
|
||||
<span v-if="item.isNew" class="suggest-card__badge">جدید</span>
|
||||
</div>
|
||||
<div class="suggest-card__body">
|
||||
<strong class="suggest-card__name">{{ item.productName }}</strong>
|
||||
<span class="suggest-card__sub">
|
||||
{{ item.subtitle || item.catalogName || item.productCode }}
|
||||
</span>
|
||||
<p v-if="item.note" class="suggest-card__note">{{ item.note }}</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
<HomeSuggestionsSection
|
||||
v-if="!auth.isAgent"
|
||||
class="home-suggestions"
|
||||
:items="suggestionsForHome"
|
||||
/>
|
||||
|
||||
<section class="section">
|
||||
<div class="section__head">
|
||||
@@ -718,6 +716,19 @@ async function toggleFavorite(productId: string) {
|
||||
font-size: 0.7rem;
|
||||
text-align: center;
|
||||
line-height: 1.4;
|
||||
/* Force navy color for quick-card labels (override global/link styles on server) */
|
||||
color: var(--color-accent-navy) !important;
|
||||
}
|
||||
|
||||
/* Keep the same color on hover/focus so it doesn't turn default link/blue */
|
||||
.quick-card:hover .quick-card__label,
|
||||
.quick-card:focus .quick-card__label {
|
||||
color: var(--color-accent-navy) !important;
|
||||
}
|
||||
|
||||
/* Ensure dark mode keeps readable navy (adjust if you prefer a different dark color) */
|
||||
html.dark .quick-card__label {
|
||||
color: var(--color-accent-navy) !important;
|
||||
}
|
||||
|
||||
.level-card {
|
||||
@@ -815,110 +826,10 @@ async function toggleFavorite(productId: string) {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.suggest-card {
|
||||
background: var(--color-surface);
|
||||
border-radius: 18px;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--color-border);
|
||||
.home-suggestions {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.suggest-card__head {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.suggest-card__title {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 800;
|
||||
color: var(--color-accent-navy);
|
||||
}
|
||||
|
||||
.suggest-card__empty {
|
||||
margin: 8px 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.suggest-card__row {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: 10px;
|
||||
margin-inline: -16px;
|
||||
padding-inline: 16px;
|
||||
padding-bottom: 4px;
|
||||
scroll-snap-type: x proximity;
|
||||
}
|
||||
|
||||
.suggest-card__item {
|
||||
flex: 0 0 132px;
|
||||
width: 132px;
|
||||
scroll-snap-align: start;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.suggest-card__image {
|
||||
position: relative;
|
||||
height: 92px;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(145deg, #e8ecf0, #d5dbe3);
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
.suggest-card__image--0 {
|
||||
background-color: #e7dfd6;
|
||||
}
|
||||
|
||||
.suggest-card__image--1 {
|
||||
background-color: #dfe8ea;
|
||||
}
|
||||
|
||||
.suggest-card__image--2 {
|
||||
background-color: #ebe4df;
|
||||
}
|
||||
|
||||
.suggest-card__badge {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.suggest-card__body {
|
||||
padding: 8px 2px 0;
|
||||
}
|
||||
|
||||
.suggest-card__name {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
overflow: hidden;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.35;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.suggest-card__sub {
|
||||
display: block;
|
||||
font-size: 0.68rem;
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.suggest-card__note {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.65rem;
|
||||
color: var(--color-primary);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.section__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1057,7 +968,6 @@ async function toggleFavorite(productId: string) {
|
||||
html.dark .section__title,
|
||||
html.dark .home-desk-head__title,
|
||||
html.dark .level-card__name,
|
||||
html.dark .suggest-card__title,
|
||||
html.dark .product-card__name {
|
||||
color: #fff;
|
||||
}
|
||||
@@ -1110,15 +1020,31 @@ html.dark .product-card__sub {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.home-scroll--customer {
|
||||
grid-template-areas:
|
||||
'hero hero hero'
|
||||
'quick quick quick'
|
||||
'suggest suggest suggest'
|
||||
'products products products';
|
||||
}
|
||||
|
||||
.home-scroll--customer .hero-banner {
|
||||
aspect-ratio: 2.35 / 1;
|
||||
}
|
||||
|
||||
.home-suggestions {
|
||||
grid-area: suggest;
|
||||
}
|
||||
|
||||
.hero-banner {
|
||||
grid-area: hero;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
height: auto;
|
||||
height: 100%;
|
||||
aspect-ratio: 2.1 / 1;
|
||||
align-self: start;
|
||||
align-self: stretch;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
@@ -1132,19 +1058,6 @@ html.dark .product-card__sub {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.suggest-card {
|
||||
grid-area: level;
|
||||
padding: 20px 22px;
|
||||
align-self: start;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.suggest-card__row {
|
||||
margin-inline: -22px;
|
||||
padding-inline: 22px;
|
||||
}
|
||||
|
||||
.quick-grid {
|
||||
grid-area: quick;
|
||||
display: grid;
|
||||
|
||||
@@ -7,6 +7,8 @@ import BaseButton from '@/components/ui/BaseButton.vue'
|
||||
import BaseInput from '@/components/ui/BaseInput.vue'
|
||||
import CurrencyInput from '@/components/ui/CurrencyInput.vue'
|
||||
import PersianDateInput from '@/components/ui/PersianDateInput.vue'
|
||||
import TimeInput from '@/components/ui/TimeInput.vue'
|
||||
import AppSelect from '@/components/ui/AppSelect.vue'
|
||||
import FormErrorBanner from '@/components/ui/FormErrorBanner.vue'
|
||||
import IconChevronBack from '@/components/icons/IconChevronBack.vue'
|
||||
import IconChevronLeft from '@/components/icons/IconChevronLeft.vue'
|
||||
@@ -30,7 +32,7 @@ import { getStatusHero, getTrackingFooterLabel } from '@/utils/orderTracking'
|
||||
import OrderTrackingTimeline from '@/components/orders/OrderTrackingTimeline.vue'
|
||||
import OrderStatusHero from '@/components/orders/OrderStatusHero.vue'
|
||||
import OrderTrackingSummary from '@/components/orders/OrderTrackingSummary.vue'
|
||||
import { canSubmitCashReceipt, isInvoicePriced } from '@/utils/orderInvoice'
|
||||
import { canSubmitCashReceipt, isInvoicePriced, orderItemMeterage } from '@/utils/orderInvoice'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -122,6 +124,14 @@ watch(
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const bankAccountOptions = computed(() => [
|
||||
{ value: '', label: 'انتخاب کنید' },
|
||||
...bankAccounts.value.map((acc) => ({
|
||||
value: acc.id,
|
||||
label: `${acc.bankName} — ${acc.cardNumber}`,
|
||||
})),
|
||||
])
|
||||
|
||||
watch(
|
||||
() => orderId.value,
|
||||
async (id) => {
|
||||
@@ -401,7 +411,7 @@ async function submitReceipt() {
|
||||
<strong>{{ item.productName }}</strong>
|
||||
<p class="item-row__meta">کالیته {{ item.catalogCode }} · {{ item.color }}</p>
|
||||
</div>
|
||||
<span>{{ formatMeterage(item.requestedMeterage) }}</span>
|
||||
<span>{{ formatMeterage(orderItemMeterage(item)) }}</span>
|
||||
</div>
|
||||
<div class="item-row item-row--summary">
|
||||
<span>مجموع متراژ</span>
|
||||
@@ -517,7 +527,12 @@ async function submitReceipt() {
|
||||
label="تاریخ واریز"
|
||||
:error="fieldErrors.receiptDate"
|
||||
/>
|
||||
<BaseInput v-model="receiptTime" label="ساعت واریز" type="time" />
|
||||
<TimeInput
|
||||
v-model="receiptTime"
|
||||
label="ساعت واریز"
|
||||
placeholder="مثلاً ۱۴:۳۰"
|
||||
hint="با گام ۵ دقیقه"
|
||||
/>
|
||||
<BaseInput
|
||||
v-model="trackingNumber"
|
||||
label="شماره پیگیری"
|
||||
@@ -525,22 +540,13 @@ async function submitReceipt() {
|
||||
dir="ltr"
|
||||
:error="fieldErrors.trackingNumber"
|
||||
/>
|
||||
<div class="form-group">
|
||||
<label class="form-label">حساب مقصد</label>
|
||||
<select
|
||||
v-model="destinationAccountId"
|
||||
class="select-input"
|
||||
:class="{ 'select-input--error': fieldErrors.destinationAccountId }"
|
||||
>
|
||||
<option value="">انتخاب کنید</option>
|
||||
<option v-for="acc in bankAccounts" :key="acc.id" :value="acc.id">
|
||||
{{ acc.bankName }} - {{ acc.cardNumber }}
|
||||
</option>
|
||||
</select>
|
||||
<p v-if="fieldErrors.destinationAccountId" class="input-error-text">
|
||||
{{ fieldErrors.destinationAccountId }}
|
||||
</p>
|
||||
</div>
|
||||
<AppSelect
|
||||
v-model="destinationAccountId"
|
||||
label="حساب مقصد"
|
||||
aria-label="حساب مقصد"
|
||||
:options="bankAccountOptions"
|
||||
:error="fieldErrors.destinationAccountId"
|
||||
/>
|
||||
<div class="form-group">
|
||||
<label class="form-label">تصویر رسید</label>
|
||||
<label class="upload-box" :class="{ 'upload-box--error': fieldErrors.receiptImage }">
|
||||
@@ -1188,18 +1194,6 @@ async function submitReceipt() {
|
||||
}
|
||||
}
|
||||
|
||||
.select-input {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 1.5px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.select-input--error {
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
|
||||
.upload-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -58,7 +58,6 @@ const orderModalOpen = ref(false)
|
||||
const orderModalError = ref('')
|
||||
const {
|
||||
requesting: catalogRequesting,
|
||||
successMessage: catalogSuccessMessage,
|
||||
addressModalOpen,
|
||||
addressError: catalogAddressError,
|
||||
requestCatalog,
|
||||
@@ -279,10 +278,6 @@ function onAddressSubmit(address: string) {
|
||||
|
||||
<template>
|
||||
<div v-if="product" class="page product-detail product-page">
|
||||
<p v-if="catalogSuccessMessage" class="catalog-success-banner" role="status">
|
||||
{{ catalogSuccessMessage }}
|
||||
</p>
|
||||
|
||||
<header class="pd-header mobile-only">
|
||||
<button type="button" class="icon-btn" aria-label="بازگشت" @click="goBack">
|
||||
<IconChevronBack :size="20" />
|
||||
@@ -505,7 +500,7 @@ function onAddressSubmit(address: string) {
|
||||
:disabled="catalogRequesting"
|
||||
@click="requestPhysicalCatalog"
|
||||
>
|
||||
<img class="btn-secondary__icon" :src="quickCatalogRequestIcon" alt="" aria-hidden="true" />
|
||||
<!-- <img class="btn-secondary__icon" :src="quickCatalogRequestIcon" alt="" aria-hidden="true" />-->
|
||||
{{ catalogRequesting ? 'در حال ثبت...' : 'درخواست کالیته' }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -527,7 +522,7 @@ function onAddressSubmit(address: string) {
|
||||
:disabled="catalogRequesting"
|
||||
@click="requestPhysicalCatalog"
|
||||
>
|
||||
<img class="btn-secondary__icon" :src="quickCatalogRequestIcon" alt="" aria-hidden="true" />
|
||||
<!-- <img class="btn-secondary__icon" :src="quickCatalogRequestIcon" alt="" aria-hidden="true" />-->
|
||||
{{ catalogRequesting ? 'در حال ثبت...' : 'درخواست کالیته' }}
|
||||
</button>
|
||||
</footer>
|
||||
@@ -564,17 +559,6 @@ function onAddressSubmit(address: string) {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.catalog-success-banner {
|
||||
margin: 0;
|
||||
padding: 12px var(--page-padding);
|
||||
background: var(--color-success-light);
|
||||
color: var(--color-success);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.6;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.product-detail {
|
||||
--pd-card-border: 1px solid #eeeef1;
|
||||
--pd-card-shadow: 0 1px 2px rgba(16, 24, 40, 0.04), 0 6px 16px rgba(16, 24, 40, 0.05);
|
||||
@@ -772,18 +756,19 @@ function onAddressSubmit(address: string) {
|
||||
}
|
||||
|
||||
.spec-cell strong {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.45;
|
||||
color: var(--color-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.spec-cell span {
|
||||
color: #98a2ad;
|
||||
font-size: 0.7rem;
|
||||
.spec-cell span:not(.spec-icon) {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.specs-more {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import { onClickOutside } from '@vueuse/core'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useCrmStore } from '@/stores/crm'
|
||||
@@ -8,6 +7,7 @@ import PageHeader from '@/components/layout/PageHeader.vue'
|
||||
import PageSkeleton from '@/components/ui/PageSkeleton.vue'
|
||||
import SearchField from '@/components/ui/SearchField.vue'
|
||||
import BaseButton from '@/components/ui/BaseButton.vue'
|
||||
import AppSelect from '@/components/ui/AppSelect.vue'
|
||||
import IconFilter from '@/components/icons/IconFilter.vue'
|
||||
import IconChevronLeft from '@/components/icons/IconChevronLeft.vue'
|
||||
import IconHeart from '@/components/icons/IconHeart.vue'
|
||||
@@ -37,32 +37,20 @@ const sortMode = ref<'newest' | 'name'>('newest')
|
||||
const filterOpen = ref(false)
|
||||
const filterDraftCategory = ref('')
|
||||
const filterDraftSeason = ref('')
|
||||
const categorySelectOpen = ref(false)
|
||||
const seasonSelectOpen = ref(false)
|
||||
const categoryRoot = ref<HTMLElement | null>(null)
|
||||
const seasonRoot = ref<HTMLElement | null>(null)
|
||||
const categoryRootDesk = ref<HTMLElement | null>(null)
|
||||
const seasonRootDesk = ref<HTMLElement | null>(null)
|
||||
|
||||
const showFilter = computed(() => !catalogId.value)
|
||||
|
||||
const categoryFilterOptions = computed(() => [
|
||||
{ value: '', label: 'همه دستهها' },
|
||||
...crm.usageCategories.map((item) => ({ value: item.id, label: item.title })),
|
||||
])
|
||||
|
||||
const seasonFilterOptions = computed(() => [
|
||||
{ value: '', label: 'همه فصول' },
|
||||
...crm.seasonCategories.map((item) => ({ value: item.id, label: item.title })),
|
||||
])
|
||||
|
||||
const hasActiveFilters = computed(() => !!categoryId.value || !!season.value)
|
||||
|
||||
const selectedCategoryLabel = computed(() => {
|
||||
if (!filterDraftCategory.value) return 'همه دستهها'
|
||||
return (
|
||||
crm.usageCategories.find((item) => item.id === filterDraftCategory.value)?.title ??
|
||||
'همه دستهها'
|
||||
)
|
||||
})
|
||||
|
||||
const selectedSeasonLabel = computed(() => {
|
||||
if (!filterDraftSeason.value) return 'همه فصول'
|
||||
return (
|
||||
crm.seasonCategories.find((item) => item.id === filterDraftSeason.value)?.title ?? 'همه فصول'
|
||||
)
|
||||
})
|
||||
|
||||
const title = computed(() => {
|
||||
if (catalogId.value && catalogTitle.value) return catalogTitle.value
|
||||
if (categoryId.value) {
|
||||
@@ -289,53 +277,13 @@ function toggleFavorite(productId: string) {
|
||||
function openFilter() {
|
||||
filterDraftCategory.value = categoryId.value
|
||||
filterDraftSeason.value = season.value
|
||||
categorySelectOpen.value = false
|
||||
seasonSelectOpen.value = false
|
||||
filterOpen.value = true
|
||||
}
|
||||
|
||||
function closeFilter() {
|
||||
filterOpen.value = false
|
||||
categorySelectOpen.value = false
|
||||
seasonSelectOpen.value = false
|
||||
}
|
||||
|
||||
function toggleCategorySelect() {
|
||||
seasonSelectOpen.value = false
|
||||
categorySelectOpen.value = !categorySelectOpen.value
|
||||
}
|
||||
|
||||
function toggleSeasonSelect() {
|
||||
categorySelectOpen.value = false
|
||||
seasonSelectOpen.value = !seasonSelectOpen.value
|
||||
}
|
||||
|
||||
function selectFilterCategory(id: string) {
|
||||
filterDraftCategory.value = id
|
||||
categorySelectOpen.value = false
|
||||
}
|
||||
|
||||
function selectFilterSeason(id: string) {
|
||||
filterDraftSeason.value = id
|
||||
seasonSelectOpen.value = false
|
||||
}
|
||||
|
||||
onClickOutside(categoryRoot, () => {
|
||||
categorySelectOpen.value = false
|
||||
})
|
||||
|
||||
onClickOutside(seasonRoot, () => {
|
||||
seasonSelectOpen.value = false
|
||||
})
|
||||
|
||||
onClickOutside(categoryRootDesk, () => {
|
||||
categorySelectOpen.value = false
|
||||
})
|
||||
|
||||
onClickOutside(seasonRootDesk, () => {
|
||||
seasonSelectOpen.value = false
|
||||
})
|
||||
|
||||
function resolveFilterAxis(nextCategory: string, nextSeason: string) {
|
||||
if (nextCategory) return 'usage'
|
||||
if (nextSeason) return 'season'
|
||||
@@ -415,103 +363,21 @@ onUnmounted(() => {
|
||||
<h2>فیلتر محصولات</h2>
|
||||
</header>
|
||||
|
||||
<label class="products-filter-section">
|
||||
<span class="products-filter-section__title">دسته کاربرد</span>
|
||||
<div ref="categoryRootDesk" class="field-select">
|
||||
<button
|
||||
type="button"
|
||||
class="field-select__trigger"
|
||||
:class="{ 'is-open': categorySelectOpen }"
|
||||
aria-haspopup="listbox"
|
||||
:aria-expanded="categorySelectOpen"
|
||||
@click="toggleCategorySelect"
|
||||
>
|
||||
<span :class="{ 'is-placeholder': !filterDraftCategory }">{{ selectedCategoryLabel }}</span>
|
||||
<svg class="field-select__chevron" viewBox="0 0 12 12" aria-hidden="true">
|
||||
<path
|
||||
d="M2.5 4.5 6 8l3.5-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.4"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<ul v-if="categorySelectOpen" class="field-select__menu" role="listbox" aria-label="دسته کاربرد">
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
class="field-select__option"
|
||||
:class="{ 'is-active': !filterDraftCategory }"
|
||||
@click="selectFilterCategory('')"
|
||||
>
|
||||
همه دستهها
|
||||
</button>
|
||||
</li>
|
||||
<li v-for="item in crm.usageCategories" :key="item.id">
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
class="field-select__option"
|
||||
:class="{ 'is-active': filterDraftCategory === item.id }"
|
||||
@click="selectFilterCategory(item.id)"
|
||||
>
|
||||
{{ item.title }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</label>
|
||||
<AppSelect
|
||||
v-model="filterDraftCategory"
|
||||
class="products-filter-section"
|
||||
label="دسته کاربرد"
|
||||
aria-label="دسته کاربرد"
|
||||
:options="categoryFilterOptions"
|
||||
/>
|
||||
|
||||
<label class="products-filter-section">
|
||||
<span class="products-filter-section__title">فصل</span>
|
||||
<div ref="seasonRootDesk" class="field-select">
|
||||
<button
|
||||
type="button"
|
||||
class="field-select__trigger"
|
||||
:class="{ 'is-open': seasonSelectOpen }"
|
||||
aria-haspopup="listbox"
|
||||
:aria-expanded="seasonSelectOpen"
|
||||
@click="toggleSeasonSelect"
|
||||
>
|
||||
<span :class="{ 'is-placeholder': !filterDraftSeason }">{{ selectedSeasonLabel }}</span>
|
||||
<svg class="field-select__chevron" viewBox="0 0 12 12" aria-hidden="true">
|
||||
<path
|
||||
d="M2.5 4.5 6 8l3.5-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.4"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<ul v-if="seasonSelectOpen" class="field-select__menu" role="listbox" aria-label="فصل">
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
class="field-select__option"
|
||||
:class="{ 'is-active': !filterDraftSeason }"
|
||||
@click="selectFilterSeason('')"
|
||||
>
|
||||
همه فصول
|
||||
</button>
|
||||
</li>
|
||||
<li v-for="item in crm.seasonCategories" :key="item.id">
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
class="field-select__option"
|
||||
:class="{ 'is-active': filterDraftSeason === item.id }"
|
||||
@click="selectFilterSeason(item.id)"
|
||||
>
|
||||
{{ item.title }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</label>
|
||||
<AppSelect
|
||||
v-model="filterDraftSeason"
|
||||
class="products-filter-section"
|
||||
label="فصل"
|
||||
aria-label="فصل"
|
||||
:options="seasonFilterOptions"
|
||||
/>
|
||||
|
||||
<div class="products-filter-actions products-filter-actions--desk">
|
||||
<BaseButton @click="applyFilter">اعمال فیلتر</BaseButton>
|
||||
@@ -546,107 +412,21 @@ onUnmounted(() => {
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<label class="products-filter-section">
|
||||
<span class="products-filter-section__title">دسته کاربرد</span>
|
||||
<div ref="categoryRoot" class="field-select">
|
||||
<button
|
||||
type="button"
|
||||
class="field-select__trigger"
|
||||
:class="{ 'is-open': categorySelectOpen }"
|
||||
aria-haspopup="listbox"
|
||||
:aria-expanded="categorySelectOpen"
|
||||
@click="toggleCategorySelect"
|
||||
>
|
||||
<span :class="{ 'is-placeholder': !filterDraftCategory }">{{ selectedCategoryLabel }}</span>
|
||||
<svg class="field-select__chevron" viewBox="0 0 12 12" aria-hidden="true">
|
||||
<path
|
||||
d="M2.5 4.5 6 8l3.5-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.4"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<ul v-if="categorySelectOpen" class="field-select__menu" role="listbox" aria-label="دسته کاربرد">
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
class="field-select__option"
|
||||
:class="{ 'is-active': !filterDraftCategory }"
|
||||
:aria-selected="!filterDraftCategory"
|
||||
@click="selectFilterCategory('')"
|
||||
>
|
||||
همه دستهها
|
||||
</button>
|
||||
</li>
|
||||
<li v-for="item in crm.usageCategories" :key="item.id">
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
class="field-select__option"
|
||||
:class="{ 'is-active': filterDraftCategory === item.id }"
|
||||
:aria-selected="filterDraftCategory === item.id"
|
||||
@click="selectFilterCategory(item.id)"
|
||||
>
|
||||
{{ item.title }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</label>
|
||||
<AppSelect
|
||||
v-model="filterDraftCategory"
|
||||
class="products-filter-section"
|
||||
label="دسته کاربرد"
|
||||
aria-label="دسته کاربرد"
|
||||
:options="categoryFilterOptions"
|
||||
/>
|
||||
|
||||
<label class="products-filter-section">
|
||||
<span class="products-filter-section__title">فصل</span>
|
||||
<div ref="seasonRoot" class="field-select">
|
||||
<button
|
||||
type="button"
|
||||
class="field-select__trigger"
|
||||
:class="{ 'is-open': seasonSelectOpen }"
|
||||
aria-haspopup="listbox"
|
||||
:aria-expanded="seasonSelectOpen"
|
||||
@click="toggleSeasonSelect"
|
||||
>
|
||||
<span :class="{ 'is-placeholder': !filterDraftSeason }">{{ selectedSeasonLabel }}</span>
|
||||
<svg class="field-select__chevron" viewBox="0 0 12 12" aria-hidden="true">
|
||||
<path
|
||||
d="M2.5 4.5 6 8l3.5-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.4"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<ul v-if="seasonSelectOpen" class="field-select__menu" role="listbox" aria-label="فصل">
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
class="field-select__option"
|
||||
:class="{ 'is-active': !filterDraftSeason }"
|
||||
:aria-selected="!filterDraftSeason"
|
||||
@click="selectFilterSeason('')"
|
||||
>
|
||||
همه فصول
|
||||
</button>
|
||||
</li>
|
||||
<li v-for="item in crm.seasonCategories" :key="item.id">
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
class="field-select__option"
|
||||
:class="{ 'is-active': filterDraftSeason === item.id }"
|
||||
:aria-selected="filterDraftSeason === item.id"
|
||||
@click="selectFilterSeason(item.id)"
|
||||
>
|
||||
{{ item.title }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</label>
|
||||
<AppSelect
|
||||
v-model="filterDraftSeason"
|
||||
class="products-filter-section"
|
||||
label="فصل"
|
||||
aria-label="فصل"
|
||||
:options="seasonFilterOptions"
|
||||
/>
|
||||
|
||||
<div class="products-filter-actions">
|
||||
<BaseButton block @click="applyFilter">اعمال فیلتر</BaseButton>
|
||||
@@ -960,83 +740,10 @@ onUnmounted(() => {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.field-select {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.field-select__trigger {
|
||||
.products-filter-section.app-select {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
height: var(--btn-height);
|
||||
padding: 0 14px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font: inherit;
|
||||
font-size: 0.9375rem;
|
||||
text-align: start;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.field-select__trigger.is-open {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.field-select__trigger .is-placeholder {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.field-select__chevron {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
color: var(--color-text-muted);
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.field-select__trigger.is-open .field-select__chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.field-select__menu {
|
||||
position: absolute;
|
||||
inset-inline: 0;
|
||||
top: calc(100% + 6px);
|
||||
z-index: 20;
|
||||
margin: 0;
|
||||
padding: 6px;
|
||||
list-style: none;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-md);
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.field-select__option {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font: inherit;
|
||||
font-size: 0.875rem;
|
||||
text-align: start;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.field-select__option.is-active {
|
||||
background: var(--color-primary-light);
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.products-filter-actions {
|
||||
|
||||
@@ -1,24 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useMediaQuery } from '@vueuse/core'
|
||||
import BaseButton from '@/components/ui/BaseButton.vue'
|
||||
import IconMapPin from '@/components/icons/IconMapPin.vue'
|
||||
import IconPlus from '@/components/icons/IconPlus.vue'
|
||||
import IconTrash from '@/components/icons/IconTrash.vue'
|
||||
import IconVerified from '@/components/icons/IconVerified.vue'
|
||||
import ProfileSubpageLayout from '@/views/profile/components/ProfileSubpageLayout.vue'
|
||||
import { useAppToast } from '@/composables/useAppToast'
|
||||
import { addressesApi, type SavedAddress } from '@/services/addresses'
|
||||
import { ApiError } from '@/services/http'
|
||||
|
||||
const isDesktop = useMediaQuery('(min-width: 768px)')
|
||||
const toast = useAppToast()
|
||||
|
||||
const rows = ref<SavedAddress[]>([])
|
||||
const text = ref('')
|
||||
const label = ref('')
|
||||
const error = ref('')
|
||||
const pageError = ref('')
|
||||
const formError = ref('')
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const addOpen = ref(false)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
pageError.value = ''
|
||||
try {
|
||||
rows.value = await addressesApi.list()
|
||||
} catch (e) {
|
||||
error.value = e instanceof ApiError || e instanceof Error ? e.message : 'دریافت آدرسها ناموفق بود.'
|
||||
pageError.value =
|
||||
e instanceof ApiError || e instanceof Error ? e.message : 'دریافت آدرسها ناموفق بود.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -26,133 +38,424 @@ async function load() {
|
||||
|
||||
onMounted(load)
|
||||
|
||||
function resetForm() {
|
||||
text.value = ''
|
||||
label.value = ''
|
||||
formError.value = ''
|
||||
}
|
||||
|
||||
function openAddForm() {
|
||||
resetForm()
|
||||
addOpen.value = true
|
||||
}
|
||||
|
||||
function closeAddForm() {
|
||||
if (saving.value) return
|
||||
addOpen.value = false
|
||||
resetForm()
|
||||
}
|
||||
|
||||
function onAddKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape' && addOpen.value) closeAddForm()
|
||||
}
|
||||
|
||||
watch(addOpen, (isOpen) => {
|
||||
document.body.style.overflow = isOpen ? 'hidden' : ''
|
||||
if (isOpen) {
|
||||
window.addEventListener('keydown', onAddKeydown)
|
||||
} else {
|
||||
window.removeEventListener('keydown', onAddKeydown)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.body.style.overflow = ''
|
||||
window.removeEventListener('keydown', onAddKeydown)
|
||||
})
|
||||
|
||||
async function add() {
|
||||
const trimmed = text.value.trim()
|
||||
if (trimmed.length < 3 || saving.value) {
|
||||
error.value = 'آدرس را کاملتر بنویسید.'
|
||||
formError.value = 'آدرس را کاملتر بنویسید.'
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
error.value = ''
|
||||
formError.value = ''
|
||||
try {
|
||||
await addressesApi.create({ text: trimmed, label: label.value.trim() || undefined })
|
||||
text.value = ''
|
||||
label.value = ''
|
||||
rows.value = await addressesApi.list()
|
||||
closeAddForm()
|
||||
toast.show('آدرس با موفقیت ثبت شد.', { tone: 'success' })
|
||||
} catch (e) {
|
||||
error.value = e instanceof ApiError || e instanceof Error ? e.message : 'ذخیره آدرس ناموفق بود.'
|
||||
formError.value =
|
||||
e instanceof ApiError || e instanceof Error ? e.message : 'ذخیره آدرس ناموفق بود.'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function makeDefault(id: string) {
|
||||
error.value = ''
|
||||
pageError.value = ''
|
||||
try {
|
||||
rows.value = await addressesApi.makeDefault(id)
|
||||
toast.show('آدرس پیشفرض بهروزرسانی شد.', { tone: 'success' })
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'تغییر آدرس پیشفرض ناموفق بود.'
|
||||
pageError.value = e instanceof Error ? e.message : 'تغییر آدرس پیشفرض ناموفق بود.'
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id: string) {
|
||||
error.value = ''
|
||||
pageError.value = ''
|
||||
try {
|
||||
rows.value = await addressesApi.remove(id)
|
||||
toast.show('آدرس حذف شد.', { tone: 'success' })
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'حذف آدرس ناموفق بود.'
|
||||
pageError.value = e instanceof Error ? e.message : 'حذف آدرس ناموفق بود.'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ProfileSubpageLayout title="آدرسها" subtitle="برای ارسال کالیته از آدرس پیشفرض استفاده میشود">
|
||||
<p v-if="error" class="addr-error">{{ error }}</p>
|
||||
<p v-if="loading" class="addr-muted">در حال دریافت…</p>
|
||||
<ul v-else-if="rows.length" class="addr-list">
|
||||
<li v-for="row in rows" :key="row.id" class="addr-item">
|
||||
<div>
|
||||
<strong>{{ row.label || 'آدرس' }}</strong>
|
||||
<span v-if="row.isDefault" class="addr-badge">پیشفرض</span>
|
||||
<p>{{ row.text }}</p>
|
||||
</div>
|
||||
<div class="addr-actions">
|
||||
<button v-if="!row.isDefault" type="button" @click="makeDefault(row.id)">پیشفرض</button>
|
||||
<button type="button" class="addr-remove" @click="remove(row.id)">حذف</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="addr-muted">هنوز آدرسی ثبت نشده است.</p>
|
||||
<div class="addresses-page">
|
||||
<ProfileSubpageLayout title="آدرسها" subtitle="برای ارسال کالیته از آدرس پیشفرض استفاده میشود">
|
||||
<div class="addresses-page__toolbar">
|
||||
<p class="addresses-page__hint">آدرسهای محل فعالیت برای ارسال کالیته</p>
|
||||
<BaseButton class="addresses-page__new-btn" size="sm" @click="openAddForm">
|
||||
<IconPlus :size="18" />
|
||||
آدرس جدید
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
<label class="addr-field">
|
||||
<span>عنوان (اختیاری)</span>
|
||||
<input v-model="label" type="text" maxlength="40" placeholder="مثلاً فروشگاه" />
|
||||
</label>
|
||||
<label class="addr-field">
|
||||
<span>آدرس</span>
|
||||
<textarea v-model="text" rows="3" maxlength="500" placeholder="آدرس کامل محل فعالیت" />
|
||||
</label>
|
||||
<BaseButton :loading="saving" @click="add">افزودن آدرس</BaseButton>
|
||||
</ProfileSubpageLayout>
|
||||
<p v-if="pageError" class="addr-error" role="alert">{{ pageError }}</p>
|
||||
<p v-if="loading" class="addr-muted">در حال دریافت…</p>
|
||||
|
||||
<ul v-else-if="rows.length" class="addr-list">
|
||||
<li
|
||||
v-for="row in rows"
|
||||
:key="row.id"
|
||||
class="addr-card"
|
||||
:class="{ 'addr-card--default': row.isDefault }"
|
||||
>
|
||||
<div class="addr-card__main">
|
||||
<div class="addr-card__icon" aria-hidden="true">
|
||||
<IconMapPin :size="20" />
|
||||
</div>
|
||||
<div class="addr-card__body">
|
||||
<div class="addr-card__head">
|
||||
<span v-if="row.isDefault" class="addr-default-badge">
|
||||
<IconVerified :size="16" />
|
||||
پیشفرض ارسال
|
||||
</span>
|
||||
<strong class="addr-card__label">{{ row.label || 'آدرس' }}</strong>
|
||||
</div>
|
||||
<p class="addr-card__text">{{ row.text }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="addr-card__actions">
|
||||
<button
|
||||
v-if="!row.isDefault"
|
||||
type="button"
|
||||
class="addr-icon-btn"
|
||||
aria-label="انتخاب بهعنوان آدرس پیشفرض"
|
||||
title="پیشفرض"
|
||||
@click="makeDefault(row.id)"
|
||||
>
|
||||
<IconVerified :size="20" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="addr-icon-btn addr-icon-btn--danger"
|
||||
aria-label="حذف آدرس"
|
||||
title="حذف"
|
||||
@click="remove(row.id)"
|
||||
>
|
||||
<IconTrash :size="20" />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div v-else class="addr-empty">
|
||||
<IconMapPin :size="40" class="addr-empty__icon" />
|
||||
<p class="addr-muted">هنوز آدرسی ثبت نشده است.</p>
|
||||
<BaseButton size="sm" variant="outline" @click="openAddForm">افزودن اولین آدرس</BaseButton>
|
||||
</div>
|
||||
</ProfileSubpageLayout>
|
||||
|
||||
<!-- Mobile: action sheet -->
|
||||
<Teleport to="body">
|
||||
<Transition name="addr-sheet">
|
||||
<div
|
||||
v-if="addOpen && !isDesktop"
|
||||
class="addr-add-panel"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="addr-add-title-mobile"
|
||||
@click.self="closeAddForm"
|
||||
>
|
||||
<div class="addr-add-sheet">
|
||||
<div class="addr-add-sheet__handle" aria-hidden="true" />
|
||||
<header class="addr-add-sheet__head">
|
||||
<h2 id="addr-add-title-mobile">آدرس جدید</h2>
|
||||
<button type="button" class="addr-add-sheet__close" aria-label="بستن" @click="closeAddForm">
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
<form class="addr-form" @submit.prevent="add">
|
||||
<label class="addr-field">
|
||||
<span>عنوان (اختیاری)</span>
|
||||
<input v-model="label" type="text" maxlength="40" placeholder="مثلاً فروشگاه" />
|
||||
</label>
|
||||
<label class="addr-field">
|
||||
<span>آدرس</span>
|
||||
<textarea
|
||||
v-model="text"
|
||||
rows="3"
|
||||
maxlength="500"
|
||||
placeholder="آدرس کامل محل فعالیت"
|
||||
/>
|
||||
</label>
|
||||
<p v-if="formError" class="addr-error" role="alert">{{ formError }}</p>
|
||||
<BaseButton type="submit" class="addr-form__submit" :loading="saving">
|
||||
افزودن آدرس
|
||||
</BaseButton>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<!-- Desktop: modal -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="addOpen && isDesktop"
|
||||
class="dialog-overlay addr-add-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="addr-add-title-desktop"
|
||||
@click.self="closeAddForm"
|
||||
>
|
||||
<div class="dialog card addr-add-modal">
|
||||
<h2 id="addr-add-title-desktop" class="addr-add-modal__title">آدرس جدید</h2>
|
||||
<p class="addr-add-modal__hint">آدرس کامل محل فعالیت را وارد کنید.</p>
|
||||
<form class="addr-form" @submit.prevent="add">
|
||||
<label class="addr-field">
|
||||
<span>عنوان (اختیاری)</span>
|
||||
<input v-model="label" type="text" maxlength="40" placeholder="مثلاً فروشگاه" />
|
||||
</label>
|
||||
<label class="addr-field">
|
||||
<span>آدرس</span>
|
||||
<textarea
|
||||
v-model="text"
|
||||
rows="3"
|
||||
maxlength="500"
|
||||
placeholder="آدرس کامل محل فعالیت"
|
||||
/>
|
||||
</label>
|
||||
<p v-if="formError" class="addr-error" role="alert">{{ formError }}</p>
|
||||
<div class="dialog-actions addr-add-modal__actions">
|
||||
<BaseButton type="button" variant="outline" :disabled="saving" @click="closeAddForm">
|
||||
انصراف
|
||||
</BaseButton>
|
||||
<BaseButton type="submit" :loading="saving">افزودن آدرس</BaseButton>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.addresses-page :deep(.profile-subpage__card.card) {
|
||||
background: var(--color-bg);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.addresses-page :deep(.profile-subpage__nav) {
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.addresses-page :deep(.profile-subpage-desktop__panel.card) {
|
||||
background: var(--color-bg);
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.addresses-page__toolbar {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.addresses-page__hint {
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.addresses-page__new-btn {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.addr-error {
|
||||
margin: 0 0 12px;
|
||||
color: var(--color-danger);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.addr-muted {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.addr-list {
|
||||
list-style: none;
|
||||
margin: 0 0 16px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.addr-item {
|
||||
.addr-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 14px;
|
||||
background: var(--color-elevated);
|
||||
}
|
||||
|
||||
.addr-card--default {
|
||||
border-color: rgba(var(--color-primary-rgb), 0.45);
|
||||
background: var(--color-primary-soft);
|
||||
box-shadow: 0 0 0 1px rgba(var(--color-primary-rgb), 0.08);
|
||||
}
|
||||
|
||||
.addr-card__main {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.addr-card__icon {
|
||||
flex-shrink: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.addr-item p {
|
||||
margin: 6px 0 0;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.addr-badge {
|
||||
margin-right: 8px;
|
||||
font-size: 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.addr-actions {
|
||||
.addr-card--default .addr-card__icon {
|
||||
background: var(--color-elevated);
|
||||
}
|
||||
|
||||
.addr-card__body {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.addr-card__head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.addr-default-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-primary);
|
||||
background: var(--color-elevated);
|
||||
}
|
||||
|
||||
.addr-card--default .addr-default-badge {
|
||||
background: rgba(var(--color-primary-rgb), 0.12);
|
||||
}
|
||||
|
||||
.addr-card__label {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.addr-card__text {
|
||||
margin: 8px 0 0;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.65;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.addr-card__actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.addr-actions button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
.addr-icon-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-primary);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.addr-remove {
|
||||
color: var(--color-danger) !important;
|
||||
.addr-card--default .addr-icon-btn {
|
||||
background: var(--color-elevated);
|
||||
}
|
||||
|
||||
.addr-icon-btn:hover {
|
||||
background: var(--color-primary-soft);
|
||||
}
|
||||
|
||||
.addr-icon-btn--danger {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.addr-icon-btn--danger:hover {
|
||||
background: rgba(220, 38, 38, 0.1);
|
||||
}
|
||||
|
||||
.addr-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 32px 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.addr-empty__icon {
|
||||
color: var(--color-text-muted);
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.addr-field {
|
||||
@@ -173,6 +476,126 @@ async function remove(id: string) {
|
||||
font: inherit;
|
||||
font-weight: 400;
|
||||
color: var(--color-text);
|
||||
background: transparent;
|
||||
background: var(--color-elevated);
|
||||
}
|
||||
|
||||
.addr-form__submit {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Mobile sheet */
|
||||
.addr-add-panel {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 200;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.addr-add-sheet {
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
max-height: min(88dvh, 640px);
|
||||
overflow-y: auto;
|
||||
background: var(--color-elevated);
|
||||
color: var(--color-text);
|
||||
border-radius: 20px 20px 0 0;
|
||||
padding: 10px 16px calc(16px + env(safe-area-inset-bottom, 0px));
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.addr-add-sheet__handle {
|
||||
width: 40px;
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: var(--color-border);
|
||||
margin: 4px auto 12px;
|
||||
}
|
||||
|
||||
.addr-add-sheet__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.addr-add-sheet__head h2 {
|
||||
margin: 0;
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.addr-add-sheet__close {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-size: 1.4rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.addr-sheet-enter-active,
|
||||
.addr-sheet-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.addr-sheet-enter-active .addr-add-sheet,
|
||||
.addr-sheet-leave-active .addr-add-sheet {
|
||||
transition: transform 0.24s ease;
|
||||
}
|
||||
|
||||
.addr-sheet-enter-from,
|
||||
.addr-sheet-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.addr-sheet-enter-from .addr-add-sheet,
|
||||
.addr-sheet-leave-to .addr-add-sheet {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
|
||||
/* Desktop modal */
|
||||
.addr-add-overlay {
|
||||
z-index: 200;
|
||||
}
|
||||
|
||||
.addr-add-modal {
|
||||
padding: 24px;
|
||||
max-width: min(440px, calc(100vw - 48px));
|
||||
}
|
||||
|
||||
.addr-add-modal__title {
|
||||
margin: 0 0 6px;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.addr-add-modal__hint {
|
||||
margin: 0 0 16px;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.addr-add-modal__actions {
|
||||
flex-direction: row-reverse;
|
||||
justify-content: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.addr-card__actions {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.addresses-page__toolbar {
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { addressesApi } from '@/services/addresses'
|
||||
import BaseInput from '@/components/ui/BaseInput.vue'
|
||||
import FormErrorBanner from '@/components/ui/FormErrorBanner.vue'
|
||||
import BaseButton from '@/components/ui/BaseButton.vue'
|
||||
@@ -14,8 +16,13 @@ import {
|
||||
hasFieldErrors,
|
||||
type FieldErrors,
|
||||
} from '@/utils/formErrors'
|
||||
import { useAppToast } from '@/composables/useAppToast'
|
||||
import { ApiError } from '@/services/http'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const router = useRouter()
|
||||
const savedAddressText = ref<string | null>(null)
|
||||
const savedAddressLoading = ref(true)
|
||||
|
||||
const firstName = ref(auth.user?.firstName ?? '')
|
||||
const lastName = ref(auth.user?.lastName ?? '')
|
||||
@@ -26,7 +33,9 @@ const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const fieldErrors = ref<FieldErrors>({})
|
||||
const formError = ref('')
|
||||
const saved = ref(false)
|
||||
const saving = ref(false)
|
||||
const photoBusy = ref(false)
|
||||
const toast = useAppToast()
|
||||
|
||||
const initials = computed(() => {
|
||||
const first = firstName.value.trim().charAt(0)
|
||||
@@ -79,6 +88,19 @@ async function removeAvatar() {
|
||||
await persistAvatar(null)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
savedAddressLoading.value = true
|
||||
try {
|
||||
const rows = await addressesApi.list()
|
||||
const preferred = rows.find((r) => r.isDefault) ?? rows[0]
|
||||
savedAddressText.value = preferred?.text ?? null
|
||||
} catch {
|
||||
savedAddressText.value = null
|
||||
} finally {
|
||||
savedAddressLoading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
async function save() {
|
||||
fieldErrors.value = {}
|
||||
formError.value = ''
|
||||
@@ -94,6 +116,7 @@ async function save() {
|
||||
if (hasFieldErrors(errors)) {
|
||||
fieldErrors.value = errors
|
||||
formError.value = FORM_VALIDATION_SUMMARY
|
||||
toast.show('لطفاً فیلدهای مشخصشده را اصلاح کنید.', { tone: 'error' })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -107,15 +130,31 @@ async function save() {
|
||||
payload.address = address.value.trim()
|
||||
}
|
||||
|
||||
const updated = await auth.updateProfile(payload)
|
||||
if (!updated) {
|
||||
formError.value = 'ذخیره پروفایل ناموفق بود.'
|
||||
return
|
||||
saving.value = true
|
||||
try {
|
||||
const updated = await auth.updateProfile(payload)
|
||||
if (!updated) {
|
||||
formError.value = 'ذخیره پروفایل ناموفق بود.'
|
||||
toast.show('ذخیره اطلاعات انجام نشد. دوباره تلاش کنید.', { tone: 'error' })
|
||||
return
|
||||
}
|
||||
saved.value = true
|
||||
toast.show('اطلاعات کاربری با موفقیت ذخیره شد.')
|
||||
setTimeout(() => {
|
||||
saved.value = false
|
||||
}, 2000)
|
||||
} catch (e) {
|
||||
const msg =
|
||||
e instanceof ApiError
|
||||
? e.message
|
||||
: e instanceof Error
|
||||
? e.message
|
||||
: 'ذخیره پروفایل ناموفق بود.'
|
||||
formError.value = msg
|
||||
toast.show(msg, { tone: 'error' })
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
saved.value = true
|
||||
setTimeout(() => {
|
||||
saved.value = false
|
||||
}, 2000)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -191,7 +230,7 @@ async function save() {
|
||||
@update:model-value="clearField('nationalId')"
|
||||
/>
|
||||
<div v-if="auth.isAgent" class="form-group profile-edit__full">
|
||||
<label class="form-label" for="profile-address">آدرس</label>
|
||||
<label class="form-label" for="profile-address">آدرس (واسطه)</label>
|
||||
<textarea
|
||||
id="profile-address"
|
||||
v-model="address"
|
||||
@@ -203,10 +242,28 @@ async function save() {
|
||||
/>
|
||||
<p v-if="fieldErrors.address" class="input-error-text">{{ fieldErrors.address }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="form-group profile-edit__full profile-edit__saved-address">
|
||||
<label class="form-label">آدرس ثبتشده</label>
|
||||
<p v-if="savedAddressLoading" class="profile-edit__address-hint">در حال بارگذاری…</p>
|
||||
<p v-else-if="savedAddressText" class="profile-edit__address-text">{{ savedAddressText }}</p>
|
||||
<p v-else class="profile-edit__address-hint">
|
||||
هنوز آدرسی ثبت نشده. هنگام درخواست کالیته یا از بخش «آدرسها» میتوانید آدرس اضافه کنید.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="profile-edit__address-link"
|
||||
@click="router.push('/profile/addresses')"
|
||||
>
|
||||
مدیریت آدرسها
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="profile-edit__actions">
|
||||
<BaseButton class="profile-edit__save" @click="save">ذخیره تغییرات</BaseButton>
|
||||
<BaseButton class="profile-edit__save" :loading="saving" @click="save">
|
||||
ذخیره تغییرات
|
||||
</BaseButton>
|
||||
<p v-if="saved" class="profile-edit__saved">ذخیره شد ✓</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -395,4 +452,37 @@ async function save() {
|
||||
font-size: 0.8125rem;
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.profile-edit__saved-address {
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.profile-edit__address-text {
|
||||
margin: 6px 0 10px;
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.65;
|
||||
color: var(--color-text);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.profile-edit__address-hint {
|
||||
margin: 6px 0 10px;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.6;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.profile-edit__address-link {
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { onClickOutside } from '@vueuse/core'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { fetchReminders } from '@/services/agentApi'
|
||||
import { useCrmStore } from '@/stores/crm'
|
||||
@@ -13,6 +12,8 @@ import IconChevronBack from '@/components/icons/IconChevronBack.vue'
|
||||
import { useGoBack } from '@/composables/useGoBack'
|
||||
import PageSkeleton from '@/components/ui/PageSkeleton.vue'
|
||||
import PersianDateInput from '@/components/ui/PersianDateInput.vue'
|
||||
import AppSelect from '@/components/ui/AppSelect.vue'
|
||||
import TimeInput from '@/components/ui/TimeInput.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -34,23 +35,12 @@ const form = ref({
|
||||
time: '10:00',
|
||||
note: '',
|
||||
})
|
||||
const customerOpen = ref(false)
|
||||
const customerRoot = ref<HTMLElement | null>(null)
|
||||
|
||||
const selectedCustomerLabel = computed(() => {
|
||||
if (!form.value.customerId) return 'انتخاب مشتری'
|
||||
const customer = customers.value.find((item) => item.id === form.value.customerId)
|
||||
return customer?.name || customer?.contactName || 'انتخاب مشتری'
|
||||
})
|
||||
|
||||
onClickOutside(customerRoot, () => {
|
||||
customerOpen.value = false
|
||||
})
|
||||
|
||||
function selectCustomer(id: string) {
|
||||
form.value.customerId = id
|
||||
customerOpen.value = false
|
||||
}
|
||||
const customerOptions = computed(() =>
|
||||
customers.value.map((customer) => ({
|
||||
value: customer.id,
|
||||
label: customer.name || customer.contactName || customer.id,
|
||||
})),
|
||||
)
|
||||
|
||||
const filters: Array<{ id: ReminderFilter; label: string }> = [
|
||||
{ id: 'all', label: 'همه' },
|
||||
@@ -134,7 +124,6 @@ function typeClass(type: MeetingInteractionType) {
|
||||
async function openCreate() {
|
||||
showCreate.value = true
|
||||
completedExpanded.value = false
|
||||
customerOpen.value = false
|
||||
error.value = null
|
||||
try {
|
||||
customers.value = await crm.loadCustomers()
|
||||
@@ -248,54 +237,21 @@ function toggleCompleted() {
|
||||
<section v-if="showCreate" class="create-panel">
|
||||
<h3>یادآور جدید</h3>
|
||||
<p v-if="error" class="create-error">{{ error }}</p>
|
||||
<label>
|
||||
مشتری
|
||||
<div ref="customerRoot" class="field-select">
|
||||
<button
|
||||
type="button"
|
||||
class="field-select__trigger"
|
||||
:class="{ 'is-open': customerOpen }"
|
||||
aria-haspopup="listbox"
|
||||
:aria-expanded="customerOpen"
|
||||
@click="customerOpen = !customerOpen"
|
||||
>
|
||||
<span :class="{ 'is-placeholder': !form.customerId }">{{ selectedCustomerLabel }}</span>
|
||||
<svg class="field-select__chevron" viewBox="0 0 12 12" aria-hidden="true">
|
||||
<path
|
||||
d="M2.5 4.5 6 8l3.5-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.4"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<ul v-if="customerOpen" class="field-select__menu" role="listbox">
|
||||
<li v-for="customer in customers" :key="customer.id">
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
class="field-select__option"
|
||||
:class="{ 'is-active': form.customerId === customer.id }"
|
||||
:aria-selected="form.customerId === customer.id"
|
||||
@click="selectCustomer(customer.id)"
|
||||
>
|
||||
{{ customer.name || customer.contactName }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</label>
|
||||
<AppSelect
|
||||
v-model="form.customerId"
|
||||
label="مشتری"
|
||||
placeholder="انتخاب مشتری"
|
||||
aria-label="مشتری"
|
||||
searchable
|
||||
:options="customerOptions"
|
||||
/>
|
||||
<label>
|
||||
عنوان
|
||||
<input v-model="form.title" type="text" placeholder="مثلاً پیگیری نمونه" />
|
||||
</label>
|
||||
<div class="create-row">
|
||||
<PersianDateInput v-model="form.date" label="تاریخ" class="create-date" />
|
||||
<label>
|
||||
ساعت
|
||||
<input v-model="form.time" type="time" />
|
||||
</label>
|
||||
<TimeInput v-model="form.time" label="ساعت" placeholder="مثلاً ۱۰:۳۰" />
|
||||
</div>
|
||||
<label>
|
||||
یادداشت
|
||||
@@ -904,83 +860,6 @@ function toggleCompleted() {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.field-select {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.field-select__trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
height: var(--btn-height);
|
||||
padding: 0 14px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text);
|
||||
font: inherit;
|
||||
font-size: 0.9375rem;
|
||||
text-align: start;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.field-select__trigger.is-open {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.field-select__trigger .is-placeholder {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.field-select__chevron {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
color: var(--color-text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.field-select__trigger.is-open .field-select__chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.field-select__menu {
|
||||
position: absolute;
|
||||
inset-inline: 0;
|
||||
top: calc(100% + 6px);
|
||||
z-index: 20;
|
||||
margin: 0;
|
||||
padding: 6px;
|
||||
list-style: none;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 8px 24px rgba(16, 24, 40, 0.08);
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.field-select__option {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font: inherit;
|
||||
font-size: 0.875rem;
|
||||
text-align: start;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.field-select__option.is-active {
|
||||
background: var(--color-primary-light);
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.create-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
|
||||
Reference in New Issue
Block a user