Polish customer app UI and streamline CRM overlays.

Replace standalone customer create and meeting report pages with overlays, add TimeInput, remove opaque input backgrounds app-wide, improve desktop layouts/skeletons, and refresh shopping, customer, and reminder screens.
This commit is contained in:
2026-09-15 21:41:48 +03:30
parent d2c11e5d62
commit bbb3a15d1c
101 changed files with 2910 additions and 896 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

+27 -15
View File
@@ -19,19 +19,19 @@
--color-purple-light: #ede9fe;
--color-info: #3b6fa0;
--color-info-light: #eef3f7;
--color-bg: #f6f8f7;
--color-page-background: #f6f8f7;
--color-bg: #eef1f0;
--color-page-background: #eef1f0;
--color-shell-bg: #eef1f0;
--color-surface: #ffffff;
--color-border: #e5e9e8;
--color-surface: #eef1f0;
--color-border: #d8dedc;
--color-text: #121212;
--color-text-primary: #121212;
--color-text-muted: #6b7280;
--color-text-secondary: #6b7280;
--color-tab-inactive-bg: #f1f5f9;
--color-tab-inactive-bg: #e4e8e6;
--color-tab-inactive-text: #64748b;
--color-summary-bg: #f1f5f9;
--color-elevated: #ffffff;
--color-summary-bg: #e4e8e6;
--color-elevated: #eef1f0;
--color-brand-warm: #f3ece3;
--color-brand-warm-text: #1a202c;
--color-brand-warm-muted: #718096;
@@ -40,9 +40,9 @@
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--shadow-sm: 0 1px 2px rgba(16, 24, 40, 0.04);
--shadow-md: 0 4px 16px rgba(16, 24, 40, 0.06);
--shadow-card: 0 1px 2px rgba(16, 24, 40, 0.04), 0 4px 16px rgba(16, 24, 40, 0.04);
--shadow-sm: none;
--shadow-md: none;
--shadow-card: none;
--shadow-frame: 0 8px 32px rgba(15, 23, 42, 0.1);
--nav-height: 64px;
--page-padding: 16px;
@@ -84,9 +84,9 @@ html.dark {
--color-info-light: #1b2740;
--color-bg: #0b0f14;
--color-page-background: #0b0f14;
--color-shell-bg: #080b10;
--color-surface: #121821;
--color-elevated: #1a2230;
--color-shell-bg: #0b0f14;
--color-surface: #0b0f14;
--color-elevated: #0b0f14;
--color-border: #2a3444;
--color-text: #eef2f8;
--color-text-primary: #eef2f8;
@@ -158,6 +158,19 @@ textarea {
font-family: inherit;
}
input:not([type='checkbox']):not([type='radio']):not([type='submit']):not([type='button']):not([type='reset']):not([type='file']):not([type='hidden']):not([type='image']):not([type='range']):not([type='color']),
textarea,
select {
background-color: transparent;
}
.persian-date-input__trigger,
.time-input__trigger,
.search-field__input,
.field-select__trigger {
background-color: transparent;
}
input[type='number']::-webkit-inner-spin-button,
input[type='number']::-webkit-outer-spin-button {
-webkit-appearance: none;
@@ -212,7 +225,7 @@ input[type='number'] {
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
padding: 16px;
box-shadow: var(--shadow-sm);
box-shadow: none;
color: var(--color-text);
}
@@ -294,7 +307,6 @@ input[type='number'] {
.auth-page .otp-box {
border: 1px solid #eeeef1;
border-radius: 12px;
background: var(--color-surface);
}
.auth-page .btn {
@@ -0,0 +1,149 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { useCrmStore } from '@/stores/crm'
import BaseInput from '@/components/ui/BaseInput.vue'
import FormErrorBanner from '@/components/ui/FormErrorBanner.vue'
import BaseButton from '@/components/ui/BaseButton.vue'
import { normalizePhone } from '@/utils/format'
import { validatePhone } from '@/utils/validation'
import {
FORM_VALIDATION_SUMMARY,
clearFieldError,
hasFieldErrors,
type FieldErrors,
} from '@/utils/formErrors'
import { ApiError } from '@/services/http'
const emit = defineEmits<{
success: [id?: string]
}>()
const auth = useAuthStore()
const crm = useCrmStore()
const firstName = ref('')
const lastName = ref('')
const phone = ref('')
const businessName = ref('')
const fieldErrors = ref<FieldErrors>({})
const formError = ref('')
const saving = ref(false)
function clearField(key: string) {
fieldErrors.value = clearFieldError(fieldErrors.value, key)
if (!hasFieldErrors(fieldErrors.value)) formError.value = ''
}
async function submit() {
fieldErrors.value = {}
formError.value = ''
if (!auth.isAgent) {
formError.value = 'فقط واسطه می‌تواند مشتری به لیست خود اضافه کند.'
return
}
const normalized = normalizePhone(phone.value)
const errors: FieldErrors = {}
if (!firstName.value.trim()) errors.firstName = 'نام الزامی است.'
if (!lastName.value.trim()) errors.lastName = 'نام خانوادگی الزامی است.'
if (!validatePhone(normalized)) errors.phone = 'شماره موبایل معتبر نیست.'
if (hasFieldErrors(errors)) {
fieldErrors.value = errors
formError.value = FORM_VALIDATION_SUMMARY
return
}
saving.value = true
try {
const created = (await crm.registerCustomer({
firstName: firstName.value.trim(),
lastName: lastName.value.trim(),
phone: normalized,
businessName: businessName.value.trim() || undefined,
})) as { id?: string }
emit('success', created?.id)
} catch (e) {
formError.value =
e instanceof ApiError || e instanceof Error ? e.message : 'ثبت مشتری ناموفق بود.'
} finally {
saving.value = false
}
}
defineExpose({ submit, saving })
</script>
<template>
<div class="customer-create-form">
<p class="customer-create-form__intro">
مشتری به لیست شما اضافه می‌شود و بعد از ورود با همین شماره، سفارش‌هایش به حساب شما وصل می‌ماند.
</p>
<FormErrorBanner :message="formError" />
<div class="customer-create-form__fields">
<BaseInput
v-model="firstName"
label="نام"
placeholder="نام مشتری"
:error="fieldErrors.firstName"
@update:model-value="clearField('firstName')"
/>
<BaseInput
v-model="lastName"
label="نام خانوادگی"
placeholder="نام خانوادگی"
:error="fieldErrors.lastName"
@update:model-value="clearField('lastName')"
/>
<BaseInput
v-model="phone"
label="شماره موبایل"
type="tel"
inputmode="tel"
dir="ltr"
placeholder="۰۹۱۲۳۴۵۶۷۸۹"
:error="fieldErrors.phone"
@update:model-value="clearField('phone')"
/>
<BaseInput
v-model="businessName"
label="نام فروشگاه یا برند (اختیاری)"
placeholder="مثلاً بوتیک رز"
/>
</div>
<BaseButton block :loading="saving" :disabled="saving" @click="submit">
ثبت مشتری
</BaseButton>
</div>
</template>
<style scoped>
.customer-create-form {
display: flex;
flex-direction: column;
gap: 12px;
}
.customer-create-form__intro {
margin: 0;
color: var(--color-text-muted);
font-size: 0.875rem;
line-height: 1.7;
}
.customer-create-form__fields {
display: flex;
flex-direction: column;
gap: 12px;
background: var(--color-surface);
border-radius: var(--radius-md);
padding: 16px;
box-shadow: var(--shadow-sm);
}
</style>
@@ -0,0 +1,183 @@
<script setup lang="ts">
import { onBeforeUnmount, watch } from 'vue'
import CustomerCreateForm from '@/components/customers/CustomerCreateForm.vue'
const props = defineProps<{
open: boolean
}>()
const emit = defineEmits<{
close: []
success: [id?: string]
}>()
function close() {
emit('close')
}
function onSuccess(id?: string) {
emit('success', id)
}
function onKeydown(event: KeyboardEvent) {
if (event.key === 'Escape' && props.open) 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)
})
</script>
<template>
<Teleport to="body">
<Transition name="customer-create-sheet">
<div
v-if="open"
class="customer-create-overlay"
role="dialog"
aria-modal="true"
aria-labelledby="customer-create-title"
@click.self="close"
>
<div class="customer-create-sheet">
<div class="customer-create-sheet__handle" aria-hidden="true" />
<header class="customer-create-sheet__head">
<h2 id="customer-create-title">افزودن مشتری</h2>
<button
type="button"
class="customer-create-sheet__close"
aria-label="بستن"
@click="close"
>
×
</button>
</header>
<CustomerCreateForm @success="onSuccess" />
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.customer-create-overlay {
position: fixed;
inset: 0;
z-index: 200;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: flex-end;
justify-content: center;
padding: 0;
}
.customer-create-sheet {
width: 100%;
max-width: 520px;
max-height: min(92dvh, 680px);
overflow-y: auto;
background: var(--color-bg);
color: var(--color-text);
border-radius: 20px 20px 0 0;
padding: 10px 16px calc(16px + env(safe-area-inset-bottom, 0px));
box-shadow: var(--shadow-md);
display: flex;
flex-direction: column;
gap: 16px;
}
.customer-create-sheet__handle {
width: 40px;
height: 4px;
border-radius: 999px;
background: var(--color-border);
margin: 4px auto 0;
}
.customer-create-sheet__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.customer-create-sheet__head h2 {
margin: 0;
font-size: 1.0625rem;
font-weight: 700;
}
.customer-create-sheet__close {
width: 36px;
height: 36px;
border: none;
border-radius: 999px;
background: var(--color-surface);
color: var(--color-text-muted);
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
display: grid;
place-items: center;
padding: 0;
}
.customer-create-sheet-enter-active,
.customer-create-sheet-leave-active {
transition: opacity 0.2s ease;
}
.customer-create-sheet-enter-active .customer-create-sheet,
.customer-create-sheet-leave-active .customer-create-sheet {
transition: transform 0.22s ease;
}
.customer-create-sheet-enter-from,
.customer-create-sheet-leave-to {
opacity: 0;
}
.customer-create-sheet-enter-from .customer-create-sheet,
.customer-create-sheet-leave-to .customer-create-sheet {
transform: translateY(100%);
}
@media (min-width: 768px) {
.customer-create-overlay {
align-items: center;
padding: 24px;
}
.customer-create-sheet {
border-radius: 16px;
max-width: 440px;
max-height: min(85dvh, 620px);
padding: 20px 20px 24px;
background: var(--color-surface);
}
.customer-create-sheet__handle {
display: none;
}
.customer-create-sheet-enter-from .customer-create-sheet,
.customer-create-sheet-leave-to .customer-create-sheet {
transform: translateY(16px) scale(0.98);
}
}
</style>
@@ -1,20 +1,24 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { onMounted, ref, watch } from 'vue'
import { useCrmStore } from '@/stores/crm'
import type { CrmCustomer, MeetingInteractionType } from '@/types/crm'
import PageHeader from '@/components/layout/PageHeader.vue'
import PageSkeleton from '@/components/ui/PageSkeleton.vue'
import IconCheckCircleOutline from '@/components/icons/IconCheckCircleOutline.vue'
import PersianDateInput from '@/components/ui/PersianDateInput.vue'
import TimeInput from '@/components/ui/TimeInput.vue'
import IconClipboardEmpty from '@/components/icons/IconClipboardEmpty.vue'
import IconPhone from '@/components/icons/IconPhone.vue'
import IconMail from '@/components/icons/IconMail.vue'
import IconEditPen from '@/components/icons/IconEditPen.vue'
const route = useRoute()
const router = useRouter()
const props = defineProps<{
customerId: string
}>()
const emit = defineEmits<{
success: []
}>()
const crm = useCrmStore()
const customerId = computed(() => String(route.query.customerId ?? ''))
const customer = ref<CrmCustomer | null>(null)
const loading = ref(true)
const saving = ref(false)
@@ -38,32 +42,41 @@ const types: Array<{ id: MeetingInteractionType; label: string; icon: 'clipboard
{ id: 'note', label: 'یادداشت', icon: 'pen' },
]
onMounted(async () => {
if (!customerId.value) {
async function loadCustomer() {
if (!props.customerId) {
loading.value = false
error.value = 'مشتری مشخص نشده است.'
customer.value = null
return
}
loading.value = true
error.value = null
try {
const data = await crm.loadCustomerDetail(customerId.value)
const data = await crm.loadCustomerDetail(props.customerId)
customer.value = data.customer
} catch (e) {
customer.value = null
error.value = e instanceof Error ? e.message : 'بارگذاری مشتری ناموفق بود.'
} finally {
loading.value = false
}
})
}
onMounted(loadCustomer)
watch(() => props.customerId, loadCustomer)
async function submit() {
if (!customerId.value || !subject.value.trim() || !date.value.trim()) {
if (!props.customerId || !subject.value.trim() || !date.value.trim()) {
error.value = 'موضوع و تاریخ الزامی است.'
return
}
saving.value = true
error.value = null
try {
await crm.submitMeeting({
customerId: customerId.value,
customerId: props.customerId,
type: interactionType.value,
title: subject.value.trim(),
subject: subject.value.trim(),
@@ -81,14 +94,9 @@ async function submit() {
reminderTime: reminderOn.value
? reminderTime.value.trim() || time.value.trim() || '10:00'
: undefined,
reminderNote: reminderOn.value
? reminderNote.value.trim() || undefined
: undefined,
})
router.push({
path: '/customer',
query: { id: customerId.value, tab: 'reports' },
reminderNote: reminderOn.value ? reminderNote.value.trim() || undefined : undefined,
})
emit('success')
} catch (e) {
error.value = e instanceof Error ? e.message : 'ثبت گزارش ناموفق بود.'
} finally {
@@ -98,25 +106,12 @@ async function submit() {
</script>
<template>
<div class="page meeting-page">
<PageHeader title="ثبت گزارش ملاقات" show-back centered @back="router.back()">
<template #trailing>
<button
type="button"
class="submit-icon-btn"
aria-label="ثبت گزارش"
:disabled="saving"
@click="submit"
>
<IconCheckCircleOutline :size="22" />
</button>
</template>
</PageHeader>
<div class="meeting-report-form">
<PageSkeleton v-if="loading" variant="form" />
<p v-else-if="error" class="form-error">{{ error }}</p>
<div class="page__scroll meeting-scroll">
<PageSkeleton v-if="loading" variant="form" />
<p v-else-if="error" class="form-error">{{ error }}</p>
<section v-if="customer" class="customer-card">
<template v-if="customer">
<section class="customer-card">
<div class="avatar" :style="{ background: customer.avatarColor }">
{{ customer.avatarLetter }}
</div>
@@ -149,15 +144,9 @@ async function submit() {
<section>
<h3>تاریخ و زمان</h3>
<div class="two-cols">
<label class="field">
<span>تاریخ</span>
<input v-model="date" type="text" />
</label>
<label class="field">
<span>ساعت</span>
<input v-model="time" type="text" />
</label>
<div class="two-cols two-cols--date">
<PersianDateInput v-model="date" label="تاریخ" class="meeting-date" />
<TimeInput v-model="time" label="ساعت" placeholder="مثلاً ۱۰:۳۰" class="meeting-time" />
</div>
</section>
@@ -199,15 +188,14 @@ async function submit() {
<span>عنوان یادآور</span>
<input v-model="reminderTitle" type="text" />
</label>
<div class="two-cols">
<label class="field">
<span>تاریخ یادآور</span>
<input v-model="reminderDate" type="text" />
</label>
<label class="field">
<span>ساعت یادآور</span>
<input v-model="reminderTime" type="text" />
</label>
<div class="two-cols two-cols--date">
<PersianDateInput v-model="reminderDate" label="تاریخ یادآور" class="meeting-date" />
<TimeInput
v-model="reminderTime"
label="ساعت یادآور"
placeholder="مثلاً ۱۰:۳۰"
class="meeting-time"
/>
</div>
<label class="field">
<span>یادداشت یادآور (اختیاری)</span>
@@ -215,28 +203,27 @@ async function submit() {
</label>
</template>
</section>
</div>
<footer class="footer">
<button type="button" class="submit" :disabled="saving" @click="submit">
{{ saving ? 'در حال ثبت...' : 'ثبت گزارش و یادآور' }}
</button>
</footer>
</template>
</div>
</template>
<style scoped>
.meeting-page {
background: var(--color-bg);
}
.meeting-scroll {
padding: 8px var(--page-padding) 100px;
.meeting-report-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.form-error {
margin: 0;
color: var(--color-danger);
font-size: 0.875rem;
}
.customer-card {
display: flex;
gap: 12px;
@@ -273,23 +260,6 @@ h3 {
gap: 8px;
}
.submit-icon-btn {
width: 40px;
height: 40px;
border: none;
background: transparent;
color: var(--color-primary);
cursor: pointer;
display: grid;
place-items: center;
padding: 0;
}
.submit-icon-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.type-btn {
border: 1.5px solid var(--color-border);
background: var(--color-surface);
@@ -319,6 +289,62 @@ h3 {
gap: 8px;
}
.two-cols--date {
align-items: end;
}
.meeting-report-form :deep(.meeting-time.form-group),
.meeting-report-form :deep(.meeting-date.form-group) {
margin-bottom: 0;
}
.meeting-report-form :deep(.meeting-time .form-label),
.meeting-report-form :deep(.meeting-date .form-label) {
font-size: 0.85rem;
font-weight: 400;
margin-bottom: 6px;
}
.meeting-report-form :deep(.meeting-date .persian-date-input__trigger) {
border-radius: 12px;
padding-inline: 10px;
font-size: 0.8125rem;
gap: 6px;
}
.meeting-report-form :deep(.meeting-date .persian-date-input__trigger > span:first-child) {
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.meeting-report-form :deep(.meeting-date .persian-date-input__icon) {
flex-shrink: 0;
}
.meeting-report-form :deep(.meeting-date .persian-date-input__icon svg) {
width: 16px;
height: 16px;
}
.meeting-report-form :deep(.meeting-time .time-input__trigger) {
border-radius: 12px;
padding-inline: 10px;
font-size: 0.8125rem;
gap: 6px;
}
.meeting-report-form :deep(.meeting-time .time-input__icon svg) {
width: 16px;
height: 16px;
}
.reminder-box :deep(.meeting-time .time-input__panel) {
top: auto;
bottom: calc(100% + 6px);
}
.field {
display: flex;
flex-direction: column;
@@ -330,7 +356,6 @@ h3 {
.field textarea {
border: 1px solid var(--color-border);
border-radius: 12px;
background: #f5f5f5;
padding: 12px;
font: inherit;
resize: vertical;
@@ -397,7 +422,7 @@ h3 {
width: 22px;
height: 22px;
border-radius: 999px;
background: #fff;
background: var(--color-surface);
transition: transform 0.15s;
}
@@ -409,14 +434,6 @@ h3 {
transform: translateX(-18px);
}
.footer {
position: sticky;
bottom: 0;
padding: 12px var(--page-padding) calc(12px + env(safe-area-inset-bottom, 0));
background: var(--color-surface);
border-top: 1px solid var(--color-border);
}
.submit {
width: 100%;
min-height: 52px;
@@ -427,4 +444,9 @@ h3 {
font-weight: 700;
cursor: pointer;
}
.submit:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>
@@ -0,0 +1,189 @@
<script setup lang="ts">
import { onBeforeUnmount, watch } from 'vue'
import MeetingReportForm from '@/components/customers/MeetingReportForm.vue'
const props = defineProps<{
open: boolean
customerId: string
}>()
const emit = defineEmits<{
close: []
success: []
}>()
function close() {
emit('close')
}
function onSuccess() {
emit('success')
}
function onKeydown(event: KeyboardEvent) {
if (event.key === 'Escape' && props.open) 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)
})
</script>
<template>
<Teleport to="body">
<Transition name="meeting-report-sheet">
<div
v-if="open"
class="meeting-report-overlay"
role="dialog"
aria-modal="true"
aria-labelledby="meeting-report-title"
@click.self="close"
>
<div class="meeting-report-sheet">
<div class="meeting-report-sheet__handle" aria-hidden="true" />
<header class="meeting-report-sheet__head">
<h2 id="meeting-report-title">ثبت گزارش ملاقات</h2>
<button
type="button"
class="meeting-report-sheet__close"
aria-label="بستن"
@click="close"
>
×
</button>
</header>
<MeetingReportForm
v-if="customerId"
:key="customerId"
:customer-id="customerId"
@success="onSuccess"
/>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.meeting-report-overlay {
position: fixed;
inset: 0;
z-index: 200;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: flex-end;
justify-content: center;
padding: 0;
}
.meeting-report-sheet {
width: 100%;
max-width: 560px;
max-height: min(94dvh, 760px);
overflow-y: auto;
background: var(--color-bg);
color: var(--color-text);
border-radius: 20px 20px 0 0;
padding: 10px 16px calc(16px + env(safe-area-inset-bottom, 0px));
box-shadow: var(--shadow-md);
display: flex;
flex-direction: column;
gap: 16px;
}
.meeting-report-sheet__handle {
width: 40px;
height: 4px;
border-radius: 999px;
background: var(--color-border);
margin: 4px auto 0;
}
.meeting-report-sheet__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.meeting-report-sheet__head h2 {
margin: 0;
font-size: 1.0625rem;
font-weight: 700;
}
.meeting-report-sheet__close {
width: 36px;
height: 36px;
border: none;
border-radius: 999px;
background: var(--color-surface);
color: var(--color-text-muted);
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
display: grid;
place-items: center;
padding: 0;
}
.meeting-report-sheet-enter-active,
.meeting-report-sheet-leave-active {
transition: opacity 0.2s ease;
}
.meeting-report-sheet-enter-active .meeting-report-sheet,
.meeting-report-sheet-leave-active .meeting-report-sheet {
transition: transform 0.22s ease;
}
.meeting-report-sheet-enter-from,
.meeting-report-sheet-leave-to {
opacity: 0;
}
.meeting-report-sheet-enter-from .meeting-report-sheet,
.meeting-report-sheet-leave-to .meeting-report-sheet {
transform: translateY(100%);
}
@media (min-width: 768px) {
.meeting-report-overlay {
align-items: center;
padding: 24px;
}
.meeting-report-sheet {
border-radius: 16px;
max-width: 520px;
max-height: min(88dvh, 720px);
padding: 20px 20px 24px;
background: var(--color-surface);
}
.meeting-report-sheet__handle {
display: none;
}
.meeting-report-sheet-enter-from .meeting-report-sheet,
.meeting-report-sheet-leave-to .meeting-report-sheet {
transform: translateY(16px) scale(0.98);
}
}
</style>
+35
View File
@@ -0,0 +1,35 @@
<script setup lang="ts">
withDefaults(
defineProps<{
size?: number
color?: string
strokeWidth?: number
}>(),
{ size: 24, color: 'currentColor', strokeWidth: 1.5 },
)
</script>
<template>
<svg
:width="size"
:height="size"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
d="M8.25 5.25 12 7.75l3.75-2.5 2.25 2.75L16 9.5V18.5a1 1 0 0 1-1 1H9a1 1 0 0 1-1-1V9.5L6 8l2.25-2.75Z"
:stroke="color"
:stroke-width="strokeWidth"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M12 7.75V18.5"
:stroke="color"
:stroke-width="strokeWidth"
stroke-linecap="round"
/>
</svg>
</template>
+48
View File
@@ -0,0 +1,48 @@
<script setup lang="ts">
withDefaults(
defineProps<{
size?: number
color?: string
strokeWidth?: number
}>(),
{ size: 24, color: 'currentColor', strokeWidth: 1.5 },
)
</script>
<template>
<svg
:width="size"
:height="size"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
d="M7.5 5.5h9l2 3v10.5a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1V8.5l2-3Z"
:stroke="color"
:stroke-width="strokeWidth"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M12 5.5v13"
:stroke="color"
:stroke-width="strokeWidth"
stroke-linecap="round"
/>
<path
d="M7.5 9 12 11.75 16.5 9"
:stroke="color"
:stroke-width="strokeWidth"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M10.25 5.5V4a1.75 1.75 0 0 1 3.5 0v1.5"
:stroke="color"
:stroke-width="strokeWidth"
stroke-linecap="round"
/>
</svg>
</template>
+41
View File
@@ -0,0 +1,41 @@
<script setup lang="ts">
withDefaults(
defineProps<{
size?: number
color?: string
strokeWidth?: number
}>(),
{ size: 24, color: 'currentColor', strokeWidth: 1.5 },
)
</script>
<template>
<svg
:width="size"
:height="size"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
d="M8 4.5h8v4.25l-1.75 11H13l-1-9.5-1 9.5H7.75L6 8.75V4.5Z"
:stroke="color"
:stroke-width="strokeWidth"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M12 8.75v11"
:stroke="color"
:stroke-width="strokeWidth"
stroke-linecap="round"
/>
<path
d="M8 7.25h8"
:stroke="color"
:stroke-width="strokeWidth"
stroke-linecap="round"
/>
</svg>
</template>
-1
View File
@@ -73,7 +73,6 @@ const resolvedAlign = computed(() => props.align ?? (isNumericField.value ? 'end
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: 0.9375rem;
background: var(--color-surface);
color: var(--color-text);
transition: border-color 0.15s;
}
-1
View File
@@ -61,7 +61,6 @@ function onInput(event: Event) {
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: 0.9375rem;
background: var(--color-surface);
color: var(--color-text);
transition: border-color 0.15s;
text-align: right;
+158 -11
View File
@@ -15,24 +15,51 @@ withDefaults(
</script>
<template>
<div class="page-skeleton" role="status" aria-live="polite" aria-busy="true" aria-label="در حال بارگذاری">
<div
class="page-skeleton"
:class="{ 'page-skeleton--home': variant === 'home' }"
role="status"
aria-live="polite"
aria-busy="true"
aria-label="در حال بارگذاری"
>
<!-- Home -->
<template v-if="variant === 'home'">
<div class="page-skeleton__banner">
<SkeletonBlock height="148px" radius="16px" />
<div class="page-skeleton__hero">
<SkeletonBlock class="page-skeleton__hero-block" height="148px" radius="16px" />
</div>
<div class="page-skeleton__quick">
<div v-for="n in 4" :key="`q-${n}`" class="page-skeleton__quick-item">
<SkeletonBlock circle width="48px" height="48px" />
<SkeletonBlock width="64%" height="10px" />
<SkeletonBlock width="72%" height="10px" />
</div>
</div>
<SkeletonBlock width="40%" height="16px" />
<div class="page-skeleton__grid">
<div v-for="n in 4" :key="`g-${n}`" class="page-skeleton__card">
<SkeletonBlock height="110px" radius="12px" />
<SkeletonBlock width="80%" height="12px" />
<SkeletonBlock width="50%" height="10px" />
<div class="page-skeleton__level">
<div class="page-skeleton__level-top">
<SkeletonBlock circle width="48px" height="48px" />
<div class="page-skeleton__level-text">
<SkeletonBlock width="56px" height="10px" />
<SkeletonBlock width="88px" height="16px" />
<SkeletonBlock width="104px" height="10px" />
</div>
</div>
<SkeletonBlock height="8px" radius="999px" />
<SkeletonBlock width="62%" height="10px" />
</div>
<div class="page-skeleton__products">
<div class="page-skeleton__section-head">
<SkeletonBlock width="132px" height="16px" />
<SkeletonBlock width="72px" height="12px" />
</div>
<div class="page-skeleton__grid page-skeleton__product-grid">
<div v-for="n in 6" :key="`g-${n}`" class="page-skeleton__card">
<SkeletonBlock class="page-skeleton__product-image" height="110px" radius="12px" />
<SkeletonBlock width="82%" height="12px" />
<SkeletonBlock width="54%" height="10px" />
</div>
</div>
</div>
</template>
@@ -126,7 +153,7 @@ withDefaults(
width: 100%;
}
.page-skeleton__banner,
.page-skeleton__hero,
.page-skeleton__panel,
.page-skeleton__detail-body,
.page-skeleton__form {
@@ -135,6 +162,52 @@ withDefaults(
gap: 12px;
}
.page-skeleton__hero {
width: 100%;
}
.page-skeleton__hero-block {
width: 100%;
}
.page-skeleton__level {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: transparent;
}
.page-skeleton__level-top {
display: flex;
align-items: center;
gap: 12px;
}
.page-skeleton__level-text {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.page-skeleton__products {
display: flex;
flex-direction: column;
gap: 12px;
min-width: 0;
}
.page-skeleton__section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.page-skeleton__quick {
display: grid;
grid-template-columns: repeat(4, 1fr);
@@ -228,8 +301,82 @@ withDefaults(
}
@media (min-width: 768px) {
.page-skeleton {
padding: 8px 0 28px;
}
.page-skeleton--home {
display: grid;
grid-column: 1 / -1;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-areas:
'hero hero level'
'quick quick quick'
'products products products';
gap: 18px 20px;
width: 100%;
min-width: 0;
}
.page-skeleton__hero {
grid-area: hero;
aspect-ratio: 2.1 / 1;
min-height: 0;
}
.page-skeleton__hero-block {
height: 100% !important;
border-radius: 20px !important;
}
.page-skeleton__quick {
grid-area: quick;
gap: 14px;
}
.page-skeleton__quick-item {
min-height: 96px;
justify-content: center;
padding: 12px 8px;
border: 1px solid var(--color-border);
border-radius: 16px;
}
.page-skeleton__level {
grid-area: level;
align-self: stretch;
justify-content: center;
padding: 20px 22px;
}
.page-skeleton__products {
grid-area: products;
}
.page-skeleton__grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
}
.page-skeleton__product-image {
height: 160px !important;
}
.page-skeleton__profile-hero {
align-items: flex-start;
padding: 0 0 8px;
}
.page-skeleton__panel {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.page-skeleton__list {
gap: 12px;
}
.page-skeleton__row--card {
padding: 14px 16px;
}
}
</style>
-1
View File
@@ -190,7 +190,6 @@ onBeforeUnmount(() => {
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: 0.9375rem;
background: var(--color-surface);
color: var(--color-text);
transition: border-color 0.15s;
}
-1
View File
@@ -28,7 +28,6 @@ defineEmits<{
padding: 0 16px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface);
color: var(--color-text);
font-family: inherit;
font-size: 0.9375rem;
+371
View File
@@ -0,0 +1,371 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { toPersianDigits } from '@/utils/format'
import IconProgressClock from '@/components/icons/IconProgressClock.vue'
const props = defineProps<{
modelValue?: string
label?: string
placeholder?: string
hint?: string
error?: string
disabled?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const rootRef = ref<HTMLElement | null>(null)
const isOpen = ref(false)
const draftHour = ref(10)
const draftMinute = ref(0)
const minuteSteps = [0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55]
const parsed = computed(() => parseTime(props.modelValue ?? ''))
const displayValue = computed(() => {
if (!parsed.value) return ''
return formatTimeLabel(parsed.value.hour, parsed.value.minute)
})
watch(
() => props.modelValue,
(value) => {
const next = parseTime(value ?? '')
if (next) {
draftHour.value = next.hour
draftMinute.value = snapMinute(next.minute)
}
},
{ immediate: true },
)
function snapMinute(minute: number) {
const snapped = Math.round(minute / 5) * 5
return snapped === 60 ? 55 : snapped
}
function parseTime(value: string) {
const match = /^(\d{1,2}):(\d{2})$/.exec(value.trim())
if (!match) return null
const hour = Number(match[1])
const minute = Number(match[2])
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return null
return { hour, minute }
}
function formatTimeLabel(hour: number, minute: number) {
return toPersianDigits(
`${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`,
)
}
function padTime(hour: number, minute: number) {
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`
}
function togglePanel() {
if (props.disabled) return
if (!isOpen.value) {
if (parsed.value) {
draftHour.value = parsed.value.hour
draftMinute.value = snapMinute(parsed.value.minute)
} else {
const now = new Date()
draftHour.value = now.getHours()
draftMinute.value = snapMinute(now.getMinutes())
}
}
isOpen.value = !isOpen.value
}
function closePanel() {
isOpen.value = false
}
function stepHour(delta: number) {
draftHour.value = (draftHour.value + delta + 24) % 24
}
function stepMinute(delta: number) {
const index = minuteSteps.indexOf(draftMinute.value)
const base = index < 0 ? 0 : index
const next = (base + delta + minuteSteps.length) % minuteSteps.length
draftMinute.value = minuteSteps[next]
}
function confirmSelection() {
emit('update:modelValue', padTime(draftHour.value, draftMinute.value))
closePanel()
}
function selectNow() {
const now = new Date()
const hour = now.getHours()
const minute = snapMinute(now.getMinutes())
draftHour.value = hour
draftMinute.value = minute
emit('update:modelValue', padTime(hour, minute))
closePanel()
}
function onDocumentClick(event: MouseEvent) {
if (!rootRef.value?.contains(event.target as Node)) {
closePanel()
}
}
onMounted(() => {
document.addEventListener('click', onDocumentClick)
})
onBeforeUnmount(() => {
document.removeEventListener('click', onDocumentClick)
})
</script>
<template>
<div ref="rootRef" class="form-group time-input">
<label v-if="label" class="form-label">{{ label }}</label>
<button
type="button"
class="input time-input__trigger"
:class="{ 'input-error': error, 'is-open': isOpen }"
:disabled="disabled"
@click.stop="togglePanel"
>
<span :class="{ 'time-input__placeholder': !displayValue }">
{{ displayValue || placeholder || 'انتخاب ساعت' }}
</span>
<span class="time-input__icon" aria-hidden="true">
<IconProgressClock :size="18" />
</span>
</button>
<div v-if="isOpen" class="time-input__panel" @click.stop>
<div class="time-input__picker" role="group" aria-label="انتخاب ساعت">
<div class="time-input__spinner">
<span class="time-input__spinner-label">ساعت</span>
<button type="button" class="time-input__step" aria-label="یک ساعت بعد" @click="stepHour(1)">
<svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true">
<path d="M6 14l6-6 6 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
<strong class="time-input__value">{{ toPersianDigits(String(draftHour).padStart(2, '0')) }}</strong>
<button type="button" class="time-input__step" aria-label="یک ساعت قبل" @click="stepHour(-1)">
<svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true">
<path d="M6 10l6 6 6-6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
</div>
<span class="time-input__colon" aria-hidden="true">:</span>
<div class="time-input__spinner">
<span class="time-input__spinner-label">دقیقه</span>
<button type="button" class="time-input__step" aria-label="پنج دقیقه بعد" @click="stepMinute(1)">
<svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true">
<path d="M6 14l6-6 6 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
<strong class="time-input__value">
{{ toPersianDigits(String(draftMinute).padStart(2, '0')) }}
</strong>
<button type="button" class="time-input__step" aria-label="پنج دقیقه قبل" @click="stepMinute(-1)">
<svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true">
<path d="M6 10l6 6 6-6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
</div>
</div>
<div class="time-input__footer">
<button type="button" class="time-input__now" @click="selectNow">اکنون</button>
<button type="button" class="time-input__confirm" @click="confirmSelection">تأیید</button>
</div>
</div>
<p v-if="error" class="input-error-text">{{ error }}</p>
<p v-else-if="hint" class="form-hint">{{ hint }}</p>
</div>
</template>
<style scoped>
.time-input {
position: relative;
}
.input {
width: 100%;
height: var(--btn-height);
padding: 0 14px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: 0.9375rem;
color: var(--color-text);
transition: border-color 0.15s;
}
.time-input__trigger {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
cursor: pointer;
text-align: right;
}
.time-input__trigger:focus {
outline: none;
border-color: var(--color-primary);
}
.time-input__trigger.is-open,
.time-input__trigger:focus-visible {
border-color: var(--color-primary);
}
.time-input__trigger:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.time-input__placeholder {
color: var(--color-text-muted);
}
.time-input__trigger > span:first-child {
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.time-input__icon {
display: flex;
align-items: center;
flex-shrink: 0;
color: var(--color-text-muted);
}
.input-error {
border-color: var(--color-danger);
}
.input-error-text {
color: var(--color-danger);
font-size: 0.8125rem;
margin: 4px 0 0;
}
.time-input__panel {
position: absolute;
top: calc(100% + 4px);
right: 0;
left: 0;
z-index: 320;
padding: 8px;
border: 1px solid var(--color-border);
border-radius: 12px;
background: var(--color-surface);
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);
}
.time-input__picker {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 6px;
align-items: center;
}
.time-input__spinner {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 4px 6px 6px;
border: 1px solid var(--color-border);
border-radius: 10px;
background: transparent;
}
.time-input__spinner-label {
font-size: 0.6875rem;
color: var(--color-text-muted);
line-height: 1.2;
}
.time-input__value {
display: flex;
align-items: center;
justify-content: center;
min-height: 24px;
font-size: 0.9375rem;
font-weight: 700;
letter-spacing: 0.04em;
color: var(--color-text);
}
.time-input__step {
width: 28px;
height: 24px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--color-text-muted);
display: grid;
place-items: center;
padding: 0;
cursor: pointer;
}
.time-input__step:hover {
background: var(--color-primary-soft);
color: var(--color-primary);
}
.time-input__colon {
font-size: 0.9375rem;
font-weight: 700;
color: var(--color-text-muted);
line-height: 1;
padding-top: 12px;
}
.time-input__footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--color-border);
}
.time-input__now {
border: none;
background: transparent;
color: var(--color-primary);
font: inherit;
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
padding: 4px 0;
}
.time-input__confirm {
min-height: 32px;
padding: 0 14px;
border: none;
border-radius: 8px;
background: var(--color-primary);
color: #fff;
font: inherit;
font-size: 0.75rem;
font-weight: 700;
cursor: pointer;
}
</style>
+12 -6
View File
@@ -96,9 +96,10 @@ const router = createRouter({
},
{
path: '/customers/new',
name: 'customer-new',
component: () => import('@/views/customers/CustomerCreateView.vue'),
meta: { requiresAuth: true, hideNav: true },
redirect: (to) => ({
path: '/customers',
query: { ...to.query, create: '1' },
}),
},
{
path: '/customer',
@@ -108,9 +109,14 @@ const router = createRouter({
},
{
path: '/meeting-report',
name: 'meeting-report',
component: () => import('@/views/customers/MeetingReportView.vue'),
meta: { requiresAuth: true, hideNav: true },
redirect: (to) => ({
path: '/customer',
query: {
id: String(to.query.customerId ?? ''),
tab: 'reports',
meetingReport: '1',
},
}),
},
{
path: '/reminders',
+19
View File
@@ -8,6 +8,9 @@ import IconIdCard from '@/components/icons/IconIdCard.vue'
import IconCatalog from '@/components/icons/IconCatalog.vue'
import IconHeadset from '@/components/icons/IconHeadset.vue'
import IconHome from '@/components/icons/IconHome.vue'
import IconShirt from '@/components/icons/IconShirt.vue'
import IconSuit from '@/components/icons/IconSuit.vue'
import IconTrousers from '@/components/icons/IconTrousers.vue'
export const CATEGORY_ICON_MAP: Record<string, Component> = {
bolt: IconBolt,
@@ -19,6 +22,22 @@ export const CATEGORY_ICON_MAP: Record<string, Component> = {
catalog: IconCatalog,
headset: IconHeadset,
home: IconHome,
shirt: IconShirt,
suit: IconSuit,
trousers: IconTrousers,
}
const USAGE_ICON_BY_ID: Record<string, string> = {
shirt: 'shirt',
suit: 'suit',
trousers: 'trousers',
}
export function usageCategoryIconKey(item: { id: string; icon?: string }) {
// آیکون‌های ذخیره‌شده در ادمین (grid/bag/id-card) را برای دسته‌های استاندارد نادیده بگیر
if (USAGE_ICON_BY_ID[item.id]) return USAGE_ICON_BY_ID[item.id]
if (item.icon && CATEGORY_ICON_MAP[item.icon]) return item.icon
return item.icon ?? 'grid'
}
export function categoryIconOf(icon?: string, fallback: Component = IconDiamond) {
+1 -2
View File
@@ -90,7 +90,7 @@ onMounted(async () => {
<template>
<div class="page login-page">
<img class="login-page__bg" :src="fabricUrl" alt="" />
<img class="login-page__fabric" :src="fabricUrl2" alt="" />
<!-- <img class="login-page__fabric" :src="fabricUrl2" alt="" />-->
<div class="login-page__scroll">
<div class="login-page__stage">
@@ -346,7 +346,6 @@ onMounted(async () => {
padding: 0 8px 0 14px;
border: 1px solid #e4e0db;
border-radius: 14px;
background: #e4ddd4;
}
.phone-field.is-invalid {
-2
View File
@@ -456,7 +456,6 @@ onMounted(() => {
padding: 0;
border: 1px solid #e4e0db;
border-radius: 16px;
background: #f7f5f1;
color: #1c1b19;
font-family: inherit;
font-size: 1.25rem;
@@ -469,7 +468,6 @@ onMounted(() => {
.otp-box:focus {
outline: none;
border-color: var(--color-primary);
background: #fff;
}
.otp-box--error {
-1
View File
@@ -385,7 +385,6 @@ async function submit() {
padding: 0 14px;
border: 1px solid #e0d9d0;
border-radius: 14px;
background: #ebe4dc;
}
.reg-field.is-invalid,
+8 -7
View File
@@ -400,8 +400,8 @@ async function addAllToInquiry() {
<style scoped>
.calite-detail {
background: #f4f0ed;
color: #2a2622;
background: var(--color-bg);
color: var(--color-text);
}
.calite-detail__nav,
@@ -563,7 +563,7 @@ async function addAllToInquiry() {
top: 16%;
bottom: 16%;
width: 1px;
background: #e4dfd8;
background: var(--color-border);
}
.attr__icon {
@@ -617,7 +617,7 @@ async function addAllToInquiry() {
padding: 0 14px;
border: none;
border-radius: 999px;
background: #ece8e2;
background: var(--color-tab-inactive-bg);
color: #6a645e;
font: inherit;
font-size: 0.8125rem;
@@ -644,11 +644,12 @@ async function addAllToInquiry() {
overflow: hidden;
border: none;
border-radius: 20px;
background: #fff;
background: var(--color-surface);
text-align: start;
cursor: pointer;
color: #1c1c1c;
box-shadow: 0 1px 3px rgba(40, 32, 24, 0.05);
box-shadow: none;
border: 1px solid var(--color-border);
}
.pattern-card__photo {
@@ -903,7 +904,7 @@ html.dark .calite-detail__footer.sticky-footer {
width: 100%;
max-width: none;
padding: 12px 28px 24px;
background: #f4f0ed;
background: var(--color-bg);
}
.inquiry-bar {
@@ -175,7 +175,6 @@ async function submit() {
border-radius: 10px;
text-align: center;
font: inherit;
background: var(--color-bg);
color: var(--color-text);
}
@@ -193,7 +192,6 @@ async function submit() {
padding: 10px 12px;
font: inherit;
resize: vertical;
background: var(--color-surface);
color: var(--color-text);
}
+50 -30
View File
@@ -10,7 +10,7 @@ import IconChevronLeft from '@/components/icons/IconChevronLeft.vue'
import IconHeadset from '@/components/icons/IconHeadset.vue'
import IconDiamond from '@/components/icons/IconDiamond.vue'
import IconCatalog from '@/components/icons/IconCatalog.vue'
import { categoryIconOf } from '@/utils/categoryIcons'
import { categoryIconOf, usageCategoryIconKey } from '@/utils/categoryIcons'
import { useGoBack } from '@/composables/useGoBack'
import AuthThemeToggle from '@/components/ui/AuthThemeToggle.vue'
import PageSkeleton from '@/components/ui/PageSkeleton.vue'
@@ -23,10 +23,6 @@ const seasonCategories = ref<SeasonCategory[]>([])
const loading = ref(true)
const error = ref<string | null>(null)
function usageIconKey(item: ProductCategory) {
return item.icon || (item.id === 'shirt' ? 'id-card' : item.id === 'suit' ? 'bag' : 'grid')
}
function seasonIconKey(item: SeasonCategory) {
return item.icon || (item.id === 'summer' ? 'bolt' : item.id === 'winter' ? 'feather' : 'diamond')
}
@@ -91,7 +87,7 @@ function openSeason(id: string) {
:style="item.imageUrl ? { backgroundImage: `url(${item.imageUrl})` } : undefined"
>
<span v-if="item.showIcon !== false" class="usage-card__icon">
<component :is="categoryIconOf(usageIconKey(item), IconCatalog)" :size="18" />
<component :is="categoryIconOf(usageCategoryIconKey(item), IconCatalog)" :size="18" />
</span>
</div>
<div class="usage-card__body">
@@ -114,7 +110,7 @@ function openSeason(id: string) {
:key="item.id"
type="button"
class="season-card"
:class="`season-card--${item.id}`"
:class="[`season-card--${item.id}`, { 'season-card--banner': !!item.imageUrl }]"
:style="item.imageUrl ? { backgroundImage: `url(${item.imageUrl})` } : undefined"
@click="openSeason(item.id)"
>
@@ -260,7 +256,8 @@ function openSeason(id: string) {
margin: 0;
line-height: normal;
font-family: inherit;
box-shadow: var(--shadow-sm);
box-shadow: none;
border: 1px solid var(--color-border);
}
.usage-card__image {
@@ -297,11 +294,12 @@ function openSeason(id: string) {
width: 28px;
height: 28px;
border-radius: 999px;
background: var(--color-surface);
background: var(--color-primary-soft);
color: var(--color-primary);
display: grid;
place-items: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
box-shadow: none;
border: 1px solid var(--color-border);
}
.usage-card__body {
@@ -335,9 +333,9 @@ function openSeason(id: string) {
position: relative;
isolation: isolate;
display: flex;
align-items: center;
align-items: flex-end;
justify-content: flex-start;
min-height: 124px;
min-height: 148px;
width: 100%;
appearance: none;
-webkit-appearance: none;
@@ -346,17 +344,28 @@ function openSeason(id: string) {
overflow: hidden;
text-align: start;
cursor: pointer;
padding: 16px 18px;
padding: 14px 16px;
margin: 0;
font-family: inherit;
box-shadow: var(--shadow-sm);
box-shadow: none;
border: 1px solid var(--color-border);
background-color: #d6c3a8;
background-image: linear-gradient(160deg, #d6c3a8, #8b7355);
background-size: cover;
background-position: center left;
background-position: center top;
background-repeat: no-repeat;
}
.season-card--banner {
align-items: stretch;
justify-content: flex-start;
padding: 0;
min-height: 156px;
aspect-ratio: 2.35 / 1;
background-size: cover;
background-position: center top;
}
.season-card--summer {
background-image: linear-gradient(160deg, #f3e6c8, #c4a574);
}
@@ -369,25 +378,23 @@ function openSeason(id: string) {
background-image: linear-gradient(160deg, #efe6d6, #b9a48a);
}
.season-card::after {
content: '';
position: absolute;
inset: 0;
z-index: 0;
pointer-events: none;
background: linear-gradient(
to left,
color-mix(in srgb, var(--color-surface) 90%, transparent) 0%,
color-mix(in srgb, var(--color-surface) 72%, transparent) 38%,
color-mix(in srgb, var(--color-surface) 22%, transparent) 68%,
transparent 100%
);
}
.season-card__content {
position: relative;
z-index: 1;
max-width: 58%;
padding: 14px 16px;
}
.season-card--banner .season-card__content {
display: flex;
flex-direction: column;
justify-content: flex-end;
align-self: stretch;
max-width: none;
width: 100%;
min-height: 100%;
margin-top: auto;
padding: 14px 16px;
}
.season-card__content strong {
@@ -415,6 +422,19 @@ function openSeason(id: string) {
flex-shrink: 0;
}
.season-card--banner .season-card__content strong,
.season-card--banner .season-card__content p,
.season-card--banner .season-card__link {
color: #fff;
text-shadow:
0 1px 2px rgba(0, 0, 0, 0.55),
0 2px 12px rgba(0, 0, 0, 0.35);
}
.season-card--banner .season-card__icon {
background: rgba(255, 255, 255, 0.92);
}
.season-card__content p {
margin: 0 0 10px;
color: var(--color-text-muted);
@@ -398,7 +398,7 @@ const currentStep = 1
}
.company-showcase--par .company-showcase__cta {
background: #fff;
background: var(--color-surface);
color: var(--color-primary);
}
-169
View File
@@ -1,169 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useCrmStore } from '@/stores/crm'
import { useGoBack } from '@/composables/useGoBack'
import PageHeader from '@/components/layout/PageHeader.vue'
import BaseInput from '@/components/ui/BaseInput.vue'
import FormErrorBanner from '@/components/ui/FormErrorBanner.vue'
import BaseButton from '@/components/ui/BaseButton.vue'
import { normalizePhone } from '@/utils/format'
import { validatePhone } from '@/utils/validation'
import {
FORM_VALIDATION_SUMMARY,
clearFieldError,
hasFieldErrors,
type FieldErrors,
} from '@/utils/formErrors'
import { ApiError } from '@/services/http'
const router = useRouter()
const auth = useAuthStore()
const crm = useCrmStore()
const { goBack } = useGoBack('/customers')
const firstName = ref('')
const lastName = ref('')
const phone = ref('')
const businessName = ref('')
const fieldErrors = ref<FieldErrors>({})
const formError = ref('')
const saving = ref(false)
function clearField(key: string) {
fieldErrors.value = clearFieldError(fieldErrors.value, key)
if (!hasFieldErrors(fieldErrors.value)) formError.value = ''
}
async function submit() {
fieldErrors.value = {}
formError.value = ''
if (!auth.isAgent) {
formError.value = 'فقط واسطه می‌تواند مشتری به لیست خود اضافه کند.'
return
}
const normalized = normalizePhone(phone.value)
const errors: FieldErrors = {}
if (!firstName.value.trim()) errors.firstName = 'نام الزامی است.'
if (!lastName.value.trim()) errors.lastName = 'نام خانوادگی الزامی است.'
if (!validatePhone(normalized)) errors.phone = 'شماره موبایل معتبر نیست.'
if (hasFieldErrors(errors)) {
fieldErrors.value = errors
formError.value = FORM_VALIDATION_SUMMARY
return
}
saving.value = true
try {
const created = (await crm.registerCustomer({
firstName: firstName.value.trim(),
lastName: lastName.value.trim(),
phone: normalized,
businessName: businessName.value.trim() || undefined,
})) as { id?: string }
if (created?.id) {
await router.replace({ path: '/customer', query: { id: created.id } })
return
}
await router.replace('/customers')
} catch (e) {
formError.value =
e instanceof ApiError || e instanceof Error
? e.message
: 'ثبت مشتری ناموفق بود.'
} finally {
saving.value = false
}
}
</script>
<template>
<div class="page customer-create-page">
<PageHeader title="افزودن مشتری" show-back centered @back="goBack" />
<div class="page__scroll customer-create-scroll">
<p class="intro">
مشتری به لیست شما اضافه می‌شود و بعد از ورود با همین شماره، سفارش‌هایش به حساب شما وصل می‌ماند.
</p>
<FormErrorBanner :message="formError" />
<div class="form-card">
<BaseInput
v-model="firstName"
label="نام"
placeholder="نام مشتری"
:error="fieldErrors.firstName"
@update:model-value="clearField('firstName')"
/>
<BaseInput
v-model="lastName"
label="نام خانوادگی"
placeholder="نام خانوادگی"
:error="fieldErrors.lastName"
@update:model-value="clearField('lastName')"
/>
<BaseInput
v-model="phone"
label="شماره موبایل"
type="tel"
inputmode="tel"
dir="ltr"
placeholder="۰۹۱۲۳۴۵۶۷۸۹"
:error="fieldErrors.phone"
@update:model-value="clearField('phone')"
/>
<BaseInput
v-model="businessName"
label="نام فروشگاه یا برند (اختیاری)"
placeholder="مثلاً بوتیک رز"
/>
</div>
</div>
<footer class="footer">
<BaseButton block :loading="saving" :disabled="saving" @click="submit">
ثبت مشتری
</BaseButton>
</footer>
</div>
</template>
<style scoped>
.customer-create-page {
background: var(--color-bg);
}
.customer-create-scroll {
padding: 8px var(--page-padding) 100px;
display: flex;
flex-direction: column;
gap: 12px;
}
.intro {
margin: 0;
color: var(--color-text-muted);
font-size: 0.875rem;
line-height: 1.7;
}
.form-card {
background: var(--color-surface);
border-radius: var(--radius-md);
padding: 16px;
box-shadow: var(--shadow-sm);
}
.footer {
position: sticky;
bottom: 0;
padding: 12px var(--page-padding) calc(12px + env(safe-area-inset-bottom, 0px));
background: color-mix(in srgb, var(--color-bg) 92%, transparent);
}
</style>
+467 -229
View File
@@ -7,6 +7,7 @@ import type {
CrmCustomer,
CrmMeeting,
CrmOrderSummary,
CustomerStatus,
CustomerTab,
MeetingInteractionType,
} from '@/types/crm'
@@ -21,6 +22,7 @@ import IconClipboardEmpty from '@/components/icons/IconClipboardEmpty.vue'
import IconPhone from '@/components/icons/IconPhone.vue'
import IconMail from '@/components/icons/IconMail.vue'
import IconEditPen from '@/components/icons/IconEditPen.vue'
import MeetingReportOverlay from '@/components/customers/MeetingReportOverlay.vue'
const route = useRoute()
const router = useRouter()
@@ -28,6 +30,7 @@ const crm = useCrmStore()
const auth = useAuthStore()
const id = computed(() => String(route.query.id ?? ''))
const tab = computed(() => (String(route.query.tab ?? 'info') as CustomerTab))
const showMeetingReport = computed(() => route.query.meetingReport === '1')
const customer = ref<CrmCustomer | null>(null)
const orders = ref<CrmOrderSummary[]>([])
const meetings = ref<CrmMeeting[]>([])
@@ -69,6 +72,25 @@ function setTab(next: CustomerTab) {
router.replace({ path: '/customer', query: { id: id.value, tab: next } })
}
function openMeetingReport() {
router.push({
path: '/customer',
query: { id: id.value, tab: 'reports', meetingReport: '1' },
})
}
function closeMeetingReport() {
router.replace({
path: '/customer',
query: { id: id.value, tab: 'reports' },
})
}
async function onMeetingReportSuccess() {
closeMeetingReport()
await load()
}
function typeLabel(type: MeetingInteractionType) {
if (type === 'in_person') return 'ملاقات حضوری'
if (type === 'call') return 'تماس'
@@ -117,6 +139,20 @@ function orderStatusTone(order: CrmOrderSummary): 'warning' | 'success' | 'info'
if (variant === 'warning' || variant === 'danger') return 'warning'
return order.statusTone || 'info'
}
const recentOrders = computed(() => orders.value.slice(0, 3))
function customerStatusLabel(status: CustomerStatus) {
if (status === 'active') return 'فعال'
if (status === 'inactive') return 'غیرفعال'
return 'جدید'
}
function customerStatusClass(status: CustomerStatus) {
if (status === 'active') return 'status-badge--active'
if (status === 'inactive') return 'status-badge--inactive'
return 'status-badge--new'
}
</script>
<template>
@@ -133,22 +169,24 @@ function orderStatusTone(order: CrmOrderSummary): 'warning' | 'success' | 'info'
</PageHeader>
<div class="page__scroll page__scroll--with-nav customer-scroll">
<section v-if="tab !== 'reports'" class="profile-row">
<div class="avatar" :style="{ background: customer.avatarColor }">
<section v-if="tab !== 'reports'" class="profile-card">
<div class="profile-card__avatar" :style="{ background: customer.avatarColor }">
{{ customer.avatarLetter }}
</div>
<div>
<div class="name-row">
<div class="profile-card__info">
<div class="profile-card__name-row">
<h2>{{ customer.name }}</h2>
<span class="badge">فعال</span>
<span class="status-badge" :class="customerStatusClass(customer.status)">
{{ customerStatusLabel(customer.status) }}
</span>
</div>
<p dir="ltr">{{ customer.phone }}</p>
<p class="profile-card__phone" dir="ltr">{{ customer.phone }}</p>
</div>
</section>
<section v-else class="profile-banner">
<div class="profile-banner__top">
<div class="avatar avatar--lg" :style="{ background: customer.avatarColor }">
<div class="profile-card__avatar profile-card__avatar--lg" :style="{ background: customer.avatarColor }">
{{ customer.avatarLetter }}
</div>
<div>
@@ -156,7 +194,7 @@ function orderStatusTone(order: CrmOrderSummary): 'warning' | 'success' | 'info'
<p>{{ customer.contactName }}</p>
<p dir="ltr">{{ customer.phone }}</p>
</div>
<span class="badge badge--light">مشتری فعال</span>
<span class="status-badge status-badge--active status-badge--on-dark">فعال</span>
</div>
<div class="profile-banner__stats">
<div>
@@ -174,54 +212,85 @@ function orderStatusTone(order: CrmOrderSummary): 'warning' | 'success' | 'info'
</div>
</section>
<div class="tabs">
<div class="tabs" role="tablist">
<button
v-for="item in tabs"
:key="item.id"
type="button"
role="tab"
class="tab"
:class="{ 'tab--active': tab === item.id }"
:aria-selected="tab === item.id"
@click="setTab(item.id)"
>
{{ item.label }}
<span class="tab__text">{{ item.label }}</span>
</button>
</div>
<template v-if="tab === 'info'">
<section class="stats">
<h3>اطلاعات خرید</h3>
<div class="stats__grid">
<div>
<span>تعداد سفارش</span>
<strong>{{ formatNumber(customer.orderCount) }}</strong>
<section class="purchase-card">
<h3 class="section-title">اطلاعات خرید</h3>
<div class="purchase-stats">
<div class="purchase-stats__item">
<span class="purchase-stats__label">تعداد سفارش</span>
<strong class="purchase-stats__value">{{ formatNumber(customer.orderCount) }}</strong>
</div>
<div>
<span>مجموع خرید</span>
<strong>{{ formatNumber(customer.totalPurchaseToman) }}</strong>
<em>تومان</em>
<span class="purchase-stats__divider" aria-hidden="true" />
<div class="purchase-stats__item">
<span class="purchase-stats__label">مجموع خرید</span>
<strong class="purchase-stats__value">{{ formatNumber(customer.totalPurchaseToman) }}</strong>
<em class="purchase-stats__unit">تومان</em>
</div>
<div>
<span>میانگین هر سفارش</span>
<strong>{{ formatNumber(customer.averageOrderToman) }}</strong>
<em>تومان</em>
<span class="purchase-stats__divider" aria-hidden="true" />
<div class="purchase-stats__item">
<span class="purchase-stats__label">میانگین هر سفارش</span>
<strong class="purchase-stats__value">{{ formatNumber(customer.averageOrderToman) }}</strong>
<em class="purchase-stats__unit">تومان</em>
</div>
</div>
</section>
<button type="button" class="agent-card" @click="router.push('/profile')">
<div class="agent-card__avatar">{{ agentInitial }}</div>
<div>
<strong>{{ agentName }}</strong>
<p>کد واسطه: {{ agentCode }}</p>
</div>
<IconChevronLeft :size="18" />
</button>
<section class="panel-card">
<button type="button" class="agent-row" @click="router.push('/profile')">
<div class="agent-row__avatar">{{ agentInitial }}</div>
<div class="agent-row__info">
<strong>{{ agentName }}</strong>
<p>کد واسطه: {{ agentCode }}</p>
</div>
<IconChevronLeft :size="18" class="agent-row__chevron" aria-hidden="true" />
</button>
<section>
<div class="section-head">
<h3>آخرین سفارش‌ها</h3>
<button type="button" class="link" @click="setTab('orders')">مشاهده همه</button>
<div class="orders-block">
<div class="orders-block__head">
<h3 class="section-title">آخرین سفارش‌ها</h3>
<button type="button" class="link-btn" @click="setTab('orders')">مشاهده همه</button>
</div>
<article
v-for="order in recentOrders"
:key="order.id"
class="order-row"
role="button"
tabindex="0"
@click="openOrder(order.id)"
@keydown.enter="openOrder(order.id)"
>
<div class="order-row__title">
<strong>{{ order.title }}</strong>
<IconChevronLeft :size="14" class="order-row__chevron" aria-hidden="true" />
</div>
<span class="order-row__amount">{{ formatNumber(order.amountToman) }} تومان</span>
<span class="status-pill" :class="`status-pill--${orderStatusTone(order)}`">
{{ orderStatusLabel(order) }}
</span>
</article>
<p v-if="!recentOrders.length" class="empty-inline">سفارشی ثبت نشده است.</p>
</div>
</section>
</template>
<template v-else-if="tab === 'orders'">
<section class="panel-card panel-card--list">
<article
v-for="order in orders"
:key="order.id"
@@ -231,74 +300,58 @@ function orderStatusTone(order: CrmOrderSummary): 'warning' | 'success' | 'info'
@click="openOrder(order.id)"
@keydown.enter="openOrder(order.id)"
>
<div>
<div class="order-row__title">
<strong>{{ order.title }}</strong>
<p>{{ formatNumber(order.amountToman) }} تومان</p>
<IconChevronLeft :size="14" class="order-row__chevron" aria-hidden="true" />
</div>
<span class="tone" :class="`tone--${orderStatusTone(order)}`">{{ orderStatusLabel(order) }}</span>
<IconChevronLeft :size="16" class="order-row__chevron" />
<span class="order-row__amount">{{ formatNumber(order.amountToman) }} تومان</span>
<span class="status-pill" :class="`status-pill--${orderStatusTone(order)}`">
{{ orderStatusLabel(order) }}
</span>
</article>
<p v-if="!orders.length" class="empty-inline">سفارشی ثبت نشده است.</p>
</section>
</template>
<template v-else-if="tab === 'orders'">
<article
v-for="order in orders"
:key="order.id"
class="order-row"
role="button"
tabindex="0"
@click="openOrder(order.id)"
@keydown.enter="openOrder(order.id)"
>
<div>
<strong>{{ order.title }}</strong>
<p>{{ order.date }} — {{ formatNumber(order.amountToman) }} تومان</p>
</div>
<span class="tone" :class="`tone--${orderStatusTone(order)}`">{{ orderStatusLabel(order) }}</span>
<IconChevronLeft :size="16" class="order-row__chevron" />
</article>
<p v-if="!orders.length" class="empty-inline">سفارشی ثبت نشده است.</p>
</template>
<template v-else>
<h3 class="block-title">تعاملات و پیگیری‌ها</h3>
<button
type="button"
class="dashed-btn"
@click="router.push({ path: '/meeting-report', query: { customerId: customer.id } })"
>
+ ثبت گزارش ملاقات جدید
</button>
<div class="meeting-filters">
<section class="panel-card panel-card--reports">
<h3 class="section-title">تعاملات و پیگیری‌ها</h3>
<button
v-for="chip in meetingFilters"
:key="chip.id"
type="button"
class="meeting-filter"
:class="{ 'meeting-filter--active': meetingFilter === chip.id }"
@click="meetingFilter = chip.id"
class="dashed-btn"
@click="openMeetingReport"
>
{{ chip.label }}
+ ثبت گزارش ملاقات جدید
</button>
</div>
<article v-for="item in filteredMeetings" :key="item.id" class="meeting-row">
<div class="meeting-row__icon" :class="meetingIconClass(item.type)">
<IconClipboardEmpty v-if="item.type === 'in_person'" :size="16" />
<IconPhone v-else-if="item.type === 'call'" :size="16" />
<IconMail v-else-if="item.type === 'message'" :size="16" />
<IconEditPen v-else :size="16" />
<div class="meeting-filters">
<button
v-for="chip in meetingFilters"
:key="chip.id"
type="button"
class="meeting-filter"
:class="{ 'meeting-filter--active': meetingFilter === chip.id }"
@click="meetingFilter = chip.id"
>
{{ chip.label }}
</button>
</div>
<div class="meeting-row__body">
<strong>{{ typeLabel(item.type) }}</strong>
<p>{{ item.description || item.title }}</p>
</div>
<span>{{ item.date }}</span>
</article>
<p v-if="!filteredMeetings.length" class="empty-inline">
گزارشی برای این فیلتر ثبت نشده است.
</p>
<article v-for="item in filteredMeetings" :key="item.id" class="meeting-row">
<div class="meeting-row__icon" :class="meetingIconClass(item.type)">
<IconClipboardEmpty v-if="item.type === 'in_person'" :size="16" />
<IconPhone v-else-if="item.type === 'call'" :size="16" />
<IconMail v-else-if="item.type === 'message'" :size="16" />
<IconEditPen v-else :size="16" />
</div>
<div class="meeting-row__body">
<strong>{{ typeLabel(item.type) }}</strong>
<p>{{ item.description || item.title }}</p>
</div>
<span class="meeting-row__date">{{ item.date }}</span>
</article>
<p v-if="!filteredMeetings.length" class="empty-inline">
گزارشی برای این فیلتر ثبت نشده است.
</p>
</section>
</template>
</div>
@@ -317,6 +370,13 @@ function orderStatusTone(order: CrmOrderSummary): 'warning' | 'success' | 'info'
<div v-else class="page empty">
<p>{{ error || 'مشتری یافت نشد.' }}</p>
</div>
<MeetingReportOverlay
:open="showMeetingReport"
:customer-id="id"
@close="closeMeetingReport"
@success="onMeetingReportSuccess"
/>
</template>
<style scoped>
@@ -325,62 +385,100 @@ function orderStatusTone(order: CrmOrderSummary): 'warning' | 'success' | 'info'
}
.customer-scroll {
padding: 8px var(--page-padding) 100px;
padding: 0 var(--page-padding) 100px;
display: flex;
flex-direction: column;
gap: 14px;
gap: 0;
}
.profile-row {
.profile-card,
.purchase-card,
.panel-card {
background: transparent;
border-radius: 0;
box-shadow: none;
}
.profile-card {
display: flex;
gap: 12px;
align-items: center;
gap: 14px;
padding: 12px 0 16px;
border-bottom: 1px solid var(--color-border);
}
.avatar {
width: 64px;
height: 64px;
.profile-card__avatar {
width: 56px;
height: 56px;
border-radius: 999px;
display: grid;
place-items: center;
font-size: 1.4rem;
font-size: 1.25rem;
font-weight: 700;
color: #111;
flex-shrink: 0;
}
.avatar--lg {
.profile-card__avatar--lg {
width: 56px;
height: 56px;
}
.name-row {
.profile-card__info {
min-width: 0;
flex: 1;
}
.profile-card__name-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.name-row h2,
.profile-card__name-row h2,
.profile-banner h2 {
margin: 0;
font-size: 1.1rem;
}
.profile-row p,
.profile-banner p {
margin: 2px 0 0;
color: var(--color-text-muted);
font-size: 0.85rem;
}
.badge {
background: var(--color-primary-soft);
color: var(--color-primary);
border-radius: 999px;
padding: 2px 10px;
font-size: 0.75rem;
font-size: 1.05rem;
font-weight: 700;
line-height: 1.35;
}
.badge--light {
.profile-card__phone,
.profile-banner p {
margin: 6px 0 0;
color: var(--color-text-muted);
font-size: 0.875rem;
line-height: 1.4;
}
.status-badge {
display: inline-flex;
align-items: center;
height: 24px;
padding: 0 10px;
border-radius: 6px;
font-size: 0.75rem;
font-weight: 600;
white-space: nowrap;
}
.status-badge--active {
background: var(--color-success-light);
color: var(--color-success);
}
.status-badge--inactive {
background: #f3f4f6;
color: #6b7280;
}
.status-badge--new {
background: #ede9fe;
color: #6d28d9;
}
.status-badge--on-dark {
background: rgba(255, 255, 255, 0.2);
color: #fff;
}
@@ -390,6 +488,7 @@ function orderStatusTone(order: CrmOrderSummary): 'warning' | 'success' | 'info'
color: #fff;
border-radius: var(--radius-lg);
padding: 16px;
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.04), 0 6px 16px rgba(16, 24, 40, 0.05);
}
.profile-banner__top {
@@ -422,169 +521,269 @@ function orderStatusTone(order: CrmOrderSummary): 'warning' | 'success' | 'info'
.tabs {
display: flex;
gap: 12px;
width: 100%;
align-items: stretch;
border-bottom: 1px solid var(--color-border);
overflow-x: auto;
}
.tab {
border: none;
background: transparent;
padding: 10px 2px;
color: var(--color-text-muted);
white-space: nowrap;
flex: 1;
display: flex;
justify-content: center;
align-items: flex-end;
min-width: 0;
appearance: none;
-webkit-appearance: none;
border: 0;
background: none;
padding: 10px 0 0;
color: #9ca3af;
font-family: inherit;
font-size: 0.875rem;
font-weight: 400;
line-height: 1.5;
cursor: pointer;
}
.tab__text {
position: relative;
display: inline-block;
padding-bottom: 11px;
white-space: nowrap;
}
.tab--active {
color: var(--color-text);
font-weight: 700;
box-shadow: inset 0 -3px 0 var(--color-text);
}
.stats,
.agent-card,
.order-row,
.meeting-row {
background: var(--color-surface);
border-radius: var(--radius-md);
padding: 14px;
box-shadow: var(--shadow-sm);
.tab--active .tab__text::after {
content: '';
position: absolute;
inset-inline: 0;
bottom: -1px;
height: 3px;
background: var(--color-primary);
border-radius: 2px 2px 0 0;
}
.stats h3,
.section-head h3,
.block-title {
margin: 0 0 10px;
.section-title {
margin: 0;
font-size: 0.95rem;
font-weight: 700;
color: var(--color-text);
}
.stats__grid {
.purchase-card {
padding: 16px 0;
border-bottom: 1px solid var(--color-border);
}
.purchase-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
text-align: center;
grid-template-columns: 1fr 1px 1fr 1px 1fr;
align-items: stretch;
gap: 0;
margin-top: 14px;
}
.stats__grid span,
.stats__grid em {
display: block;
font-size: 0.7rem;
.purchase-stats__item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
gap: 4px;
padding: 0 6px;
text-align: center;
min-width: 0;
}
.purchase-stats__divider {
width: 1px;
background: var(--color-border);
align-self: stretch;
}
.purchase-stats__label {
font-size: 0.72rem;
color: var(--color-text-muted);
line-height: 1.4;
}
.purchase-stats__value {
font-size: 1rem;
font-weight: 700;
line-height: 1.25;
color: var(--color-text);
}
.purchase-stats__unit {
font-size: 0.72rem;
color: var(--color-text-muted);
font-style: normal;
line-height: 1.3;
}
.stats__grid strong {
display: block;
margin: 4px 0;
font-size: 0.9rem;
.panel-card {
padding: 0 0 8px;
overflow: hidden;
}
.agent-card {
width: 100%;
border: none;
display: grid;
grid-template-columns: 44px 1fr 20px;
gap: 10px;
.panel-card--list,
.panel-card--reports {
padding: 16px 0;
}
.agent-row {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 16px 0;
border: none;
border-bottom: 1px solid var(--color-border);
background: transparent;
text-align: start;
cursor: pointer;
font-family: inherit;
}
.agent-card__avatar {
.agent-row__avatar {
width: 44px;
height: 44px;
border-radius: 999px;
background: var(--color-primary-soft);
background: #e8ecea;
color: var(--color-primary);
display: grid;
place-items: center;
font-weight: 700;
}
.agent-card p {
margin: 2px 0 0;
color: var(--color-text-muted);
font-size: 0.78rem;
}
.section-head {
display: flex;
justify-content: space-between;
align-items: center;
}
.link {
border: none;
background: transparent;
color: var(--color-info);
cursor: pointer;
font-size: 0.8rem;
}
.order-row,
.meeting-row {
display: flex;
justify-content: space-between;
gap: 10px;
align-items: center;
margin-bottom: 8px;
}
.order-row {
cursor: pointer;
}
.order-row__chevron {
color: var(--color-text-muted);
flex-shrink: 0;
}
.meeting-row__body {
.agent-row__info {
flex: 1;
min-width: 0;
}
.order-row p,
.meeting-row p {
margin: 2px 0 0;
.agent-row__info strong {
display: block;
font-size: 0.9rem;
font-weight: 700;
}
.agent-row__info p {
margin: 4px 0 0;
color: var(--color-text-muted);
font-size: 0.78rem;
}
.agent-row__chevron {
color: #9ca3af;
flex-shrink: 0;
}
.orders-block {
padding: 14px 0 8px;
}
.orders-block__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 4px;
}
.link-btn {
border: none;
background: transparent;
color: #6b7280;
font-size: 0.8rem;
font-weight: 500;
cursor: pointer;
padding: 0;
font-family: inherit;
}
.order-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 10px;
align-items: center;
padding: 14px 0;
border-bottom: 1px solid var(--color-border);
cursor: pointer;
font-family: inherit;
}
.panel-card--list .order-row:last-of-type,
.orders-block .order-row:last-of-type {
border-bottom: none;
}
.order-row__title {
display: inline-flex;
align-items: center;
gap: 4px;
min-width: 0;
}
.order-row__title strong {
font-size: 0.875rem;
font-weight: 600;
line-height: 1.35;
}
.order-row__chevron {
color: #9ca3af;
flex-shrink: 0;
}
.order-row__amount {
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-text);
white-space: nowrap;
}
.status-pill {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 26px;
padding: 0 10px;
border-radius: 6px;
font-size: 0.7rem;
font-weight: 600;
white-space: nowrap;
}
.status-pill--warning {
background: #fff4e5;
color: #c27803;
}
.status-pill--success {
background: var(--color-success-light);
color: var(--color-success);
}
.status-pill--info {
background: var(--color-info-light);
color: var(--color-info);
}
.empty-inline {
margin: 8px 0 0;
margin: 12px 0 4px;
text-align: center;
color: var(--color-text-muted);
font-size: 0.85rem;
}
.tone {
border-radius: 999px;
padding: 4px 10px;
font-size: 0.72rem;
white-space: nowrap;
}
.tone--warning {
background: var(--color-warning-light);
color: #b45309;
}
.tone--success {
background: var(--color-success-light);
color: var(--color-success);
}
.tone--info {
background: var(--color-info-light);
color: var(--color-info);
}
.edit-btn {
border: none;
background: transparent;
color: var(--color-info, #2563eb);
color: #3b82f6;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
@@ -596,19 +795,20 @@ function orderStatusTone(order: CrmOrderSummary): 'warning' | 'success' | 'info'
display: flex;
gap: 8px;
overflow-x: auto;
margin-bottom: 8px;
margin: 12px 0 8px;
padding-bottom: 4px;
}
.meeting-filter {
border: 1px solid var(--color-border);
background: var(--color-surface);
border-radius: 999px;
background: var(--color-bg);
border-radius: 6px;
padding: 6px 12px;
font-size: 0.72rem;
color: var(--color-text-muted);
cursor: pointer;
white-space: nowrap;
font-family: inherit;
}
.meeting-filter--active {
@@ -627,7 +827,20 @@ function orderStatusTone(order: CrmOrderSummary): 'warning' | 'success' | 'info'
padding: 14px;
font-weight: 700;
cursor: pointer;
margin-bottom: 8px;
margin-top: 12px;
font-family: inherit;
}
.meeting-row {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 0;
border-bottom: 1px solid var(--color-border);
}
.meeting-row:last-of-type {
border-bottom: none;
}
.meeting-row__icon {
@@ -654,11 +867,34 @@ function orderStatusTone(order: CrmOrderSummary): 'warning' | 'success' | 'info'
color: #6d28d9;
}
.meeting-row__body {
flex: 1;
min-width: 0;
}
.meeting-row__body strong {
display: block;
font-size: 0.875rem;
}
.meeting-row__body p {
margin: 4px 0 0;
color: var(--color-text-muted);
font-size: 0.78rem;
line-height: 1.45;
}
.meeting-row__date {
font-size: 0.75rem;
color: var(--color-text-muted);
flex-shrink: 0;
}
.cta-bar {
position: sticky;
bottom: 0;
padding: 12px var(--page-padding) calc(12px + env(safe-area-inset-bottom, 0));
background: linear-gradient(transparent, var(--color-bg) 30%);
background: linear-gradient(180deg, rgba(238, 241, 240, 0) 0%, var(--color-bg) 32%);
}
.cta {
@@ -668,12 +904,14 @@ function orderStatusTone(order: CrmOrderSummary): 'warning' | 'success' | 'info'
border-radius: 14px;
background: var(--color-primary);
color: #fff;
font-size: 0.95rem;
font-weight: 700;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
font-family: inherit;
}
.empty {
+30 -1
View File
@@ -11,6 +11,7 @@ import IconFilter from '@/components/icons/IconFilter.vue'
import IconChevronLeft from '@/components/icons/IconChevronLeft.vue'
import IconPlus from '@/components/icons/IconPlus.vue'
import IconProfile from '@/components/icons/IconProfile.vue'
import CustomerCreateOverlay from '@/components/customers/CustomerCreateOverlay.vue'
const route = useRoute()
const router = useRouter()
@@ -22,6 +23,7 @@ const loading = ref(true)
const error = ref<string | null>(null)
const filtered = computed(() => customers.value)
const showCreate = computed(() => route.query.create === '1')
const activeCount = computed(() => customers.value.filter((c) => c.status === 'active').length)
const totalPurchase = computed(() =>
@@ -81,8 +83,29 @@ function statusLabel(s: CustomerStatus) {
return 'جدید'
}
function customerListQuery(extra: Record<string, string> = {}) {
return {
...(status.value !== 'all' ? { status: status.value } : {}),
...(q.value ? { q: q.value } : {}),
...extra,
}
}
function onRegisterCustomer() {
router.push('/customers/new')
router.push({ path: '/customers', query: { ...customerListQuery(), create: '1' } })
}
function closeCreate() {
router.replace({ path: '/customers', query: customerListQuery() })
}
async function onCustomerCreated(id?: string) {
if (id) {
await router.replace({ path: '/customer', query: { id } })
return
}
closeCreate()
await load()
}
function cycleStatusFilter() {
@@ -196,6 +219,12 @@ function cycleStatusFilter() {
</div>
</section>
</div>
<CustomerCreateOverlay
:open="showCreate"
@close="closeCreate"
@success="onCustomerCreated"
/>
</div>
</template>
+91 -30
View File
@@ -468,10 +468,6 @@ async function toggleFavorite(productId: string) {
padding-bottom: 8px;
}
.home-scroll > .section:last-child {
margin-top: auto;
}
.hero-banner {
--hero-image: #8a7a65;
position: relative;
@@ -515,6 +511,11 @@ async function toggleFavorite(productId: string) {
.hero-banner__content {
position: relative;
z-index: 1;
width: auto;
max-width: 100%;
min-width: 0;
box-sizing: border-box;
text-align: start;
animation: hero-copy 0.7s cubic-bezier(0.22, 1, 0.36, 1) both;
}
@@ -592,6 +593,7 @@ async function toggleFavorite(productId: string) {
line-height: 1.7;
max-width: 16rem;
font-weight: 700;
overflow-wrap: break-word;
}
.hero-banner__title span {
@@ -644,7 +646,7 @@ async function toggleFavorite(productId: string) {
.quick-card {
border: none;
background: transparent;
background: var(--color-surface);
border-radius: var(--radius-md);
padding: 12px 6px;
display: flex;
@@ -652,7 +654,7 @@ async function toggleFavorite(productId: string) {
align-items: center;
gap: 8px;
cursor: pointer;
box-shadow: none;
box-shadow: 0 2px 10px rgba(16, 24, 40, 0.06);
}
.quick-card__icon {
@@ -661,8 +663,9 @@ async function toggleFavorite(productId: string) {
border-radius: 12px;
display: grid;
place-items: center;
background: var(--color-surface);
box-shadow: 0 2px 12px rgba(16, 24, 40, 0.06);
background: transparent;
box-shadow: none;
border: none;
}
.quick-card__image {
@@ -683,8 +686,8 @@ async function toggleFavorite(productId: string) {
background: var(--color-surface);
border-radius: 18px;
padding: 16px;
box-shadow: 0 2px 12px rgba(16, 24, 40, 0.06);
border: 1px solid rgba(230, 232, 230, 0.9);
box-shadow: none;
border: 1px solid var(--color-border);
}
.level-card__top {
@@ -819,7 +822,8 @@ async function toggleFavorite(productId: string) {
text-align: start;
cursor: pointer;
overflow: hidden;
box-shadow: 0 2px 10px rgba(16, 24, 40, 0.05);
box-shadow: none;
border: 1px solid var(--color-border);
scroll-snap-align: start;
}
@@ -912,7 +916,11 @@ async function toggleFavorite(productId: string) {
@media (min-width: 768px) {
.home-page {
padding-top: 8px;
max-width: var(--content-max-width);
margin-inline: auto;
width: 100%;
padding: 8px var(--page-padding) 0;
box-sizing: border-box;
}
.home-desk-head {
@@ -938,38 +946,87 @@ async function toggleFavorite(productId: string) {
}
.home-scroll {
gap: 22px;
padding: 8px 0 24px;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-areas:
'hero hero level'
'quick quick quick'
'products products products';
gap: 18px 20px;
padding: 8px 0 28px;
align-items: stretch;
min-width: 0;
}
.hero-banner {
min-height: 220px;
grid-area: hero;
width: 100%;
max-width: none;
min-width: 0;
min-height: 0;
height: auto;
aspect-ratio: 2.1 / 1;
align-self: start;
border-radius: 20px;
}
.hero-slide {
padding: 28px 32px;
}
.hero-banner__title {
font-size: 1.35rem;
max-width: 22rem;
.level-card {
grid-area: level;
padding: 20px 22px;
align-self: stretch;
display: flex;
flex-direction: column;
justify-content: center;
}
.quick-grid {
grid-area: quick;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
}
.home-scroll > .section {
grid-area: products;
}
.hero-slide {
width: 100%;
box-sizing: border-box;
align-items: flex-end;
justify-content: flex-start;
padding-inline-start: 36px;
padding-inline-end: 28px;
padding-block: 24px;
}
.hero-slide__media {
background-size: cover;
background-position: left center;
background-color: #8a7a65;
}
.hero-banner__content {
max-width: min(52%, 22rem);
}
.hero-banner__title {
font-size: 1.15rem;
max-width: none;
}
.hero-next-enter-from,
.hero-next-leave-to,
.hero-prev-enter-from,
.hero-prev-leave-to {
transform: none;
}
.quick-card {
min-height: 96px;
padding: 18px 14px;
border-radius: 16px;
}
.level-card {
padding: 20px 22px;
box-shadow: 0 3px 12px rgba(16, 24, 40, 0.07);
}
.product-row {
@@ -993,12 +1050,16 @@ async function toggleFavorite(productId: string) {
}
@media (min-width: 1200px) {
.hero-banner {
aspect-ratio: 2.2 / 1;
}
.hero-banner__title {
font-size: 1.25rem;
}
.product-row {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.hero-banner {
min-height: 240px;
}
}
</style>
+25 -17
View File
@@ -11,7 +11,7 @@ import { formatNumber } from '@/utils/format'
import { colorImageAt, colorSwatchStyle } from '@/utils/colorSwatch'
import IconChevronBack from '@/components/icons/IconChevronBack.vue'
import IconCart from '@/components/icons/IconCart.vue'
import IconCatalog from '@/components/icons/IconCatalog.vue'
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'
@@ -379,7 +379,7 @@ function shareProduct() {
<p class="min-note">حداقل سفارش: {{ formatNumber(minRolls) }} طاقه</p>
</div>
<button type="button" class="btn-secondary" @click="router.push({ path: '/catalog-request', query: { productId: product.id } })">
<IconCatalog :size="18" />
<img class="btn-secondary__icon" :src="quickCatalogRequestIcon" alt="" aria-hidden="true" />
درخواست کالیته
</button>
</footer>
@@ -400,7 +400,7 @@ function shareProduct() {
--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);
--pd-card-radius: 16px;
background: #fff;
background: var(--color-surface);
display: flex;
flex-direction: column;
min-height: 100%;
@@ -446,7 +446,7 @@ function shareProduct() {
inset-inline-start: 12px;
background: rgba(0, 0, 0, 0.55);
color: #fff;
border-radius: 999px;
border-radius: 6px;
padding: 4px 10px;
font-size: 0.75rem;
}
@@ -457,7 +457,7 @@ function shareProduct() {
margin: 12px var(--page-padding) 0;
padding: 10px;
overflow-x: auto;
background: #fff;
background: var(--color-surface);
border: var(--pd-card-border);
border-radius: var(--pd-card-radius);
box-shadow: var(--pd-card-shadow);
@@ -470,7 +470,7 @@ function shareProduct() {
height: 52px;
border-radius: 14px;
border: var(--pd-card-border);
background: #fff;
background: var(--color-surface);
box-shadow: var(--pd-card-shadow);
cursor: pointer;
flex-shrink: 0;
@@ -499,7 +499,7 @@ function shareProduct() {
display: flex;
flex-direction: column;
gap: 18px;
background: #fff;
background: var(--color-surface);
}
.title-row {
@@ -541,7 +541,7 @@ function shareProduct() {
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 6px;
padding: 14px 8px;
background: #fff;
background: var(--color-surface);
border: var(--pd-card-border);
border-radius: var(--pd-card-radius);
box-shadow: var(--pd-card-shadow);
@@ -631,7 +631,7 @@ function shareProduct() {
.pd-section--boxed {
padding: 12px 14px;
background: #fff;
background: var(--color-surface);
border: var(--pd-card-border);
border-radius: var(--pd-card-radius);
box-shadow: var(--pd-card-shadow);
@@ -704,8 +704,8 @@ button.tag {
min-height: 32px;
padding: 0 16px;
border: var(--pd-card-border);
border-radius: var(--pd-card-radius);
background: #fff;
border-radius: 6px;
background: var(--color-surface);
color: #6b7280;
font-size: 0.8125rem;
font-weight: 400;
@@ -726,7 +726,7 @@ button.tag--link {
}
button.tag--link:hover {
background: #fff;
background: var(--color-surface);
}
.roll-field {
@@ -734,11 +734,11 @@ button.tag--link:hover {
align-items: center;
width: fit-content;
max-width: 100%;
border: 1px solid #eeeef1;
border-radius: 16px;
background: #fff;
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.04), 0 6px 16px rgba(16, 24, 40, 0.05);
padding: 14px 16px;
border: var(--pd-card-border);
border-radius: 6px;
background: var(--color-surface);
box-shadow: var(--pd-card-shadow);
padding: 0 16px;
color: #6b7280;
font-size: 0.9rem;
}
@@ -780,6 +780,14 @@ button.tag--link:hover {
color: var(--color-primary);
}
.btn-secondary__icon {
width: 18px;
height: 18px;
display: block;
object-fit: contain;
flex-shrink: 0;
}
.btn-primary {
border: none;
background: var(--color-accent-navy);
+211 -34
View File
@@ -9,8 +9,19 @@ import SearchField from '@/components/ui/SearchField.vue'
import IconFilter from '@/components/icons/IconFilter.vue'
import IconChevronLeft from '@/components/icons/IconChevronLeft.vue'
import IconHeart from '@/components/icons/IconHeart.vue'
import IconCatalog from '@/components/icons/IconCatalog.vue'
import IconDiamond from '@/components/icons/IconDiamond.vue'
import { useFavoritesStore } from '@/stores/favorites'
import { colorSwatchStyle } from '@/utils/colorSwatch'
import { categoryIconOf, usageCategoryIconKey } from '@/utils/categoryIcons'
const CATEGORY_TAGLINES: Record<string, string> = {
suit: 'شیک، بادوام و رسمی',
trousers: 'مناسب شلوار و مصارف روزانه',
shirt: 'سبک و مناسب پیراهن',
}
const GENERIC_CATEGORY_CTA = 'مشاهده محصولات'
const route = useRoute()
const router = useRouter()
@@ -43,15 +54,81 @@ const title = computed(() => {
return 'همه محصولات'
})
const bannerSubtitle = computed(() => {
if (catalogId.value) return 'انتخاب محصول از کالیته'
if (categoryId.value === 'suit') return 'شیک، بادوام و رسمی'
if (categoryId.value === 'trousers') return 'مناسب شلوار و مصارف روزانه'
if (categoryId.value === 'shirt') return 'سبک و مناسب پیراهن'
if (season.value) return 'فیلتر شده بر اساس فصل'
return 'مشاهده و ثبت سفارش'
function splitCategoryTitle(fullTitle: string) {
const trimmed = fullTitle.trim()
const prefix = 'پارچه‌های '
if (trimmed.startsWith(prefix)) {
return { lead: 'پارچه‌های', headline: trimmed.slice(prefix.length) }
}
return { lead: undefined, headline: trimmed }
}
function seasonIconKey(item: SeasonCategory) {
return item.icon || (item.id === 'summer' ? 'bolt' : item.id === 'winter' ? 'feather' : 'diamond')
}
const bannerCopy = computed(() => {
if (categoryId.value) {
const cat = crm.usageCategories.find((c) => c.id === categoryId.value)
const parts = splitCategoryTitle(cat?.title ?? title.value)
const tagline =
CATEGORY_TAGLINES[categoryId.value] ??
(cat?.subtitle && cat.subtitle !== GENERIC_CATEGORY_CTA ? cat.subtitle : undefined)
return { ...parts, tagline }
}
if (season.value) {
const item = seasons.value.find((s) => s.id === season.value)
return {
lead: undefined,
headline: item?.title ?? title.value,
tagline: item?.description || 'فیلتر شده بر اساس فصل',
}
}
if (catalogId.value) {
return {
lead: 'کالیته',
headline: catalogTitle.value || title.value,
tagline: 'انتخاب محصول از کالیته',
}
}
return {
lead: undefined,
headline: title.value,
tagline: q.value ? 'نتایج جستجو' : 'مشاهده و ثبت سفارش',
}
})
const bannerIcon = computed(() => {
if (categoryId.value) {
const cat = crm.usageCategories.find((c) => c.id === categoryId.value)
if (cat) return categoryIconOf(usageCategoryIconKey(cat), IconCatalog)
}
if (season.value) {
const item = seasons.value.find((s) => s.id === season.value)
if (item) return categoryIconOf(seasonIconKey(item), IconDiamond)
}
return null
})
const bannerImageUrl = computed(() => {
if (categoryId.value) {
const cat = crm.usageCategories.find((c) => c.id === categoryId.value)
if (cat?.imageUrl) return cat.imageUrl
}
if (season.value) {
const item = seasons.value.find((s) => s.id === season.value)
if (item?.imageUrl) return item.imageUrl
}
if (catalogId.value) {
return products.value.find((item) => item.imageUrl)?.imageUrl
}
return undefined
})
const bannerStyle = computed(() =>
bannerImageUrl.value ? { backgroundImage: `url(${bannerImageUrl.value})` } : undefined,
)
const showSearch = computed(() => !categoryId.value && !catalogId.value)
const sortedProducts = computed(() => {
@@ -75,6 +152,10 @@ function cycleSort() {
sortMode.value = sortMode.value === 'newest' ? 'name' : 'newest'
}
const sortAriaLabel = computed(() =>
sortMode.value === 'newest' ? 'مرتب‌سازی: جدیدترین' : 'مرتب‌سازی: نام',
)
function applySearch(value: string) {
searchDraft.value = value
router.replace({
@@ -168,11 +249,18 @@ function toggleFavorite(productId: string) {
<p v-if="error">{{ error }}</p>
<PageSkeleton v-else-if="loading" variant="grid" />
<template v-else>
<section class="cat-banner">
<div class="cat-banner__icon" aria-hidden="true" />
<div>
<h2>{{ title }}</h2>
<p>{{ bannerSubtitle }}</p>
<section
class="cat-banner"
:class="{ 'cat-banner--photo': !!bannerImageUrl }"
:style="bannerStyle"
>
<div v-if="bannerIcon" class="cat-banner__icon-ring" aria-hidden="true">
<component :is="bannerIcon" :size="26" color="#ffffff" />
</div>
<div class="cat-banner__copy">
<p v-if="bannerCopy.lead" class="cat-banner__lead">{{ bannerCopy.lead }}</p>
<h2 class="cat-banner__title">{{ bannerCopy.headline }}</h2>
<p v-if="bannerCopy.tagline" class="cat-banner__tagline">{{ bannerCopy.tagline }}</p>
</div>
</section>
@@ -199,8 +287,34 @@ function toggleFavorite(productId: string) {
<div class="list-meta">
<span>تعداد محصولات: {{ sortedProducts.length.toLocaleString('fa-IR') }}</span>
<button type="button" class="list-meta__sort" @click="cycleSort">
مرتب‌سازی: {{ sortMode === 'newest' ? 'جدیدترین' : 'نام' }}
<button
type="button"
class="list-meta__sort"
:aria-label="sortAriaLabel"
@click="cycleSort"
>
<span class="list-meta__sort-icon" aria-hidden="true">
<svg width="15" height="12" viewBox="0 0 15 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.5 2.5V9.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" />
<path
d="M2.5 7.5L4.5 9.5L6.5 7.5"
stroke="currentColor"
stroke-width="1.3"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path d="M10.5 9.5V2.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" />
<path
d="M8.5 4.5L10.5 2.5L12.5 4.5"
stroke="currentColor"
stroke-width="1.3"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</span>
<span class="list-meta__sort-badge">۱</span>
<span class="list-meta__sort-label">مرتب‌سازی</span>
</button>
</div>
@@ -288,35 +402,86 @@ function toggleFavorite(productId: string) {
}
.cat-banner {
position: relative;
isolation: isolate;
display: flex;
align-items: center;
gap: 12px;
min-height: 96px;
padding: 16px;
justify-content: space-between;
gap: 16px;
min-height: 112px;
padding: 18px 20px;
border-radius: var(--radius-lg);
overflow: hidden;
background:
linear-gradient(100deg, rgba(var(--color-primary-rgb), 0.75), rgba(var(--color-primary-rgb), 0.35)),
#2f3430;
background-size: cover;
background-position: center top;
background-repeat: no-repeat;
color: #fff;
}
.cat-banner__icon {
width: 48px;
height: 48px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.2);
flex-shrink: 0;
.cat-banner--photo::before {
content: '';
position: absolute;
inset: 0;
z-index: 0;
background: linear-gradient(
90deg,
rgba(0, 0, 0, 0.58) 0%,
rgba(0, 0, 0, 0.28) 42%,
rgba(0, 0, 0, 0.08) 68%,
transparent 100%
);
pointer-events: none;
}
.cat-banner h2 {
margin: 0 0 4px;
font-size: 1rem;
.cat-banner__copy {
position: relative;
z-index: 1;
min-width: 0;
flex: 1;
text-align: start;
}
.cat-banner p {
.cat-banner__lead {
margin: 0 0 2px;
font-size: 0.82rem;
font-weight: 500;
line-height: 1.4;
opacity: 0.95;
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.35);
}
.cat-banner__title {
margin: 0 0 6px;
font-size: 1.35rem;
font-weight: 700;
line-height: 1.25;
text-shadow: 0 1px 8px rgba(0, 0, 0, 0.35);
}
.cat-banner__tagline {
margin: 0;
font-size: 0.8rem;
opacity: 0.9;
font-size: 0.78rem;
font-weight: 400;
line-height: 1.5;
opacity: 0.92;
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.35);
}
.cat-banner__icon-ring {
position: relative;
z-index: 1;
width: 56px;
height: 56px;
border-radius: 999px;
border: 1.5px solid rgba(255, 255, 255, 0.88);
background: rgba(0, 0, 0, 0.16);
display: grid;
place-items: center;
flex-shrink: 0;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
}
.chips {
@@ -327,9 +492,9 @@ function toggleFavorite(productId: string) {
.chip {
border: none;
border-radius: 999px;
border-radius: var(--radius-lg);
padding: 8px 14px;
background: #ecefee;
background: var(--color-bg);
color: var(--color-text);
white-space: nowrap;
cursor: pointer;
@@ -350,15 +515,27 @@ function toggleFavorite(productId: string) {
}
.list-meta__sort {
display: inline-flex;
align-items: center;
gap: 4px;
border: none;
background: transparent;
color: var(--color-primary);
color: inherit;
font: inherit;
font-weight: 700;
cursor: pointer;
padding: 0;
}
.list-meta__sort-icon {
display: inline-flex;
flex-shrink: 0;
line-height: 0;
}
.list-meta__sort-badge {
font-variant-numeric: tabular-nums;
}
.product-item {
display: grid;
grid-template-columns: 112px 1fr;
@@ -415,7 +592,7 @@ function toggleFavorite(productId: string) {
font-size: 0.65rem;
font-weight: 700;
padding: 2px 8px;
border-radius: 999px;
border-radius: 6px;
white-space: nowrap;
}
@@ -33,7 +33,7 @@ async function open(item: AppNotification) {
</script>
<template>
<ProfileSubpageLayout title="اعلان‌ها" subtitle="آخرین خبرهای سفارش‌های شما">
<ProfileSubpageLayout title="اعلان‌ها" subtitle="سفارش‌ها، پیشنهاد محصول و سایر اعلان‌ها">
<div v-if="items.length" class="notif-box">
<div class="notif-box__head">
<span class="notif-box__count">
@@ -75,7 +75,7 @@ async function open(item: AppNotification) {
v-else
icon="vuesax-linear:notification-bing"
title="اعلانی ندارید"
message="وقتی فاکتور سفارشتان صادر شود یا وضعیت چک و پرداخت تغییر کند، همین‌جا خبردار می‌شوید."
message="وقتی فاکتور سفارشتان صادر شود، پیشنهاد محصولی برایتان ثبت شود یا وضعیت پرداخت تغییر کند، همین‌جا خبردار می‌شوید."
>
<BaseButton variant="outline" @click="router.push('/orders')">سفارش‌های من</BaseButton>
</EmptyState>
@@ -129,7 +129,7 @@ async function open(item: AppNotification) {
}
.notif-row--unread {
background: var(--color-elevated);
background: var(--color-tab-inactive-bg);
}
.notif-row__icon {
@@ -93,7 +93,7 @@ const ordersCount = computed(() => profile.value?.ordersCount ?? orderStore.orde
const salesVolume = computed(() => profile.value?.salesVolumeToman ?? 0)
const profit = computed(() => profile.value?.profitToman ?? 0)
const levelProgress = computed(
() => profile.value?.levelProgress ?? auth.user?.levelProgress ?? 72,
() => profile.value?.levelProgress ?? auth.user?.levelProgress ?? 0,
)
const unreadLabel = computed(() =>
notificationStore.unreadCount > 9 ? '9+' : String(notificationStore.unreadCount),
@@ -257,9 +257,11 @@ onMounted(async () => {
</svg>
</div>
<p class="loyalty__text">
شما در سطح {{ levelLabel }} قرار دارید. تا سطح
{{ nextLevelLabel }} فقط
{{ formatNumber(pointsToNext) }} تومان فروش دیگر نیاز دارید.
<span>شما در سطح {{ levelLabel }} قرار دارید.</span>
<span class="loyalty__text-next">
تا سطح {{ nextLevelLabel }} فقط
{{ formatNumber(pointsToNext) }} تومان فروش دیگر نیاز دارید.
</span>
</p>
</div>
<div class="loyalty__bar">
@@ -605,11 +607,16 @@ onMounted(async () => {
}
.loyalty__text {
font-weight: bold;
margin: 0;
font-size: 0.8125rem;
font-size: 0.75rem;
line-height: 1.75;
color: var(--color-text);
text-align: start;
text-align: center;
}
.loyalty__text-next {
display: block;
}
.loyalty__bar {
+1 -1
View File
@@ -288,7 +288,7 @@ const chartBars = computed(() => {
.btn-outline {
border: 1px solid var(--color-border);
background: #fff;
background: var(--color-surface);
}
.btn-primary {
File diff suppressed because it is too large Load Diff