This commit is contained in:
2026-09-29 22:01:22 +03:30
parent c332b99130
commit 38d6b0d978
136 changed files with 2344 additions and 972 deletions
+2
View File
@@ -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>
+4 -4
View File
@@ -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 {
+2 -2
View File
@@ -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;
}
-1
View File
@@ -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>
+26
View File
@@ -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>
+413
View File
@@ -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>
+106
View File
@@ -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>
+50 -7
View File
@@ -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
View File
@@ -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>
+34
View File
@@ -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 }
}
+3 -6
View File
@@ -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,
-2
View File
@@ -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
View File
@@ -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) {
+4
View File
@@ -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
+2 -1
View File
@@ -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[] {
+7
View File
@@ -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> = {
+8 -1
View File
@@ -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 {
+3 -1
View File
@@ -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>
+47 -8
View File
@@ -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
View File
@@ -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;
+25 -31
View File
@@ -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;
+9 -24
View File
@@ -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 {
+41 -334
View File
@@ -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 {
+487 -64
View File
@@ -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>
+101 -11
View File
@@ -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>
+17 -138
View File
@@ -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;