.
This commit is contained in:
@@ -146,6 +146,23 @@ body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Horizontal carousels: smooth touch scroll, no visible scrollbar */
|
||||
.scroll-x {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior-x: contain;
|
||||
scroll-behavior: smooth;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.scroll-x::-webkit-scrollbar {
|
||||
display: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, ref, watch } from 'vue'
|
||||
import BaseButton from '@/components/ui/BaseButton.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
loading?: boolean
|
||||
error?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
submit: [address: string]
|
||||
}>()
|
||||
|
||||
const address = ref('')
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(isOpen) => {
|
||||
if (isOpen) address.value = ''
|
||||
},
|
||||
)
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape' && props.open) emit('close')
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(isOpen) => {
|
||||
document.body.style.overflow = isOpen ? 'hidden' : ''
|
||||
if (isOpen) {
|
||||
window.addEventListener('keydown', onKeydown)
|
||||
} else {
|
||||
window.removeEventListener('keydown', onKeydown)
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.body.style.overflow = ''
|
||||
window.removeEventListener('keydown', onKeydown)
|
||||
})
|
||||
|
||||
function submit() {
|
||||
emit('submit', address.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="open"
|
||||
class="dialog-overlay address-modal-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="address-modal-title"
|
||||
@click.self="emit('close')"
|
||||
>
|
||||
<div class="dialog card address-modal">
|
||||
<h3 id="address-modal-title" class="address-modal__title">تکمیل آدرس</h3>
|
||||
<p class="address-modal__hint">
|
||||
برای ارسال کالیته فیزیکی، آدرس محل فعالیت خود را وارد کنید. این آدرس در اطلاعات کاربری شما
|
||||
ذخیره میشود.
|
||||
</p>
|
||||
<label class="address-modal__field">
|
||||
<span class="form-label">آدرس</span>
|
||||
<textarea
|
||||
v-model="address"
|
||||
class="address-modal__textarea"
|
||||
rows="3"
|
||||
maxlength="500"
|
||||
placeholder="آدرس کامل محل فعالیت"
|
||||
/>
|
||||
</label>
|
||||
<p v-if="error" class="address-modal__error">{{ error }}</p>
|
||||
<div class="dialog-actions address-modal__actions">
|
||||
<BaseButton variant="outline" :disabled="loading" @click="emit('close')">
|
||||
انصراف
|
||||
</BaseButton>
|
||||
<BaseButton :disabled="loading" @click="submit">
|
||||
{{ loading ? 'در حال ثبت...' : 'ثبت درخواست' }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.address-modal {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.address-modal__title {
|
||||
margin: 0 0 8px;
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.address-modal__hint {
|
||||
margin: 0 0 14px;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.address-modal__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.address-modal__textarea {
|
||||
width: 100%;
|
||||
min-height: 96px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font: inherit;
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.6;
|
||||
color: var(--color-text);
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.address-modal__textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.address-modal__error {
|
||||
margin: 8px 0 0;
|
||||
color: var(--color-danger);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.address-modal__actions {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.address-modal__actions {
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
size?: number
|
||||
color?: string
|
||||
}>(),
|
||||
{ size: 22, color: 'currentColor' },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
:width="size"
|
||||
:height="size"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
:stroke="color"
|
||||
stroke-width="1.6"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="3" y="8" width="18" height="8" rx="1.5" />
|
||||
<path d="M7 8v3" />
|
||||
<path d="M10 8v4" />
|
||||
<path d="M13 8v3" />
|
||||
<path d="M16 8v4" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
size?: number
|
||||
color?: string
|
||||
}>(),
|
||||
{ size: 22, color: 'currentColor' },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
:width="size"
|
||||
:height="size"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
:stroke="color"
|
||||
stroke-width="1.6"
|
||||
stroke-linecap="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M6 4v16" />
|
||||
<path d="M10 4v16" />
|
||||
<path d="M14 4v16" />
|
||||
<path d="M18 4v16" />
|
||||
<path d="M4 8h16" />
|
||||
<path d="M4 12h16" />
|
||||
<path d="M4 16h16" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,327 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import type { CatalogProductCard } from '@/types/crm'
|
||||
import { formatNumber } from '@/utils/format'
|
||||
import { colorSwatchStyle } from '@/utils/colorSwatch'
|
||||
import BaseButton from '@/components/ui/BaseButton.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
product: CatalogProductCard | null
|
||||
rollMeters: number
|
||||
minRolls: number
|
||||
basePrice?: number
|
||||
loading?: boolean
|
||||
error?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
submit: [rollsByColor: number[]]
|
||||
}>()
|
||||
|
||||
const rollsByColor = ref<number[]>([])
|
||||
|
||||
watch(
|
||||
() => [props.open, props.product?.colors.length] as const,
|
||||
([isOpen]) => {
|
||||
if (!isOpen || !props.product) return
|
||||
rollsByColor.value = props.product.colors.map(() => 0)
|
||||
},
|
||||
)
|
||||
|
||||
const totalRolls = computed(() =>
|
||||
rollsByColor.value.reduce((sum, qty) => sum + (qty > 0 ? qty : 0), 0),
|
||||
)
|
||||
|
||||
const totalMeters = computed(() => totalRolls.value * props.rollMeters)
|
||||
|
||||
const estimatedPrice = computed(() => {
|
||||
if (!props.basePrice || totalMeters.value <= 0) return null
|
||||
return props.basePrice * totalMeters.value
|
||||
})
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape' && props.open) emit('close')
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(isOpen) => {
|
||||
document.body.style.overflow = isOpen ? 'hidden' : ''
|
||||
if (isOpen) {
|
||||
window.addEventListener('keydown', onKeydown)
|
||||
} else {
|
||||
window.removeEventListener('keydown', onKeydown)
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.body.style.overflow = ''
|
||||
window.removeEventListener('keydown', onKeydown)
|
||||
})
|
||||
|
||||
function adjustRoll(index: number, delta: number) {
|
||||
const next = [...rollsByColor.value]
|
||||
next[index] = Math.max(0, (next[index] ?? 0) + delta)
|
||||
rollsByColor.value = next
|
||||
}
|
||||
|
||||
function submit() {
|
||||
emit('submit', [...rollsByColor.value])
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="open && product"
|
||||
class="dialog-overlay product-order-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="product-order-title"
|
||||
@click.self="emit('close')"
|
||||
>
|
||||
<div class="dialog card product-order-modal">
|
||||
<header class="product-order-modal__head">
|
||||
<h3 id="product-order-title" class="product-order-modal__title">ثبت سفارش</h3>
|
||||
<button type="button" class="product-order-modal__close" aria-label="بستن" @click="emit('close')">
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="product-order-modal__table-head" aria-hidden="true">
|
||||
<span>رنگ</span>
|
||||
<span>تعداد</span>
|
||||
</div>
|
||||
|
||||
<ul class="product-order-modal__rows">
|
||||
<li v-for="(color, idx) in product.colors" :key="idx" class="product-order-row">
|
||||
<div class="product-order-row__color">
|
||||
<span
|
||||
class="product-order-row__swatch"
|
||||
:style="colorSwatchStyle(color, product.colorImages?.[idx] || product.imageUrls?.[idx])"
|
||||
:title="color"
|
||||
/>
|
||||
<span v-if="color" class="product-order-row__label">{{ color }}</span>
|
||||
</div>
|
||||
<div class="product-order-row__stepper">
|
||||
<button
|
||||
type="button"
|
||||
class="product-order-row__btn"
|
||||
aria-label="کاهش"
|
||||
:disabled="loading || (rollsByColor[idx] ?? 0) <= 0"
|
||||
@click="adjustRoll(idx, -1)"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span class="product-order-row__qty">{{ formatNumber(rollsByColor[idx] ?? 0) }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="product-order-row__btn"
|
||||
aria-label="افزایش"
|
||||
:disabled="loading"
|
||||
@click="adjustRoll(idx, 1)"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p v-if="error" class="product-order-modal__error">{{ error }}</p>
|
||||
|
||||
<footer class="product-order-modal__footer">
|
||||
<div class="product-order-modal__price">
|
||||
<span>قیمت</span>
|
||||
<strong v-if="estimatedPrice != null">
|
||||
{{ formatNumber(estimatedPrice) }} تومان
|
||||
</strong>
|
||||
<strong v-else-if="totalRolls > 0">
|
||||
{{ formatNumber(totalMeters) }} متر
|
||||
</strong>
|
||||
<strong v-else>—</strong>
|
||||
</div>
|
||||
<p v-if="minRolls > 0" class="product-order-modal__hint">
|
||||
حداقل سفارش: {{ formatNumber(minRolls) }} طاقه
|
||||
</p>
|
||||
<BaseButton class="product-order-modal__submit" :disabled="loading" @click="submit">
|
||||
{{ loading ? 'در حال ثبت...' : 'ثبت' }}
|
||||
</BaseButton>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.product-order-modal {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
padding: 18px 18px 16px;
|
||||
}
|
||||
|
||||
.product-order-modal__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.product-order-modal__title {
|
||||
margin: 0;
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.product-order-modal__close {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.product-order-modal__table-head {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 12px;
|
||||
padding: 0 4px 8px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.product-order-modal__rows {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
max-height: min(52vh, 360px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.product-order-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 14px;
|
||||
background: var(--color-elevated);
|
||||
}
|
||||
|
||||
.product-order-row__color {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.product-order-row__swatch {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.product-order-row__label {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.product-order-row__stepper {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.product-order-row__btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 10px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font-size: 1.125rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.product-order-row__btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.product-order-row__qty {
|
||||
min-width: 1.5rem;
|
||||
text-align: center;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.product-order-modal__error {
|
||||
margin: 10px 0 0;
|
||||
color: var(--color-danger);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.product-order-modal__footer {
|
||||
margin-top: 14px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.product-order-modal__price {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.product-order-modal__price strong {
|
||||
font-size: 1rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.product-order-modal__hint {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.product-order-modal__submit {
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.product-order-overlay {
|
||||
align-items: flex-end;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.product-order-modal {
|
||||
max-width: none;
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,165 @@
|
||||
<script setup lang="ts">
|
||||
import type { SimpleTimelineStep } from '@/utils/simpleTimeline'
|
||||
|
||||
const props = defineProps<{
|
||||
steps: SimpleTimelineStep[]
|
||||
currentIndex: number
|
||||
timestamps?: Record<string, string | null | undefined>
|
||||
}>()
|
||||
|
||||
function stepState(index: number): 'done' | 'current' | 'pending' {
|
||||
if (index < props.currentIndex) return 'done'
|
||||
if (index === props.currentIndex) return 'current'
|
||||
return 'pending'
|
||||
}
|
||||
|
||||
function timestampFor(stepId: string) {
|
||||
return props.timestamps?.[stepId] ?? null
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tracking-timeline">
|
||||
<div
|
||||
v-for="(step, index) in steps"
|
||||
:key="step.id"
|
||||
class="tracking-timeline__item"
|
||||
:class="`tracking-timeline__item--${stepState(index)}`"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="tracking-timeline__content"
|
||||
:class="{ 'tracking-timeline__content--current': stepState(index) === 'current' }"
|
||||
>
|
||||
<strong class="tracking-timeline__label">{{ step.label }}</strong>
|
||||
<span v-if="timestampFor(step.id)" class="tracking-timeline__time">
|
||||
{{ timestampFor(step.id) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tracking-timeline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tracking-timeline__item {
|
||||
display: grid;
|
||||
grid-template-columns: 28px minmax(0, 1fr);
|
||||
column-gap: 12px;
|
||||
align-items: start;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.tracking-timeline__marker-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-self: stretch;
|
||||
width: 28px;
|
||||
}
|
||||
|
||||
.tracking-timeline__line {
|
||||
position: absolute;
|
||||
top: 22px;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
width: 2px;
|
||||
transform: translateX(-50%);
|
||||
background: var(--color-border);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.tracking-timeline__item:last-child .tracking-timeline__line {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tracking-timeline__item--done .tracking-timeline__line {
|
||||
background: var(--color-success);
|
||||
}
|
||||
|
||||
.tracking-timeline__item--current .tracking-timeline__line {
|
||||
background: color-mix(in srgb, var(--color-primary) 35%, var(--color-border));
|
||||
}
|
||||
|
||||
.tracking-timeline__marker {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
margin-top: 2px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-border);
|
||||
border: 2px solid var(--color-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tracking-timeline__item--done .tracking-timeline__marker {
|
||||
background: var(--color-success);
|
||||
border-color: var(--color-success);
|
||||
}
|
||||
|
||||
.tracking-timeline__item--current .tracking-timeline__marker {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 28%, transparent);
|
||||
}
|
||||
|
||||
.tracking-timeline__item--pending .tracking-timeline__marker {
|
||||
background: var(--color-elevated);
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
.tracking-timeline__check {
|
||||
color: var(--color-surface);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.tracking-timeline__content {
|
||||
min-width: 0;
|
||||
padding: 2px 0 14px;
|
||||
}
|
||||
|
||||
.tracking-timeline__content--current {
|
||||
padding: 10px 14px 12px;
|
||||
border-radius: 12px;
|
||||
background: var(--color-primary-soft);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.tracking-timeline__label {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.45;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.tracking-timeline__item--done .tracking-timeline__label,
|
||||
.tracking-timeline__item--current .tracking-timeline__label {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.tracking-timeline__time {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,98 @@
|
||||
import { ref } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useCatalogRequestsStore } from '@/stores/catalogRequests'
|
||||
import type { CatalogProductCard } from '@/types/crm'
|
||||
|
||||
const SUCCESS_MESSAGE =
|
||||
'درخواست دریافت فیزیکی کالیته برای شما ثبت شد و به زودی همکاران ما برای شما خواهند آورد'
|
||||
|
||||
export function useCatalogRequest() {
|
||||
const auth = useAuthStore()
|
||||
const requestsStore = useCatalogRequestsStore()
|
||||
|
||||
const requesting = ref(false)
|
||||
const successMessage = ref('')
|
||||
const addressModalOpen = ref(false)
|
||||
const pendingProduct = ref<CatalogProductCard | null>(null)
|
||||
const addressError = ref('')
|
||||
|
||||
function closeAddressModal() {
|
||||
addressModalOpen.value = false
|
||||
pendingProduct.value = null
|
||||
addressError.value = ''
|
||||
}
|
||||
|
||||
async function createRequest(product: CatalogProductCard, address: string) {
|
||||
const result = await requestsStore.create({
|
||||
items: [
|
||||
{
|
||||
productId: product.id,
|
||||
productName: product.name,
|
||||
productCode: product.code,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
note: `آدرس: ${address}`,
|
||||
})
|
||||
if (!result.success) {
|
||||
addressError.value = result.message
|
||||
return false
|
||||
}
|
||||
successMessage.value = SUCCESS_MESSAGE
|
||||
window.setTimeout(() => {
|
||||
successMessage.value = ''
|
||||
}, 6000)
|
||||
return true
|
||||
}
|
||||
|
||||
async function requestCatalog(product: CatalogProductCard) {
|
||||
addressError.value = ''
|
||||
const savedAddress = auth.user?.address?.trim()
|
||||
if (savedAddress) {
|
||||
requesting.value = true
|
||||
try {
|
||||
await createRequest(product, savedAddress)
|
||||
} finally {
|
||||
requesting.value = false
|
||||
}
|
||||
return
|
||||
}
|
||||
pendingProduct.value = product
|
||||
addressModalOpen.value = true
|
||||
}
|
||||
|
||||
async function submitAddressAndRequest(address: string) {
|
||||
const trimmed = address.trim()
|
||||
if (!trimmed) {
|
||||
addressError.value = 'آدرس الزامی است.'
|
||||
return false
|
||||
}
|
||||
const product = pendingProduct.value
|
||||
if (!product) return false
|
||||
|
||||
addressError.value = ''
|
||||
requesting.value = true
|
||||
try {
|
||||
const updated = await auth.updateProfile({ address: trimmed })
|
||||
if (!updated) {
|
||||
addressError.value = 'ذخیره آدرس ناموفق بود.'
|
||||
return false
|
||||
}
|
||||
const ok = await createRequest(product, trimmed)
|
||||
if (ok) closeAddressModal()
|
||||
return ok
|
||||
} finally {
|
||||
requesting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
requesting,
|
||||
successMessage,
|
||||
addressModalOpen,
|
||||
addressError,
|
||||
requestCatalog,
|
||||
submitAddressAndRequest,
|
||||
closeAddressModal,
|
||||
}
|
||||
}
|
||||
+17
-12
@@ -65,13 +65,7 @@ const router = createRouter({
|
||||
{
|
||||
path: '/catalogs/:catalogId',
|
||||
name: 'catalog-detail',
|
||||
component: () => import('@/views/catalogs/CatalogDetailView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/catalog-request',
|
||||
name: 'catalog-request',
|
||||
component: () => import('@/views/catalogs/CatalogRequestView.vue'),
|
||||
component: () => import('@/views/catalogs/CatalogProductRedirectView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
@@ -80,6 +74,12 @@ const router = createRouter({
|
||||
component: () => import('@/views/catalogs/CatalogRequestsView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/catalog-requests/latest-status',
|
||||
name: 'catalog-request-latest-status',
|
||||
component: () => import('@/views/catalogs/LatestCatalogRequestStatusView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/favorites',
|
||||
name: 'favorites',
|
||||
@@ -90,7 +90,7 @@ const router = createRouter({
|
||||
path: '/customers',
|
||||
name: 'customers',
|
||||
component: () => import('@/views/customers/CustomersView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
meta: { requiresAuth: true, agentOnly: true },
|
||||
},
|
||||
{
|
||||
path: '/customers/new',
|
||||
@@ -103,7 +103,7 @@ const router = createRouter({
|
||||
path: '/customer',
|
||||
name: 'customer',
|
||||
component: () => import('@/views/customers/CustomerDetailView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
meta: { requiresAuth: true, agentOnly: true },
|
||||
},
|
||||
{
|
||||
path: '/meeting-report',
|
||||
@@ -120,13 +120,13 @@ const router = createRouter({
|
||||
path: '/reminders',
|
||||
name: 'reminders',
|
||||
component: () => import('@/views/reminders/RemindersView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
meta: { requiresAuth: true, agentOnly: true },
|
||||
},
|
||||
{
|
||||
path: '/reminder',
|
||||
name: 'reminder',
|
||||
component: () => import('@/views/reminders/ReminderDetailView.vue'),
|
||||
meta: { requiresAuth: true, hideNav: true },
|
||||
meta: { requiresAuth: true, agentOnly: true, hideNav: true },
|
||||
},
|
||||
{
|
||||
path: '/companies/:companyId/catalogs',
|
||||
@@ -157,6 +157,12 @@ const router = createRouter({
|
||||
component: () => import('@/views/OrdersView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/orders/latest-status',
|
||||
name: 'order-latest-status',
|
||||
component: () => import('@/views/orders/LatestOrderStatusView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/order',
|
||||
name: 'order-query',
|
||||
@@ -264,7 +270,6 @@ router.beforeEach((to) => {
|
||||
return { name: 'home' }
|
||||
}
|
||||
|
||||
// صفحات CRM برای همه قابل مشاهدهاند (طبق طراحی موکاپ)؛ API برای غیرواسطه خطا میدهد
|
||||
if (to.meta.agentOnly) {
|
||||
const user = getCachedUser()
|
||||
if (user?.appRole !== 'agent') {
|
||||
|
||||
@@ -136,7 +136,7 @@ export async function fetchMe(): Promise<User | null> {
|
||||
}
|
||||
|
||||
export async function updateProfile(
|
||||
data: Partial<Pick<User, 'firstName' | 'lastName' | 'nationalId' | 'avatarUrl'>>,
|
||||
data: Partial<Pick<User, 'firstName' | 'lastName' | 'nationalId' | 'avatarUrl' | 'address'>>,
|
||||
): Promise<User | null> {
|
||||
try {
|
||||
const updated = await apiRequest<User>('/auth/profile', {
|
||||
@@ -150,7 +150,8 @@ export async function updateProfile(
|
||||
data.avatarUrl !== undefined &&
|
||||
data.firstName === undefined &&
|
||||
data.lastName === undefined &&
|
||||
data.nationalId === undefined
|
||||
data.nationalId === undefined &&
|
||||
data.address === undefined
|
||||
const current = getCachedUser()
|
||||
if (!avatarOnly || !current) throw e
|
||||
const merged: User = { ...current }
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { apiRequest } from './http'
|
||||
|
||||
export type ProductSuggestionDto = {
|
||||
id: string
|
||||
productId: string
|
||||
productName: string
|
||||
productCode: string
|
||||
catalogName?: string
|
||||
catalogCode?: string
|
||||
color?: string
|
||||
subtitle?: string
|
||||
imageUrl?: string
|
||||
isNew?: boolean
|
||||
note?: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export async function fetchMySuggestions(): Promise<ProductSuggestionDto[]> {
|
||||
return apiRequest<ProductSuggestionDto[]>('/suggestions/me')
|
||||
}
|
||||
+1
-1
@@ -108,7 +108,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
}
|
||||
|
||||
async function updateProfile(
|
||||
data: Partial<Pick<User, 'firstName' | 'lastName' | 'nationalId' | 'avatarUrl'>>,
|
||||
data: Partial<Pick<User, 'firstName' | 'lastName' | 'nationalId' | 'avatarUrl' | 'address'>>,
|
||||
) {
|
||||
if (!user.value) return null
|
||||
const updated = await authUpdateProfile(data)
|
||||
|
||||
+24
-5
@@ -192,6 +192,8 @@ export const useCartStore = defineStore('cart', () => {
|
||||
catalogCode: string,
|
||||
meterage: number,
|
||||
company: Company,
|
||||
notes?: string,
|
||||
accumulate = false,
|
||||
): { success: boolean; message?: string } {
|
||||
const cart = getOrCreateCart(company)
|
||||
const existing = cart.items.find((i) => i.productId === product.id)
|
||||
@@ -199,7 +201,16 @@ export const useCartStore = defineStore('cart', () => {
|
||||
|
||||
if (existing) {
|
||||
const index = items.findIndex((i) => i.id === existing.id)
|
||||
items[index] = { ...existing, meterage }
|
||||
const mergedNotes = notes
|
||||
? [existing.notes, notes].filter(Boolean).join(' | ')
|
||||
: existing.notes
|
||||
const nextMeterage = accumulate ? existing.meterage + meterage : meterage
|
||||
items[index] = {
|
||||
...existing,
|
||||
meterage: nextMeterage,
|
||||
color: product.color || existing.color,
|
||||
notes: mergedNotes || undefined,
|
||||
}
|
||||
} else {
|
||||
const item: CartItem = {
|
||||
id: `local-item-${product.id}-${Date.now()}`,
|
||||
@@ -209,6 +220,7 @@ export const useCartStore = defineStore('cart', () => {
|
||||
productCode: product.code,
|
||||
color: product.color,
|
||||
meterage,
|
||||
notes: notes || undefined,
|
||||
companyId: company.id,
|
||||
companyName: company.name,
|
||||
}
|
||||
@@ -230,6 +242,8 @@ export const useCartStore = defineStore('cart', () => {
|
||||
product: Product,
|
||||
catalogCode: string,
|
||||
meterage: number,
|
||||
notes?: string,
|
||||
accumulate = false,
|
||||
): Promise<{ success: boolean; message?: string }> {
|
||||
if (!catalogStore.companies.length) {
|
||||
await catalogStore.loadCompanies()
|
||||
@@ -242,9 +256,11 @@ export const useCartStore = defineStore('cart', () => {
|
||||
: 0
|
||||
const existing = cart.items.find((i) => i.productId === product.id)
|
||||
const previousMeterage = existing?.meterage ?? 0
|
||||
const nextMeterage =
|
||||
existing && accumulate ? previousMeterage + meterage : meterage
|
||||
|
||||
const validation = validateMeterage(
|
||||
meterage,
|
||||
nextMeterage,
|
||||
product,
|
||||
currentCompanyMeterage,
|
||||
companyMax,
|
||||
@@ -258,20 +274,23 @@ export const useCartStore = defineStore('cart', () => {
|
||||
if (!company) {
|
||||
return { success: false, message: 'شرکت محصول یافت نشد.' }
|
||||
}
|
||||
return addItemLocal(product, catalogCode, meterage, company)
|
||||
return addItemLocal(product, catalogCode, meterage, company, notes, accumulate)
|
||||
}
|
||||
|
||||
try {
|
||||
let serverCart: ApiCart
|
||||
const mergedNotes = notes
|
||||
? [existing?.notes, notes].filter(Boolean).join(' | ')
|
||||
: existing?.notes
|
||||
if (existing) {
|
||||
serverCart = await apiRequest<ApiCart>(`/carts/items/${existing.id}`, {
|
||||
method: 'PATCH',
|
||||
body: { meterage },
|
||||
body: { meterage: nextMeterage, notes: mergedNotes || undefined },
|
||||
})
|
||||
} else {
|
||||
serverCart = await apiRequest<ApiCart>('/carts/items', {
|
||||
method: 'POST',
|
||||
body: { productId: product.id, meterage },
|
||||
body: { productId: product.id, meterage: nextMeterage, notes: notes || undefined },
|
||||
})
|
||||
}
|
||||
applyServerCart(serverCart)
|
||||
|
||||
@@ -27,7 +27,10 @@ export type CatalogRequestStatus =
|
||||
export type CatalogRequest = {
|
||||
id: string
|
||||
createdAt: string
|
||||
updatedAt?: string
|
||||
status: CatalogRequestStatus
|
||||
/** Raw status from API for timeline mapping */
|
||||
serverStatus?: string
|
||||
note?: string
|
||||
items: CatalogRequestItem[]
|
||||
}
|
||||
@@ -72,6 +75,7 @@ const STATUS_LABELS: Record<string, string> = {
|
||||
function mapServerRow(row: {
|
||||
id: string
|
||||
createdAt: string
|
||||
updatedAt?: string
|
||||
status: string
|
||||
note?: string
|
||||
items: Array<{
|
||||
@@ -84,7 +88,9 @@ function mapServerRow(row: {
|
||||
return {
|
||||
id: row.id,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
status: SERVER_TO_UI[row.status] ?? (row.status as CatalogRequestStatus),
|
||||
serverStatus: row.status,
|
||||
note: row.note,
|
||||
items: row.items.map((i) => ({
|
||||
productId: i.productId,
|
||||
@@ -117,6 +123,7 @@ export const useCatalogRequestsStore = defineStore('catalogRequests', () => {
|
||||
Array<{
|
||||
id: string
|
||||
createdAt: string
|
||||
updatedAt?: string
|
||||
status: string
|
||||
note?: string
|
||||
items: Array<{
|
||||
@@ -151,6 +158,7 @@ export const useCatalogRequestsStore = defineStore('catalogRequests', () => {
|
||||
const row = await apiRequest<{
|
||||
id: string
|
||||
createdAt: string
|
||||
updatedAt?: string
|
||||
status: string
|
||||
note?: string
|
||||
items: Array<{
|
||||
|
||||
@@ -45,6 +45,7 @@ export interface User {
|
||||
levelProgress?: number
|
||||
businessName?: string
|
||||
avatarUrl?: string
|
||||
address?: string
|
||||
}
|
||||
|
||||
export interface Company {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { Router } from 'vue-router'
|
||||
import type { CatalogProductCard } from '@/types/crm'
|
||||
import { demoProductsFor, findMockCalite } from '@/data/caliteCollections'
|
||||
import { groupCatalogPatterns } from '@/utils/catalogGallery'
|
||||
import { useCatalogStore } from '@/stores/catalog'
|
||||
import { useCrmStore } from '@/stores/crm'
|
||||
|
||||
function pickPrimaryProductId(products: CatalogProductCard[]): string | null {
|
||||
if (!products.length) return null
|
||||
const patterns = groupCatalogPatterns(products)
|
||||
const first = patterns[0]?.items[0] ?? products[0]
|
||||
return first?.id ?? null
|
||||
}
|
||||
|
||||
export async function resolveCatalogPrimaryProductId(
|
||||
catalogId: string,
|
||||
knownProducts?: CatalogProductCard[],
|
||||
): Promise<string | null> {
|
||||
const fromKnown = knownProducts?.filter((item) => item.catalogId === catalogId) ?? []
|
||||
if (fromKnown.length) {
|
||||
return pickPrimaryProductId(fromKnown)
|
||||
}
|
||||
|
||||
const catalogStore = useCatalogStore()
|
||||
const crm = useCrmStore()
|
||||
|
||||
let catalog = catalogStore.getCatalog(catalogId)
|
||||
if (!catalog) {
|
||||
const companies = await catalogStore.loadCompanies()
|
||||
await Promise.all(companies.map((company) => catalogStore.loadCatalogs(company.id)))
|
||||
catalog = catalogStore.getCatalog(catalogId)
|
||||
}
|
||||
|
||||
if (catalog) {
|
||||
const products = await crm.loadBrowseProducts({
|
||||
catalogId: catalog.id,
|
||||
companyId: catalog.companyId,
|
||||
})
|
||||
const productId = pickPrimaryProductId(products)
|
||||
if (productId) return productId
|
||||
}
|
||||
|
||||
const mock = findMockCalite(catalogId)
|
||||
if (mock) {
|
||||
return pickPrimaryProductId(demoProductsFor(mock))
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export async function navigateToCatalogProduct(
|
||||
router: Router,
|
||||
catalogId: string,
|
||||
knownProducts?: CatalogProductCard[],
|
||||
) {
|
||||
const productId = await resolveCatalogPrimaryProductId(catalogId, knownProducts)
|
||||
if (productId) {
|
||||
await router.replace({ path: '/product', query: { id: productId } })
|
||||
return
|
||||
}
|
||||
await router.replace({ name: 'catalogs' })
|
||||
}
|
||||
+7
-17
@@ -19,7 +19,7 @@ export interface NavItem {
|
||||
/** Mobile bottom navigation — center FAB for سفارش */
|
||||
export const MAIN_NAV_ITEMS: NavItem[] = [
|
||||
{ name: 'home', path: '/', label: 'خانه', icon: IconHome },
|
||||
{ name: 'catalogs', path: '/catalogs', label: 'کالیتهها', icon: IconCatalog },
|
||||
{ name: 'products', path: '/products', label: 'محصولات', icon: IconCatalog },
|
||||
{ name: 'order-fab', path: '/cart', label: 'ثبت سفارش', icon: IconPlus },
|
||||
{ name: 'orders', path: '/orders', label: 'سفارشها', icon: IconOrders },
|
||||
{ name: 'profile', path: '/profile', label: 'پروفایل', icon: IconProfile },
|
||||
@@ -31,7 +31,7 @@ export const MAIN_NAV_ITEMS: NavItem[] = [
|
||||
*/
|
||||
export const DESKTOP_NAV_ITEMS: NavItem[] = [
|
||||
{ name: 'home', path: '/', label: 'خانه', icon: IconHome },
|
||||
{ name: 'catalogs', path: '/catalogs', label: 'کالیتهها', icon: IconCatalog },
|
||||
{ name: 'products', path: '/products', label: 'محصولات', icon: IconCatalog },
|
||||
{ name: 'cart', path: '/cart', label: 'ثبت سفارش', icon: IconCart },
|
||||
{ name: 'orders', path: '/orders', label: 'سفارشها', icon: IconOrders },
|
||||
{ name: 'checks', path: '/profile/checks', label: 'لیست چکها', icon: IconCheckBook },
|
||||
@@ -45,14 +45,8 @@ export function getOrderingPath(_activeCompanyId?: string | null): string {
|
||||
return SHOP_BROWSE_PATH
|
||||
}
|
||||
|
||||
const CATALOG_PATHS = [
|
||||
'/catalogs',
|
||||
'/catalog-request',
|
||||
'/catalog-requests',
|
||||
]
|
||||
|
||||
/** Shop browse flow: empty cart → categories → products → cart */
|
||||
const ORDER_BROWSE_PATHS = ['/categories', '/products', '/product', '/favorites']
|
||||
/** Shop browse: categories → products → detail → favorites */
|
||||
const PRODUCTS_BROWSE_PATHS = ['/categories', '/products', '/product', '/favorites']
|
||||
|
||||
const SETTINGS_PATHS = ['/profile/display', '/profile/edit', '/profile/security']
|
||||
|
||||
@@ -66,15 +60,11 @@ export function isNavItemActive(name: string, path: string, currentPath: string)
|
||||
if (name === 'home') {
|
||||
return currentPath === '/'
|
||||
}
|
||||
if (name === 'catalogs') {
|
||||
return matchesPathPrefix(currentPath, CATALOG_PATHS)
|
||||
if (name === 'products') {
|
||||
return matchesPathPrefix(currentPath, PRODUCTS_BROWSE_PATHS)
|
||||
}
|
||||
if (name === 'order-fab' || name === 'cart') {
|
||||
return (
|
||||
currentPath === '/cart' ||
|
||||
currentPath.startsWith('/cart/') ||
|
||||
matchesPathPrefix(currentPath, ORDER_BROWSE_PATHS)
|
||||
)
|
||||
return currentPath === '/cart' || currentPath.startsWith('/cart/')
|
||||
}
|
||||
if (name === 'checks') {
|
||||
return matchesPathPrefix(currentPath, ['/profile/checks'])
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { CatalogRequest } from '@/stores/catalogRequests'
|
||||
import type { SimpleTimelineStep } from '@/utils/simpleTimeline'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
|
||||
export type CatalogServerStatus =
|
||||
| 'new'
|
||||
| 'approved'
|
||||
| 'ready_to_ship'
|
||||
| 'delivered'
|
||||
| 'returned'
|
||||
| 'cancelled'
|
||||
|
||||
export const SIMPLE_CATALOG_STATUS_STEPS: SimpleTimelineStep[] = [
|
||||
{ id: 'registered', label: 'درخواست کالیته ثبت شد' },
|
||||
{ id: 'ready_to_ship', label: 'آماده ارسال' },
|
||||
{ id: 'shipped', label: 'ارسال شد' },
|
||||
{ id: 'returned', label: 'تحویل گرفته شد' },
|
||||
]
|
||||
|
||||
const STATUS_TO_STEP_INDEX: Record<CatalogServerStatus, number> = {
|
||||
new: 0,
|
||||
approved: 0,
|
||||
ready_to_ship: 1,
|
||||
delivered: 2,
|
||||
returned: 3,
|
||||
cancelled: 0,
|
||||
}
|
||||
|
||||
export function resolveCatalogServerStatus(request: CatalogRequest): CatalogServerStatus {
|
||||
if (request.serverStatus) {
|
||||
return request.serverStatus as CatalogServerStatus
|
||||
}
|
||||
|
||||
const legacyMap: Record<string, CatalogServerStatus> = {
|
||||
submitted: 'new',
|
||||
reviewing: 'approved',
|
||||
preparing: 'ready_to_ship',
|
||||
shipped: 'delivered',
|
||||
delivered: 'returned',
|
||||
new: 'new',
|
||||
approved: 'approved',
|
||||
ready_to_ship: 'ready_to_ship',
|
||||
returned: 'returned',
|
||||
cancelled: 'cancelled',
|
||||
}
|
||||
|
||||
return legacyMap[request.status] ?? 'new'
|
||||
}
|
||||
|
||||
export function getSimpleCatalogStatusIndex(request: CatalogRequest): number {
|
||||
const status = resolveCatalogServerStatus(request)
|
||||
return STATUS_TO_STEP_INDEX[status] ?? 0
|
||||
}
|
||||
|
||||
export function getSimpleCatalogStatusTimestamps(
|
||||
request: CatalogRequest,
|
||||
): Record<string, string | null> {
|
||||
const status = resolveCatalogServerStatus(request)
|
||||
const created = formatDateTime(request.createdAt)
|
||||
const updated = request.updatedAt ? formatDateTime(request.updatedAt) : null
|
||||
|
||||
return {
|
||||
registered: created,
|
||||
ready_to_ship: ['ready_to_ship', 'delivered', 'returned'].includes(status) ? updated : null,
|
||||
shipped: ['delivered', 'returned'].includes(status) ? updated : null,
|
||||
returned: status === 'returned' ? updated : null,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { Order, OrderStatus } from '@/types'
|
||||
import type { SimpleTimelineStep } from '@/utils/simpleTimeline'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
|
||||
export const SIMPLE_ORDER_STATUS_STEPS: SimpleTimelineStep[] = [
|
||||
{ id: 'registered', label: 'سفارش ثبت شد' },
|
||||
{ id: 'priced', label: 'قیمت گذاری انجام شد' },
|
||||
{ id: 'proforma_sent', label: 'پیش فاکتور ارسال شد' },
|
||||
{ id: 'proforma_confirmed', label: 'پیش فاکتور تایید شد' },
|
||||
{ id: 'invoice_sent', label: 'فاکتور ارسال شد' },
|
||||
{ id: 'shipped', label: 'بار ارسال شد' },
|
||||
{ id: 'delivered', label: 'بار توسط شما تحویل گرفته شد' },
|
||||
]
|
||||
|
||||
const STATUS_TO_STEP_INDEX: Partial<Record<OrderStatus, number>> = {
|
||||
submitted: 0,
|
||||
awaiting_review: 1,
|
||||
pricing: 1,
|
||||
needs_correction: 1,
|
||||
awaiting_settlement: 2,
|
||||
awaiting_settlement_fix: 2,
|
||||
cash_in_progress: 2,
|
||||
cash_invoice_issued: 2,
|
||||
awaiting_cash_payment: 2,
|
||||
cash_payment_submitted: 2,
|
||||
check_in_progress: 2,
|
||||
awaiting_cash_confirmation: 3,
|
||||
awaiting_check_review: 3,
|
||||
check_needs_fix: 3,
|
||||
check_rejected: 3,
|
||||
checks_approved: 3,
|
||||
cash_confirmed: 3,
|
||||
final_approved: 4,
|
||||
preparing: 4,
|
||||
ready_to_ship: 4,
|
||||
shipped: 5,
|
||||
delivered: 6,
|
||||
expired: 2,
|
||||
cancelled: 0,
|
||||
rejected: 1,
|
||||
}
|
||||
|
||||
const STEP_STATUS_ALIASES: Record<string, OrderStatus[]> = {
|
||||
registered: ['submitted'],
|
||||
priced: ['pricing', 'awaiting_review', 'needs_correction'],
|
||||
proforma_sent: [
|
||||
'awaiting_settlement',
|
||||
'awaiting_settlement_fix',
|
||||
'cash_in_progress',
|
||||
'cash_invoice_issued',
|
||||
'awaiting_cash_payment',
|
||||
'cash_payment_submitted',
|
||||
'check_in_progress',
|
||||
],
|
||||
proforma_confirmed: [
|
||||
'awaiting_cash_confirmation',
|
||||
'awaiting_check_review',
|
||||
'check_needs_fix',
|
||||
'check_rejected',
|
||||
'checks_approved',
|
||||
'cash_confirmed',
|
||||
],
|
||||
invoice_sent: ['final_approved', 'preparing', 'ready_to_ship'],
|
||||
shipped: ['shipped'],
|
||||
delivered: ['delivered'],
|
||||
}
|
||||
|
||||
export function getSimpleOrderStatusIndex(status: OrderStatus): number {
|
||||
return STATUS_TO_STEP_INDEX[status] ?? 0
|
||||
}
|
||||
|
||||
export function getSimpleOrderStatusTimestamps(order: Order): Record<string, string | null> {
|
||||
const result: Record<string, string | null> = {}
|
||||
|
||||
for (const step of SIMPLE_ORDER_STATUS_STEPS) {
|
||||
if (step.id === 'registered') {
|
||||
result[step.id] = formatDateTime(order.createdAt)
|
||||
continue
|
||||
}
|
||||
|
||||
const aliases = STEP_STATUS_ALIASES[step.id] ?? []
|
||||
const entry = [...order.statusHistory]
|
||||
.reverse()
|
||||
.find((item) => aliases.includes(item.toStatus))
|
||||
|
||||
result[step.id] = entry ? formatDateTime(entry.changedAt) : null
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export type SimpleTimelineStep = {
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import SearchField from '@/components/ui/SearchField.vue'
|
||||
import EmptyState from '@/components/ui/EmptyState.vue'
|
||||
import PageSkeleton from '@/components/ui/PageSkeleton.vue'
|
||||
import IconCatalog from '@/components/icons/IconCatalog.vue'
|
||||
import { navigateToCatalogProduct } from '@/utils/catalogNavigation'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -32,7 +33,7 @@ onMounted(load)
|
||||
watch(companyId, load)
|
||||
|
||||
function openCatalog(catalogId: string) {
|
||||
router.push({ name: 'catalog-detail', params: { catalogId } })
|
||||
void navigateToCatalogProduct(router, catalogId)
|
||||
}
|
||||
|
||||
function getProductCount(catalogId: string) {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import PageSkeleton from '@/components/ui/PageSkeleton.vue'
|
||||
import { navigateToCatalogProduct } from '@/utils/catalogNavigation'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
onMounted(() => {
|
||||
void navigateToCatalogProduct(router, String(route.params.catalogId ?? ''))
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<PageSkeleton variant="detail" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,314 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import PageHeader from '@/components/layout/PageHeader.vue'
|
||||
import PageSkeleton from '@/components/ui/PageSkeleton.vue'
|
||||
import { useCrmStore } from '@/stores/crm'
|
||||
import { useCatalogRequestsStore } from '@/stores/catalogRequests'
|
||||
import type { CatalogProductCard } from '@/types/crm'
|
||||
import { formatNumber } from '@/utils/format'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const crm = useCrmStore()
|
||||
const requestsStore = useCatalogRequestsStore()
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const note = ref('')
|
||||
const products = ref<CatalogProductCard[]>([])
|
||||
const quantities = ref<Record<string, number>>({})
|
||||
|
||||
const preselectId = computed(() => String(route.query.productId ?? ''))
|
||||
const catalogId = computed(() => String(route.query.catalogId ?? ''))
|
||||
|
||||
const selectedItems = computed(() =>
|
||||
products.value
|
||||
.filter((p) => (quantities.value[p.id] ?? 0) > 0)
|
||||
.map((p) => ({
|
||||
productId: p.id,
|
||||
productName: p.name,
|
||||
productCode: p.code,
|
||||
quantity: quantities.value[p.id] ?? 0,
|
||||
})),
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const list = await crm.loadBrowseProducts(
|
||||
catalogId.value ? { catalogId: catalogId.value } : undefined,
|
||||
)
|
||||
const selected = preselectId.value
|
||||
products.value = selected
|
||||
? [...list].sort((a, b) => Number(b.id === selected) - Number(a.id === selected))
|
||||
: list
|
||||
if (selected) {
|
||||
quantities.value = { ...quantities.value, [selected]: 1 }
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'بارگذاری محصولات ناموفق بود.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function setQty(id: string, value: number) {
|
||||
quantities.value = {
|
||||
...quantities.value,
|
||||
[id]: Math.max(0, Math.floor(value) || 0),
|
||||
}
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
saving.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const result = await requestsStore.create({
|
||||
items: selectedItems.value,
|
||||
note: note.value,
|
||||
})
|
||||
if (!result.success) {
|
||||
error.value = result.message
|
||||
return
|
||||
}
|
||||
router.push('/catalog-requests')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page catalog-request-page">
|
||||
<PageHeader class="mobile-only" title="درخواست کالیته" show-back centered @back="router.back()" />
|
||||
|
||||
<header class="desktop-page__head desktop-only">
|
||||
<h1 class="desktop-page__title">درخواست کالیته</h1>
|
||||
<p class="desktop-page__subtitle">محصولات و تعداد اقلام موردنظر را مشخص کنید</p>
|
||||
</header>
|
||||
|
||||
<div class="page__scroll page__scroll--with-nav body">
|
||||
<p class="hint">
|
||||
{{ preselectId ? 'این محصول برای درخواست کالیته انتخاب شده است. در صورت نیاز اقلام دیگر را هم اضافه کنید.' : 'محصولات و تعداد اقلام موردنظر را مشخص کنید.' }}
|
||||
</p>
|
||||
<PageSkeleton v-if="loading" variant="form" />
|
||||
<p v-else-if="error" class="error">{{ error }}</p>
|
||||
<template v-else>
|
||||
<div class="request-layout">
|
||||
<div class="request-products">
|
||||
<article
|
||||
v-for="item in products"
|
||||
:key="item.id"
|
||||
class="row"
|
||||
:class="{ 'row--selected': preselectId === item.id }"
|
||||
>
|
||||
<div>
|
||||
<strong>{{ item.name }}</strong>
|
||||
<span>کد {{ item.code }}</span>
|
||||
</div>
|
||||
<input
|
||||
class="qty"
|
||||
type="number"
|
||||
min="0"
|
||||
:value="quantities[item.id] ?? 0"
|
||||
@input="setQty(item.id, Number(($event.target as HTMLInputElement).value))"
|
||||
/>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<aside class="request-sidebar desktop-summary-panel">
|
||||
<h2 class="desktop-summary-panel__title">خلاصه درخواست</h2>
|
||||
<div class="desktop-summary-panel__row">
|
||||
<span>اقلام انتخابشده</span>
|
||||
<strong>{{ formatNumber(selectedItems.length) }}</strong>
|
||||
</div>
|
||||
<label class="note">
|
||||
توضیحات
|
||||
<textarea v-model="note" rows="3" placeholder="آدرس یا توضیحات اختیاری" />
|
||||
</label>
|
||||
<button type="button" class="submit" :disabled="saving" @click="submit">
|
||||
{{ saving ? 'در حال ثبت...' : 'ثبت درخواست کالیته' }}
|
||||
</button>
|
||||
<button type="button" class="linkish" @click="router.push('/catalog-requests')">
|
||||
وضعیت درخواستهای قبلی
|
||||
</button>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div class="request-mobile-footer mobile-only">
|
||||
<label class="note">
|
||||
توضیحات
|
||||
<textarea v-model="note" rows="3" placeholder="آدرس یا توضیحات اختیاری" />
|
||||
</label>
|
||||
<p class="summary">اقلام انتخابشده: {{ formatNumber(selectedItems.length) }}</p>
|
||||
<button type="button" class="submit" :disabled="saving" @click="submit">
|
||||
{{ saving ? 'در حال ثبت...' : 'ثبت درخواست کالیته' }}
|
||||
</button>
|
||||
<button type="button" class="linkish" @click="router.push('/catalog-requests')">
|
||||
وضعیت درخواستهای قبلی
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.body {
|
||||
padding: 12px var(--page-padding) 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #b42318;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 12px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.row--selected {
|
||||
outline: 2px solid var(--color-primary);
|
||||
}
|
||||
|
||||
.row strong {
|
||||
display: block;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.row span {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.qty {
|
||||
width: 64px;
|
||||
height: 40px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 10px;
|
||||
text-align: center;
|
||||
font: inherit;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.note {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.note textarea {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 12px;
|
||||
padding: 10px 12px;
|
||||
font: inherit;
|
||||
resize: vertical;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.summary {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.submit {
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
min-height: 48px;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.submit:disabled {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.linkish {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-primary);
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.request-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.request-sidebar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.request-mobile-footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.catalog-request-page {
|
||||
max-width: var(--content-max-width);
|
||||
margin-inline: auto;
|
||||
padding: 20px var(--page-padding) 28px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.request-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(280px, 340px);
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.request-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
position: sticky;
|
||||
top: 16px;
|
||||
}
|
||||
|
||||
.request-sidebar .submit {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.request-mobile-footer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.row {
|
||||
box-shadow: var(--shadow-card);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -20,19 +20,15 @@ onMounted(() => store.hydrate())
|
||||
<h1 class="desktop-page__title">وضعیت کالیتهها</h1>
|
||||
<p class="desktop-page__subtitle">پیگیری درخواستهای کالیته ثبتشده</p>
|
||||
</div>
|
||||
<button type="button" class="desk-new-btn" @click="router.push('/catalog-request')">
|
||||
<button type="button" class="desk-new-btn" @click="router.push('/categories')">
|
||||
درخواست کالیته جدید
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="page__scroll page__scroll--with-nav body">
|
||||
<button type="button" class="new-btn mobile-only" @click="router.push('/catalog-request')">
|
||||
<button type="button" class="new-btn mobile-only" @click="router.push('/categories')">
|
||||
درخواست کالیته جدید
|
||||
</button>
|
||||
<button type="button" class="new-btn" @click="router.push('/catalog-request')">
|
||||
درخواست کالیته جدید
|
||||
</button>
|
||||
|
||||
<p v-if="!store.sorted.length" class="empty">هنوز درخواستی ثبت نکردهاید.</p>
|
||||
|
||||
<article v-for="item in store.sorted" :key="item.id" class="card">
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from '@/utils/catalogGallery'
|
||||
import { MOCK_CALITE_CARDS } from '@/data/caliteCollections'
|
||||
import { formatNumber } from '@/utils/format'
|
||||
import { navigateToCatalogProduct } from '@/utils/catalogNavigation'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -56,7 +57,7 @@ onMounted(async () => {
|
||||
products.value = browse
|
||||
const focusId = String(route.query.id ?? '')
|
||||
if (focusId && catalogs.value.some((item) => item.id === focusId)) {
|
||||
router.replace({ name: 'catalog-detail', params: { catalogId: focusId } })
|
||||
await navigateToCatalogProduct(router, focusId, products.value)
|
||||
}
|
||||
} catch {
|
||||
catalogs.value = []
|
||||
@@ -66,7 +67,7 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
function openCatalog(id: string) {
|
||||
router.push({ name: 'catalog-detail', params: { catalogId: id } })
|
||||
void navigateToCatalogProduct(router, id, products.value)
|
||||
}
|
||||
|
||||
function focusSearch() {
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import PageHeader from '@/components/layout/PageHeader.vue'
|
||||
import PageSkeleton from '@/components/ui/PageSkeleton.vue'
|
||||
import SimpleStatusTimeline from '@/components/ui/SimpleStatusTimeline.vue'
|
||||
import { useCatalogRequestsStore } from '@/stores/catalogRequests'
|
||||
import {
|
||||
getSimpleCatalogStatusIndex,
|
||||
getSimpleCatalogStatusTimestamps,
|
||||
SIMPLE_CATALOG_STATUS_STEPS,
|
||||
} from '@/utils/simpleCatalogStatus'
|
||||
import { formatNumber } from '@/utils/format'
|
||||
|
||||
const router = useRouter()
|
||||
const store = useCatalogRequestsStore()
|
||||
const loading = ref(true)
|
||||
|
||||
const latestRequest = computed(() => (store.sorted.length ? store.sorted[0] : null))
|
||||
|
||||
const currentIndex = computed(() =>
|
||||
latestRequest.value ? getSimpleCatalogStatusIndex(latestRequest.value) : 0,
|
||||
)
|
||||
|
||||
const timestamps = computed(() =>
|
||||
latestRequest.value ? getSimpleCatalogStatusTimestamps(latestRequest.value) : {},
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true
|
||||
await store.hydrate()
|
||||
loading.value = false
|
||||
})
|
||||
|
||||
function goToCategories() {
|
||||
router.push('/categories')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page latest-status-page">
|
||||
<PageHeader
|
||||
class="mobile-only"
|
||||
title="آخرین درخواست کالیته"
|
||||
show-back
|
||||
centered
|
||||
@back="router.push('/')"
|
||||
/>
|
||||
|
||||
<header class="desktop-page__head desktop-only">
|
||||
<div>
|
||||
<h1 class="desktop-page__title">آخرین درخواست کالیته</h1>
|
||||
<p class="desktop-page__subtitle">پیگیری مراحل آخرین درخواست کالیته</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="page__scroll page__scroll--with-nav body">
|
||||
<PageSkeleton v-if="loading" />
|
||||
|
||||
<template v-else-if="latestRequest">
|
||||
<article class="summary-card">
|
||||
<p class="summary-card__label">درخواست</p>
|
||||
<strong class="summary-card__value">{{ latestRequest.id.slice(-8) }}</strong>
|
||||
<p class="summary-card__meta">
|
||||
{{ formatNumber(latestRequest.items.length) }} قلم کالیته
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<SimpleStatusTimeline
|
||||
:steps="SIMPLE_CATALOG_STATUS_STEPS"
|
||||
:current-index="currentIndex"
|
||||
:timestamps="timestamps"
|
||||
/>
|
||||
|
||||
<button type="button" class="detail-link" @click="router.push('/catalog-requests')">
|
||||
مشاهده همه درخواستها
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<button v-else type="button" class="empty-cta" @click="goToCategories">
|
||||
شما هیچ کالیته ای درخواست نکرده اید، جهت درخواست کالیته و مشاهده فیزیکی پارچه ها کلیک کنید
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.body {
|
||||
padding: 12px var(--page-padding) 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 14px 16px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.summary-card__label {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.summary-card__value {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.summary-card__meta {
|
||||
margin: 6px 0 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.detail-link {
|
||||
align-self: center;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--color-primary);
|
||||
font-weight: 700;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
padding: 8px 4px;
|
||||
}
|
||||
|
||||
.empty-cta {
|
||||
margin: 48px auto 0;
|
||||
max-width: 340px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.6;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.empty-cta:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.body {
|
||||
max-width: 560px;
|
||||
margin-inline: auto;
|
||||
width: 100%;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -40,11 +40,11 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
function openCategory(id: string) {
|
||||
router.push({ path: '/products', query: { categoryId: id } })
|
||||
router.push({ path: '/products', query: { categoryId: id, filterAxis: 'usage' } })
|
||||
}
|
||||
|
||||
function openSeason(id: string) {
|
||||
router.push({ path: '/products', query: { season: id } })
|
||||
router.push({ path: '/products', query: { season: id, filterAxis: 'season' } })
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -68,7 +68,7 @@ function openSeason(id: string) {
|
||||
|
||||
<header class="desktop-page__head desktop-only">
|
||||
<h1 class="desktop-page__title">دستهبندیها</h1>
|
||||
<p class="desktop-page__subtitle">دسته موردنظر خود را انتخاب کنید و محصولات را ببینید</p>
|
||||
<p class="desktop-page__subtitle">دسته موردنظر خود را انتخاب کنید</p>
|
||||
</header>
|
||||
|
||||
<div class="page__scroll page__scroll--with-nav cat-scroll">
|
||||
@@ -78,7 +78,7 @@ function openSeason(id: string) {
|
||||
<section>
|
||||
<h2 class="block-title">انتخاب بر اساس کاربرد</h2>
|
||||
<p class="block-sub">دسته موردنظر خود را انتخاب کنید</p>
|
||||
<div class="usage-grid">
|
||||
<div class="usage-grid scroll-x">
|
||||
<button
|
||||
v-for="item in usageCategories"
|
||||
:key="item.id"
|
||||
@@ -162,8 +162,8 @@ function openSeason(id: string) {
|
||||
class="feature-card feature-card--link"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="router.push('/catalog-request')"
|
||||
@keydown.enter="router.push('/catalog-request')"
|
||||
@click="router.push('/products')"
|
||||
@keydown.enter="router.push('/products')"
|
||||
>
|
||||
<span class="feature-card__icon" aria-hidden="true">
|
||||
<IconCatalog :size="22" />
|
||||
@@ -238,17 +238,22 @@ function openSeason(id: string) {
|
||||
}
|
||||
|
||||
.usage-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: 8px;
|
||||
margin-inline: calc(-1 * var(--page-padding));
|
||||
padding-inline: var(--page-padding);
|
||||
padding-bottom: 4px;
|
||||
scroll-snap-type: x proximity;
|
||||
}
|
||||
|
||||
.usage-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
flex: 0 0 148px;
|
||||
width: 148px;
|
||||
scroll-snap-align: start;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
border: none;
|
||||
@@ -532,8 +537,14 @@ function openSeason(id: string) {
|
||||
}
|
||||
|
||||
.usage-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
margin-inline: 0;
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
.usage-card {
|
||||
flex-basis: 168px;
|
||||
width: 168px;
|
||||
}
|
||||
|
||||
.usage-card__body strong {
|
||||
@@ -572,9 +583,4 @@ function openSeason(id: string) {
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
.usage-grid {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
+166
-10
@@ -5,6 +5,7 @@ import { useAuthStore } from '@/stores/auth'
|
||||
import { useCrmStore } from '@/stores/crm'
|
||||
import { useFavoritesStore } from '@/stores/favorites'
|
||||
import { fetchAgentProfile, fetchBanners, type AppBannerDto } from '@/services/agentApi'
|
||||
import { fetchMySuggestions, type ProductSuggestionDto } from '@/services/suggestionsApi'
|
||||
import IconBell from '@/components/icons/IconBell.vue'
|
||||
import IconChevronLeft from '@/components/icons/IconChevronLeft.vue'
|
||||
import levelMedalIcon from '@/assets/images/level-medal.webp'
|
||||
@@ -30,6 +31,7 @@ const favorites = useFavoritesStore()
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const newest = ref<CatalogProductCard[]>([])
|
||||
const suggestions = ref<ProductSuggestionDto[]>([])
|
||||
const favError = ref<string | null>(null)
|
||||
const agentLevel = ref<{
|
||||
label: string
|
||||
@@ -47,17 +49,17 @@ type QuickAction = {
|
||||
}
|
||||
|
||||
const customerActions: QuickAction[] = [
|
||||
{ id: 'qa-club', label: 'باشگاه مشتریان', to: '/profile', image: quickClubCrownIcon },
|
||||
{ id: 'qa-cat', label: 'دسته بندی', to: '/categories', image: quickCategoriesGridIcon },
|
||||
{ id: 'qa-new', label: 'محصولات جدید', to: '/products', image: quickNewProductsIcon },
|
||||
{ id: 'qa-swatch', label: 'درخواست کالیته', to: '/catalog-request', image: quickCatalogRequestIcon },
|
||||
{ id: 'qa-cat', label: 'دسته بندی', to: '/categories', image: quickCategoriesGridIcon },
|
||||
{ id: 'qa-club', label: 'آخرین وضعیت سفارش', to: '/orders/latest-status', image:'/' },
|
||||
{ id: 'qa-swatch', label: 'آخرین درخواست کالیته', to: '/catalog-requests/latest-status', image: quickCatalogRequestIcon },
|
||||
]
|
||||
|
||||
const agentActions: QuickAction[] = [
|
||||
{ id: 'qa-customers', label: 'باشگاه مشتریان', to: '/customers', image: quickClubCrownIcon },
|
||||
{ id: 'qa-new', label: 'محصولات جدید', to: '/products', image: quickNewProductsIcon },
|
||||
{ id: 'qa-cat', label: 'دسته بندی', to: '/categories', image: quickCategoriesGridIcon },
|
||||
{ id: 'qa-swatch', label: 'درخواست کالیته', to: '/catalog-request', image: quickCatalogRequestIcon },
|
||||
{ id: 'qa-customers', label: 'آخرین وضعیت سفارش', to: '/orders/latest-status', image: '/' },
|
||||
{ id: 'qa-swatch', label: 'آخرین درخواست کالیته', to: '/catalog-requests/latest-status', image: quickCatalogRequestIcon },
|
||||
]
|
||||
|
||||
const actions = computed(() => (auth.isAgent ? agentActions : customerActions))
|
||||
@@ -167,6 +169,12 @@ onMounted(async () => {
|
||||
} catch {
|
||||
// سطح از /auth/me؛ عدم دیپلوی agent API خانه را خراب نکند
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
suggestions.value = await fetchMySuggestions()
|
||||
} catch {
|
||||
suggestions.value = []
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'بارگذاری خانه ناموفق بود.'
|
||||
@@ -299,7 +307,7 @@ async function toggleFavorite(productId: string) {
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section class="level-card" aria-label="سطح عضویت">
|
||||
<section v-if="auth.isAgent" class="level-card" aria-label="سطح عضویت">
|
||||
<div class="level-card__top">
|
||||
<div class="level-card__identity">
|
||||
<img
|
||||
@@ -339,6 +347,38 @@ 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>
|
||||
|
||||
<section class="section">
|
||||
<div class="section__head">
|
||||
<h3 class="section__title">جدیدترین محصولات</h3>
|
||||
@@ -347,7 +387,7 @@ async function toggleFavorite(productId: string) {
|
||||
<IconChevronLeft :size="14" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="product-row">
|
||||
<div class="product-row scroll-x">
|
||||
<article
|
||||
v-for="(item, index) in newest"
|
||||
:key="item.id"
|
||||
@@ -777,6 +817,110 @@ 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);
|
||||
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;
|
||||
@@ -803,12 +947,10 @@ async function toggleFavorite(productId: string) {
|
||||
.product-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 0;
|
||||
margin-inline: calc(-1 * var(--page-padding));
|
||||
padding-inline: var(--page-padding);
|
||||
scroll-snap-type: x mandatory;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scroll-snap-type: x proximity;
|
||||
}
|
||||
|
||||
.product-card {
|
||||
@@ -977,6 +1119,20 @@ async function toggleFavorite(productId: string) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
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 {
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import PageHeader from '@/components/layout/PageHeader.vue'
|
||||
import PageSkeleton from '@/components/ui/PageSkeleton.vue'
|
||||
import SimpleStatusTimeline from '@/components/ui/SimpleStatusTimeline.vue'
|
||||
import { useOrderStore } from '@/stores/order'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import {
|
||||
getSimpleOrderStatusIndex,
|
||||
getSimpleOrderStatusTimestamps,
|
||||
SIMPLE_ORDER_STATUS_STEPS,
|
||||
} from '@/utils/simpleOrderStatus'
|
||||
import { formatCurrency } from '@/utils/format'
|
||||
|
||||
const router = useRouter()
|
||||
const orderStore = useOrderStore()
|
||||
const auth = useAuthStore()
|
||||
const loading = ref(true)
|
||||
|
||||
const latestOrder = computed(() => {
|
||||
const list = orderStore.userOrders(auth.user?.id ?? '')
|
||||
return list.length ? list[0] : null
|
||||
})
|
||||
|
||||
const currentIndex = computed(() =>
|
||||
latestOrder.value ? getSimpleOrderStatusIndex(latestOrder.value.status) : 0,
|
||||
)
|
||||
|
||||
const timestamps = computed(() =>
|
||||
latestOrder.value ? getSimpleOrderStatusTimestamps(latestOrder.value) : {},
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true
|
||||
await orderStore.hydrate()
|
||||
loading.value = false
|
||||
})
|
||||
|
||||
function goToCategories() {
|
||||
router.push('/categories')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page latest-status-page">
|
||||
<PageHeader
|
||||
class="mobile-only"
|
||||
title="آخرین وضعیت سفارش"
|
||||
show-back
|
||||
centered
|
||||
@back="router.push('/')"
|
||||
/>
|
||||
|
||||
<header class="desktop-page__head desktop-only">
|
||||
<div>
|
||||
<h1 class="desktop-page__title">آخرین وضعیت سفارش</h1>
|
||||
<p class="desktop-page__subtitle">پیگیری مراحل آخرین سفارش ثبتشده</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="page__scroll page__scroll--with-nav body">
|
||||
<PageSkeleton v-if="loading" />
|
||||
|
||||
<template v-else-if="latestOrder">
|
||||
<article class="summary-card">
|
||||
<p class="summary-card__label">سفارش شماره</p>
|
||||
<strong class="summary-card__value">{{ latestOrder.orderNumber }}</strong>
|
||||
<p v-if="latestOrder.finalAmount" class="summary-card__meta">
|
||||
{{ formatCurrency(latestOrder.finalAmount) }}
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<SimpleStatusTimeline
|
||||
:steps="SIMPLE_ORDER_STATUS_STEPS"
|
||||
:current-index="currentIndex"
|
||||
:timestamps="timestamps"
|
||||
/>
|
||||
|
||||
<button type="button" class="detail-link" @click="router.push(`/orders/${latestOrder.id}`)">
|
||||
مشاهده جزئیات سفارش
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<button v-else type="button" class="empty-cta" @click="goToCategories">
|
||||
سفارشی ثبت نکرده اید. لطفا جهت مشاهده محصولات کلیک کنید
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.body {
|
||||
padding: 12px var(--page-padding) 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 14px 16px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.summary-card__label {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.summary-card__value {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.summary-card__meta {
|
||||
margin: 6px 0 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.detail-link {
|
||||
align-self: center;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--color-primary);
|
||||
font-weight: 700;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
padding: 8px 4px;
|
||||
}
|
||||
|
||||
.empty-cta {
|
||||
margin: 48px auto 0;
|
||||
max-width: 320px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.6;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.empty-cta:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.body {
|
||||
max-width: 560px;
|
||||
margin-inline: auto;
|
||||
width: 100%;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -15,12 +15,17 @@ import quickCatalogRequestIcon from '@/assets/images/quick-catalog-request.webp'
|
||||
import IconHeart from '@/components/icons/IconHeart.vue'
|
||||
import IconShare from '@/components/icons/IconShare.vue'
|
||||
import IconShoppingBag from '@/components/icons/IconShoppingBag.vue'
|
||||
import IconRuler from '@/components/icons/IconRuler.vue'
|
||||
import IconWeave from '@/components/icons/IconWeave.vue'
|
||||
import { useGoBack } from '@/composables/useGoBack'
|
||||
import PageSkeleton from '@/components/ui/PageSkeleton.vue'
|
||||
import {
|
||||
CATALOG_SEASON_CATEGORIES,
|
||||
CATALOG_USAGE_CATEGORIES,
|
||||
} from '@/data/catalogCategories'
|
||||
import { useCatalogRequest } from '@/composables/useCatalogRequest'
|
||||
import AddressCompletionModal from '@/components/catalogs/AddressCompletionModal.vue'
|
||||
import ProductOrderModal from '@/components/products/ProductOrderModal.vue'
|
||||
|
||||
const USAGE_CHIP_LABEL: Record<string, string> = {
|
||||
suit: 'پارچه کتشلواری',
|
||||
@@ -49,6 +54,17 @@ const selectedColor = ref(0)
|
||||
const rolls = ref(1)
|
||||
const adding = ref(false)
|
||||
const showAllSpecs = ref(false)
|
||||
const orderModalOpen = ref(false)
|
||||
const orderModalError = ref('')
|
||||
const {
|
||||
requesting: catalogRequesting,
|
||||
successMessage: catalogSuccessMessage,
|
||||
addressModalOpen,
|
||||
addressError: catalogAddressError,
|
||||
requestCatalog,
|
||||
submitAddressAndRequest,
|
||||
closeAddressModal,
|
||||
} = useCatalogRequest()
|
||||
|
||||
const rollMeters = computed(() => product.value?.rollMeters ?? 50)
|
||||
const currentGalleryImage = computed(() => {
|
||||
@@ -149,7 +165,7 @@ async function load() {
|
||||
onMounted(load)
|
||||
watch(productId, load)
|
||||
|
||||
function toCartProduct(p: CatalogProductCard): Product {
|
||||
function toCartProduct(p: CatalogProductCard, colorIndex = selectedColor.value): Product {
|
||||
const roll = p.rollMeters || 50
|
||||
const step = p.orderMultiplier && p.orderMultiplier > 0 ? p.orderMultiplier : roll
|
||||
const min = p.minMeterage && p.minMeterage > 0 ? p.minMeterage : roll
|
||||
@@ -159,7 +175,7 @@ function toCartProduct(p: CatalogProductCard): Product {
|
||||
catalogId: p.catalogId || '',
|
||||
name: p.name,
|
||||
code: p.code,
|
||||
color: p.color || p.colors[selectedColor.value] || '—',
|
||||
color: p.color || p.colors[colorIndex] || '—',
|
||||
minMeterage: min,
|
||||
orderMultiplier: step,
|
||||
basePrice: p.basePrice,
|
||||
@@ -174,21 +190,63 @@ function snapMeterage(meters: number, step: number, min: number) {
|
||||
return Math.max(snapped, minSnapped)
|
||||
}
|
||||
|
||||
async function addToOrder() {
|
||||
function openOrderModal() {
|
||||
if (!product.value) return
|
||||
const cartProduct = toCartProduct(product.value)
|
||||
orderModalError.value = ''
|
||||
orderModalOpen.value = true
|
||||
}
|
||||
|
||||
function closeOrderModal() {
|
||||
orderModalOpen.value = false
|
||||
orderModalError.value = ''
|
||||
}
|
||||
|
||||
async function submitOrderModal(rollsByColor: number[]) {
|
||||
const p = product.value
|
||||
if (!p) return
|
||||
|
||||
const selections = p.colors
|
||||
.map((color, idx) => ({ color, idx, rolls: rollsByColor[idx] ?? 0 }))
|
||||
.filter((row) => row.rolls > 0)
|
||||
|
||||
if (!selections.length) {
|
||||
orderModalError.value = 'حداقل یک رنگ با تعداد انتخاب کنید.'
|
||||
return
|
||||
}
|
||||
|
||||
const totalRolls = selections.reduce((sum, row) => sum + row.rolls, 0)
|
||||
const totalMeters = totalRolls * rollMeters.value
|
||||
if (totalMeters < minOrderMeters.value) {
|
||||
orderModalError.value = `حداقل سفارش ${formatNumber(minRolls.value)} طاقه است.`
|
||||
return
|
||||
}
|
||||
|
||||
const firstSelection = selections[0]!
|
||||
const cartProduct = toCartProduct(p, firstSelection.idx)
|
||||
const meterage = snapMeterage(
|
||||
totalMeters,
|
||||
cartProduct.orderMultiplier,
|
||||
cartProduct.minMeterage,
|
||||
)
|
||||
const notes = selections
|
||||
.map((row) => `${row.color || `رنگ ${row.idx + 1}`}: ${formatNumber(row.rolls)} طاقه`)
|
||||
.join('، ')
|
||||
|
||||
adding.value = true
|
||||
orderModalError.value = ''
|
||||
try {
|
||||
const meterage = snapMeterage(
|
||||
orderMeters.value,
|
||||
cartProduct.orderMultiplier,
|
||||
cartProduct.minMeterage,
|
||||
)
|
||||
await cartStore.addItem(
|
||||
const result = await cartStore.addItem(
|
||||
cartProduct,
|
||||
product.value.catalogCode || product.value.code,
|
||||
p.catalogCode || p.code,
|
||||
meterage,
|
||||
notes,
|
||||
true,
|
||||
)
|
||||
if (!result.success) {
|
||||
orderModalError.value = result.message ?? 'افزودن به سفارش ناموفق بود.'
|
||||
return
|
||||
}
|
||||
closeOrderModal()
|
||||
} finally {
|
||||
adding.value = false
|
||||
}
|
||||
@@ -208,10 +266,23 @@ function shareProduct() {
|
||||
void navigator.clipboard?.writeText(url)
|
||||
}
|
||||
|
||||
function requestPhysicalCatalog() {
|
||||
if (!product.value || catalogRequesting.value) return
|
||||
void requestCatalog(product.value)
|
||||
}
|
||||
|
||||
function onAddressSubmit(address: string) {
|
||||
void submitAddressAndRequest(address)
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<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" />
|
||||
@@ -273,21 +344,36 @@ function shareProduct() {
|
||||
<div class="product-page__layout">
|
||||
<div class="product-page__main">
|
||||
<div class="gallery">
|
||||
<div
|
||||
class="gallery__main"
|
||||
:style="
|
||||
currentGalleryImage
|
||||
? {
|
||||
backgroundImage: `url(${currentGalleryImage})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
}
|
||||
: colorSwatchStyle(currentColorName)
|
||||
"
|
||||
>
|
||||
<span class="gallery__count">
|
||||
{{ formatNumber(selectedColor + 1) }}/{{ formatNumber(Math.max(product.colors.length, 1)) }}
|
||||
</span>
|
||||
<div class="gallery__stage">
|
||||
<div
|
||||
class="gallery__main"
|
||||
:style="
|
||||
currentGalleryImage
|
||||
? {
|
||||
backgroundImage: `url(${currentGalleryImage})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
}
|
||||
: colorSwatchStyle(currentColorName)
|
||||
"
|
||||
/>
|
||||
<div
|
||||
v-if="product.colors.length > 1"
|
||||
class="gallery__dots"
|
||||
role="tablist"
|
||||
aria-label="تصاویر محصول"
|
||||
>
|
||||
<button
|
||||
v-for="(color, idx) in product.colors"
|
||||
:key="idx"
|
||||
type="button"
|
||||
class="gallery__dot"
|
||||
:class="{ 'gallery__dot--active': selectedColor === idx }"
|
||||
:aria-label="color || `تصویر ${idx + 1}`"
|
||||
:aria-selected="selectedColor === idx"
|
||||
@click="selectedColor = idx"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gallery__thumbs">
|
||||
<button
|
||||
@@ -313,9 +399,7 @@ function shareProduct() {
|
||||
<div class="specs">
|
||||
<div class="spec-cell">
|
||||
<span class="spec-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
|
||||
<rect x="4" y="8" width="16" height="8" rx="2" />
|
||||
</svg>
|
||||
<IconRuler color="#98a2ad" />
|
||||
</span>
|
||||
<span>عرض</span>
|
||||
<strong>{{ formatNumber(product.widthCm) }} سانتیمتر</strong>
|
||||
@@ -338,10 +422,7 @@ function shareProduct() {
|
||||
</div>
|
||||
<div class="spec-cell">
|
||||
<span class="spec-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
|
||||
<circle cx="12" cy="12" r="8" />
|
||||
<path d="M12 8a6 6 0 0 1 0 8" />
|
||||
</svg>
|
||||
<IconWeave color="#98a2ad" />
|
||||
</span>
|
||||
<span>نوع بافت</span>
|
||||
<strong>{{ product.weaveType || '—' }}</strong>
|
||||
@@ -395,7 +476,7 @@ function shareProduct() {
|
||||
</section>
|
||||
|
||||
<section class="pd-section">
|
||||
<h2 class="section-title">متراژ هر ساقه (طاقه)</h2>
|
||||
<h2 class="section-title">متراژ هر طاقه</h2>
|
||||
<div class="roll-field">{{ formatNumber(product.rollMeters) }} متر</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -413,18 +494,19 @@ function shareProduct() {
|
||||
<strong>{{ formatNumber(minRolls) }} طاقه</strong>
|
||||
</div>
|
||||
<div class="cart-summary-panel__btn">
|
||||
<button type="button" class="btn-primary" :disabled="adding" @click="addToOrder">
|
||||
<button type="button" class="btn-primary" @click="openOrderModal">
|
||||
<IconCart :size="18" color="#ffffff" />
|
||||
{{ adding ? 'در حال افزودن...' : 'افزودن به سفارش' }}
|
||||
افزودن به سفارش
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-secondary pd-desk-catalog-btn"
|
||||
@click="router.push({ path: '/catalog-request', query: { productId: product.id } })"
|
||||
:disabled="catalogRequesting"
|
||||
@click="requestPhysicalCatalog"
|
||||
>
|
||||
<img class="btn-secondary__icon" :src="quickCatalogRequestIcon" alt="" aria-hidden="true" />
|
||||
درخواست کالیته
|
||||
{{ catalogRequesting ? 'در حال ثبت...' : 'درخواست کالیته' }}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -433,17 +515,42 @@ function shareProduct() {
|
||||
|
||||
<footer class="pd-footer mobile-only sticky-footer">
|
||||
<div class="pd-footer__primary">
|
||||
<button type="button" class="btn-primary" :disabled="adding" @click="addToOrder">
|
||||
<button type="button" class="btn-primary" @click="openOrderModal">
|
||||
<IconCart :size="18" color="#ffffff" />
|
||||
{{ adding ? 'در حال افزودن...' : 'افزودن به سفارش' }}
|
||||
افزودن به سفارش
|
||||
</button>
|
||||
<p class="min-note">حداقل سفارش: {{ formatNumber(minRolls) }} طاقه</p>
|
||||
</div>
|
||||
<button type="button" class="btn-secondary" @click="router.push({ path: '/catalog-request', query: { productId: product.id } })">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-secondary"
|
||||
:disabled="catalogRequesting"
|
||||
@click="requestPhysicalCatalog"
|
||||
>
|
||||
<img class="btn-secondary__icon" :src="quickCatalogRequestIcon" alt="" aria-hidden="true" />
|
||||
درخواست کالیته
|
||||
{{ catalogRequesting ? 'در حال ثبت...' : 'درخواست کالیته' }}
|
||||
</button>
|
||||
</footer>
|
||||
|
||||
<AddressCompletionModal
|
||||
:open="addressModalOpen"
|
||||
:loading="catalogRequesting"
|
||||
:error="catalogAddressError"
|
||||
@close="closeAddressModal"
|
||||
@submit="onAddressSubmit"
|
||||
/>
|
||||
|
||||
<ProductOrderModal
|
||||
:open="orderModalOpen"
|
||||
:product="product"
|
||||
:roll-meters="rollMeters"
|
||||
:min-rolls="minRolls"
|
||||
:base-price="product.basePrice"
|
||||
:loading="adding"
|
||||
:error="orderModalError"
|
||||
@close="closeOrderModal"
|
||||
@submit="submitOrderModal"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else-if="loading" class="page empty">
|
||||
@@ -457,6 +564,17 @@ function shareProduct() {
|
||||
</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);
|
||||
@@ -492,24 +610,47 @@ function shareProduct() {
|
||||
|
||||
.pd-scroll {
|
||||
flex: 1;
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
padding-bottom: 120px;
|
||||
}
|
||||
|
||||
.gallery__main {
|
||||
.gallery__stage {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.gallery__main {
|
||||
border-radius: var(--radius-lg);
|
||||
height: 260px;
|
||||
background: linear-gradient(160deg, #a8b5a0, #6f8570);
|
||||
}
|
||||
|
||||
.gallery__count {
|
||||
.gallery__dots {
|
||||
position: absolute;
|
||||
bottom: 12px;
|
||||
inset-inline-start: 12px;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
color: #fff;
|
||||
border-radius: 6px;
|
||||
padding: 4px 10px;
|
||||
font-size: 0.75rem;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.gallery__dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border: none;
|
||||
padding: 0;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.45);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
|
||||
cursor: pointer;
|
||||
transition: width 0.35s ease, background 0.35s ease;
|
||||
}
|
||||
|
||||
.gallery__dot--active {
|
||||
width: 16px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.gallery__thumbs {
|
||||
@@ -909,12 +1050,16 @@ button.tag--link:hover {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.gallery__main {
|
||||
height: 360px;
|
||||
.gallery__stage {
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.gallery__main {
|
||||
height: 360px;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.gallery__thumbs {
|
||||
margin-inline: 0;
|
||||
}
|
||||
@@ -949,5 +1094,9 @@ button.tag--link:hover {
|
||||
.gallery__main {
|
||||
height: 420px;
|
||||
}
|
||||
|
||||
.gallery__dots {
|
||||
bottom: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -26,6 +26,7 @@ const categoryId = computed(() => String(route.query.categoryId ?? ''))
|
||||
const catalogId = computed(() => String(route.query.catalogId ?? ''))
|
||||
const companyId = computed(() => String(route.query.companyId ?? ''))
|
||||
const season = computed(() => String(route.query.season ?? '') as SeasonKind | '')
|
||||
const filterAxis = computed(() => String(route.query.filterAxis ?? ''))
|
||||
const q = computed(() => String(route.query.q ?? ''))
|
||||
const searchDraft = ref(String(route.query.q ?? ''))
|
||||
const products = ref<CatalogProductCard[]>([])
|
||||
@@ -72,7 +73,7 @@ const title = computed(() => {
|
||||
return crm.seasonCategories.find((s) => s.id === season.value)?.title ?? 'محصولات'
|
||||
}
|
||||
if (q.value) return `جستجو: ${q.value}`
|
||||
return 'همه محصولات'
|
||||
return 'محصولات'
|
||||
})
|
||||
|
||||
function splitCategoryTitle(fullTitle: string) {
|
||||
@@ -159,6 +160,17 @@ const bannerStyle = computed(() =>
|
||||
|
||||
const showSearch = computed(() => !categoryId.value && !catalogId.value)
|
||||
|
||||
/** Cross-filter chips: usage entry → season chips; season entry → usage chips. */
|
||||
const crossChipKind = computed<'season' | 'usage'>(() => {
|
||||
if (filterAxis.value === 'usage' || categoryId.value) return 'season'
|
||||
if (filterAxis.value === 'season' || season.value) return 'usage'
|
||||
return 'season'
|
||||
})
|
||||
|
||||
const crossChipAriaLabel = computed(() =>
|
||||
crossChipKind.value === 'season' ? 'فصل' : 'دسته کاربرد',
|
||||
)
|
||||
|
||||
const sortedProducts = computed(() => {
|
||||
const needle = q.value.trim().toLowerCase()
|
||||
let list = [...products.value]
|
||||
@@ -186,6 +198,7 @@ const sortAriaLabel = computed(() =>
|
||||
|
||||
function applySearch(value: string) {
|
||||
searchDraft.value = value
|
||||
const axis = resolveFilterAxis(categoryId.value, season.value)
|
||||
router.replace({
|
||||
path: '/products',
|
||||
query: {
|
||||
@@ -193,6 +206,7 @@ function applySearch(value: string) {
|
||||
...(catalogId.value ? { catalogId: catalogId.value } : {}),
|
||||
...(companyId.value ? { companyId: companyId.value } : {}),
|
||||
...(season.value ? { season: season.value } : {}),
|
||||
...(axis ? { filterAxis: axis } : {}),
|
||||
...(value.trim() ? { q: value.trim() } : {}),
|
||||
},
|
||||
})
|
||||
@@ -229,13 +243,32 @@ watch(q, (value) => {
|
||||
})
|
||||
|
||||
function setSeason(value: string) {
|
||||
const nextCategory = categoryId.value
|
||||
const axis = nextCategory ? 'usage' : value ? 'season' : undefined
|
||||
router.replace({
|
||||
path: '/products',
|
||||
query: {
|
||||
...(categoryId.value ? { categoryId: categoryId.value } : {}),
|
||||
...(nextCategory ? { categoryId: nextCategory } : {}),
|
||||
...(catalogId.value ? { catalogId: catalogId.value } : {}),
|
||||
...(companyId.value ? { companyId: companyId.value } : {}),
|
||||
...(value ? { season: value } : {}),
|
||||
...(axis ? { filterAxis: axis } : {}),
|
||||
...(q.value ? { q: q.value } : {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function setCategory(value: string) {
|
||||
const nextSeason = season.value
|
||||
const axis = nextSeason ? 'season' : value ? 'usage' : undefined
|
||||
router.replace({
|
||||
path: '/products',
|
||||
query: {
|
||||
...(value ? { categoryId: value } : {}),
|
||||
...(catalogId.value ? { catalogId: catalogId.value } : {}),
|
||||
...(companyId.value ? { companyId: companyId.value } : {}),
|
||||
...(nextSeason ? { season: nextSeason } : {}),
|
||||
...(axis ? { filterAxis: axis } : {}),
|
||||
...(q.value ? { q: q.value } : {}),
|
||||
},
|
||||
})
|
||||
@@ -303,10 +336,18 @@ onClickOutside(seasonRootDesk, () => {
|
||||
seasonSelectOpen.value = false
|
||||
})
|
||||
|
||||
function resolveFilterAxis(nextCategory: string, nextSeason: string) {
|
||||
if (nextCategory) return 'usage'
|
||||
if (nextSeason) return 'season'
|
||||
return undefined
|
||||
}
|
||||
|
||||
function buildProductsQuery(nextCategory: string, nextSeason: string) {
|
||||
const axis = resolveFilterAxis(nextCategory, nextSeason)
|
||||
return {
|
||||
...(nextCategory ? { categoryId: nextCategory } : {}),
|
||||
...(nextSeason ? { season: nextSeason } : {}),
|
||||
...(axis ? { filterAxis: axis } : {}),
|
||||
...(companyId.value ? { companyId: companyId.value } : {}),
|
||||
...(q.value ? { q: q.value } : {}),
|
||||
}
|
||||
@@ -627,12 +668,12 @@ onUnmounted(() => {
|
||||
</Teleport>
|
||||
|
||||
<div class="page__scroll page__scroll--with-nav products-scroll">
|
||||
<SearchField
|
||||
<!-- <SearchField
|
||||
v-if="showSearch"
|
||||
:model-value="searchDraft"
|
||||
placeholder="جستجو نام یا کد محصول..."
|
||||
@update:model-value="applySearch"
|
||||
/>
|
||||
/>-->
|
||||
<p v-if="error">{{ error }}</p>
|
||||
<PageSkeleton v-else-if="loading" variant="grid" />
|
||||
<template v-else>
|
||||
@@ -661,26 +702,48 @@ onUnmounted(() => {
|
||||
</span>
|
||||
</section>
|
||||
|
||||
<div class="chips-row">
|
||||
<div class="chips" role="tablist" aria-label="فصل">
|
||||
<button
|
||||
type="button"
|
||||
class="chip"
|
||||
:class="{ 'chip--active': !season }"
|
||||
@click="setSeason('')"
|
||||
>
|
||||
همه
|
||||
</button>
|
||||
<button
|
||||
v-for="item in crm.seasonCategories"
|
||||
:key="item.id"
|
||||
type="button"
|
||||
class="chip"
|
||||
:class="{ 'chip--active': season === item.id }"
|
||||
@click="setSeason(item.id)"
|
||||
>
|
||||
{{ item.title }}
|
||||
</button>
|
||||
<div v-if="showFilter" class="chips-row scroll-x">
|
||||
<div class="chips" role="tablist" :aria-label="crossChipAriaLabel">
|
||||
<template v-if="crossChipKind === 'season'">
|
||||
<button
|
||||
type="button"
|
||||
class="chip"
|
||||
:class="{ 'chip--active': !season }"
|
||||
@click="setSeason('')"
|
||||
>
|
||||
همه
|
||||
</button>
|
||||
<button
|
||||
v-for="item in crm.seasonCategories"
|
||||
:key="item.id"
|
||||
type="button"
|
||||
class="chip"
|
||||
:class="{ 'chip--active': season === item.id }"
|
||||
@click="setSeason(item.id)"
|
||||
>
|
||||
{{ item.title }}
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button
|
||||
type="button"
|
||||
class="chip"
|
||||
:class="{ 'chip--active': !categoryId }"
|
||||
@click="setCategory('')"
|
||||
>
|
||||
همه
|
||||
</button>
|
||||
<button
|
||||
v-for="item in crm.usageCategories"
|
||||
:key="item.id"
|
||||
type="button"
|
||||
class="chip"
|
||||
:class="{ 'chip--active': categoryId === item.id }"
|
||||
@click="setCategory(item.id)"
|
||||
>
|
||||
{{ item.title }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ const auth = useAuthStore()
|
||||
const firstName = ref(auth.user?.firstName ?? '')
|
||||
const lastName = ref(auth.user?.lastName ?? '')
|
||||
const nationalId = ref(auth.user?.nationalId ?? '')
|
||||
const address = ref(auth.user?.address ?? '')
|
||||
const avatarUrl = ref<string | null>(auth.user?.avatarUrl ?? null)
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const fieldErrors = ref<FieldErrors>({})
|
||||
@@ -96,12 +97,17 @@ async function save() {
|
||||
return
|
||||
}
|
||||
|
||||
const updated = await auth.updateProfile({
|
||||
const payload: Parameters<typeof auth.updateProfile>[0] = {
|
||||
firstName: firstName.value.trim(),
|
||||
lastName: lastName.value.trim(),
|
||||
nationalId: nationalId.value || undefined,
|
||||
avatarUrl: avatarUrl.value ?? '',
|
||||
})
|
||||
}
|
||||
if (auth.isAgent) {
|
||||
payload.address = address.value.trim()
|
||||
}
|
||||
|
||||
const updated = await auth.updateProfile(payload)
|
||||
if (!updated) {
|
||||
formError.value = 'ذخیره پروفایل ناموفق بود.'
|
||||
return
|
||||
@@ -184,6 +190,19 @@ async function save() {
|
||||
:error="fieldErrors.nationalId"
|
||||
@update:model-value="clearField('nationalId')"
|
||||
/>
|
||||
<div v-if="auth.isAgent" class="form-group profile-edit__full">
|
||||
<label class="form-label" for="profile-address">آدرس</label>
|
||||
<textarea
|
||||
id="profile-address"
|
||||
v-model="address"
|
||||
class="profile-edit__textarea"
|
||||
rows="3"
|
||||
maxlength="500"
|
||||
placeholder="آدرس کامل محل فعالیت"
|
||||
@input="clearField('address')"
|
||||
/>
|
||||
<p v-if="fieldErrors.address" class="input-error-text">{{ fieldErrors.address }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="profile-edit__actions">
|
||||
@@ -351,4 +370,29 @@ async function save() {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.profile-edit__textarea {
|
||||
width: 100%;
|
||||
min-height: 96px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font: inherit;
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.6;
|
||||
color: var(--color-text);
|
||||
resize: vertical;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.profile-edit__textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.input-error-text {
|
||||
color: var(--color-danger);
|
||||
font-size: 0.8125rem;
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -41,16 +41,26 @@ const profile = ref<AgentProfileDto | null>(null)
|
||||
const sampleProduct = ref<CatalogProductCard>(SAMPLE_PRODUCT)
|
||||
const loading = ref(false)
|
||||
|
||||
/** مطابق موکاپ ۱۱ — بدون gate نقش */
|
||||
const quickActions = [
|
||||
const agentQuickActions = [
|
||||
{ label: 'ثبت مشتری جدید', to: '/customers', icon: 'vuesax-linear:add' },
|
||||
{ label: 'سفارشها', to: '/orders', icon: 'vuesax-linear:document-text' },
|
||||
{ label: 'مشتریان من', to: '/customers', icon: 'vuesax-linear:people' },
|
||||
{ label: 'گزارشها', to: '/reminders', icon: 'vuesax-linear:chart-2' },
|
||||
{ label: 'درخواست کالیته', to: '/catalog-request', icon: 'vuesax-linear:category' },
|
||||
{ label: 'درخواست کالیته', to: '/categories', icon: 'vuesax-linear:category' },
|
||||
{ label: 'قیمتگذاری', to: '/categories', icon: 'vuesax-linear:money-4' },
|
||||
] as const
|
||||
|
||||
const customerQuickActions = [
|
||||
{ label: 'سفارشها', to: '/orders', icon: 'vuesax-linear:document-text' },
|
||||
{ label: 'دستهبندی', to: '/categories', icon: 'vuesax-linear:category-2' },
|
||||
{ label: 'درخواست کالیته', to: '/categories', icon: 'vuesax-linear:category' },
|
||||
{ label: 'علاقهمندیها', to: '/favorites', icon: 'vuesax-linear:heart' },
|
||||
] as const
|
||||
|
||||
const quickActions = computed(() =>
|
||||
auth.isAgent ? agentQuickActions : customerQuickActions,
|
||||
)
|
||||
|
||||
const recentOrders = computed(() =>
|
||||
[...orderStore.orders]
|
||||
.sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)))
|
||||
@@ -78,6 +88,7 @@ const recentOrders = computed(() =>
|
||||
|
||||
const displayName = computed(() => auth.fullName || 'کاربر')
|
||||
const code = computed(() => profile.value?.code || auth.user?.agentCode || '—')
|
||||
const codeLabel = computed(() => (auth.isAgent ? 'کد واسطه' : 'کد مشتری'))
|
||||
const levelLabel = computed(
|
||||
() => profile.value?.levelLabel || auth.user?.levelLabel || 'برنزی',
|
||||
)
|
||||
@@ -104,10 +115,12 @@ onMounted(async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
void notificationStore.hydrate()
|
||||
try {
|
||||
profile.value = await fetchAgentProfile()
|
||||
} catch {
|
||||
profile.value = null
|
||||
if (auth.isAgent) {
|
||||
try {
|
||||
profile.value = await fetchAgentProfile()
|
||||
} catch {
|
||||
profile.value = null
|
||||
}
|
||||
}
|
||||
try {
|
||||
const products = await crm.loadBrowseProducts()
|
||||
@@ -165,8 +178,8 @@ onMounted(async () => {
|
||||
</div>
|
||||
<div class="hero__meta">
|
||||
<h2>{{ displayName }}</h2>
|
||||
<p>کد واسطه: {{ code }}</p>
|
||||
<span class="level-pill">
|
||||
<p v-if="auth.isAgent">{{ codeLabel }}: {{ code }}</p>
|
||||
<span v-if="auth.isAgent" class="level-pill">
|
||||
<svg class="level-pill__crown" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path
|
||||
fill="currentColor"
|
||||
@@ -186,7 +199,7 @@ onMounted(async () => {
|
||||
<div class="profile-sheet">
|
||||
<PageSkeleton v-if="loading" variant="profile" />
|
||||
<template v-else>
|
||||
<section class="panel">
|
||||
<section v-if="auth.isAgent" class="panel">
|
||||
<div class="panel__head">
|
||||
<h3>خلاصه عملکرد</h3>
|
||||
<span class="panel__period">
|
||||
@@ -238,7 +251,7 @@ onMounted(async () => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="loyalty">
|
||||
<section v-if="auth.isAgent" class="loyalty">
|
||||
<div class="loyalty__row">
|
||||
<div class="loyalty__crown" aria-hidden="true">
|
||||
<svg viewBox="0 0 48 48" width="40" height="40">
|
||||
|
||||
Reference in New Issue
Block a user