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:
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
@@ -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>
|
||||
+114
-92
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -385,7 +385,6 @@ async function submit() {
|
||||
padding: 0 14px;
|
||||
border: 1px solid #e0d9d0;
|
||||
border-radius: 14px;
|
||||
background: #ebe4dc;
|
||||
}
|
||||
|
||||
.reg-field.is-invalid,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user