Polish customer app UI and streamline CRM overlays.

Replace standalone customer create and meeting report pages with overlays, add TimeInput, remove opaque input backgrounds app-wide, improve desktop layouts/skeletons, and refresh shopping, customer, and reminder screens.
This commit is contained in:
2026-09-15 21:41:48 +03:30
parent d2c11e5d62
commit bbb3a15d1c
101 changed files with 2910 additions and 896 deletions
+1 -2
View File
@@ -90,7 +90,7 @@ onMounted(async () => {
<template>
<div class="page login-page">
<img class="login-page__bg" :src="fabricUrl" alt="" />
<img class="login-page__fabric" :src="fabricUrl2" alt="" />
<!-- <img class="login-page__fabric" :src="fabricUrl2" alt="" />-->
<div class="login-page__scroll">
<div class="login-page__stage">
@@ -346,7 +346,6 @@ onMounted(async () => {
padding: 0 8px 0 14px;
border: 1px solid #e4e0db;
border-radius: 14px;
background: #e4ddd4;
}
.phone-field.is-invalid {
-2
View File
@@ -456,7 +456,6 @@ onMounted(() => {
padding: 0;
border: 1px solid #e4e0db;
border-radius: 16px;
background: #f7f5f1;
color: #1c1b19;
font-family: inherit;
font-size: 1.25rem;
@@ -469,7 +468,6 @@ onMounted(() => {
.otp-box:focus {
outline: none;
border-color: var(--color-primary);
background: #fff;
}
.otp-box--error {
-1
View File
@@ -385,7 +385,6 @@ async function submit() {
padding: 0 14px;
border: 1px solid #e0d9d0;
border-radius: 14px;
background: #ebe4dc;
}
.reg-field.is-invalid,
+8 -7
View File
@@ -400,8 +400,8 @@ async function addAllToInquiry() {
<style scoped>
.calite-detail {
background: #f4f0ed;
color: #2a2622;
background: var(--color-bg);
color: var(--color-text);
}
.calite-detail__nav,
@@ -563,7 +563,7 @@ async function addAllToInquiry() {
top: 16%;
bottom: 16%;
width: 1px;
background: #e4dfd8;
background: var(--color-border);
}
.attr__icon {
@@ -617,7 +617,7 @@ async function addAllToInquiry() {
padding: 0 14px;
border: none;
border-radius: 999px;
background: #ece8e2;
background: var(--color-tab-inactive-bg);
color: #6a645e;
font: inherit;
font-size: 0.8125rem;
@@ -644,11 +644,12 @@ async function addAllToInquiry() {
overflow: hidden;
border: none;
border-radius: 20px;
background: #fff;
background: var(--color-surface);
text-align: start;
cursor: pointer;
color: #1c1c1c;
box-shadow: 0 1px 3px rgba(40, 32, 24, 0.05);
box-shadow: none;
border: 1px solid var(--color-border);
}
.pattern-card__photo {
@@ -903,7 +904,7 @@ html.dark .calite-detail__footer.sticky-footer {
width: 100%;
max-width: none;
padding: 12px 28px 24px;
background: #f4f0ed;
background: var(--color-bg);
}
.inquiry-bar {
@@ -175,7 +175,6 @@ async function submit() {
border-radius: 10px;
text-align: center;
font: inherit;
background: var(--color-bg);
color: var(--color-text);
}
@@ -193,7 +192,6 @@ async function submit() {
padding: 10px 12px;
font: inherit;
resize: vertical;
background: var(--color-surface);
color: var(--color-text);
}
+50 -30
View File
@@ -10,7 +10,7 @@ import IconChevronLeft from '@/components/icons/IconChevronLeft.vue'
import IconHeadset from '@/components/icons/IconHeadset.vue'
import IconDiamond from '@/components/icons/IconDiamond.vue'
import IconCatalog from '@/components/icons/IconCatalog.vue'
import { categoryIconOf } from '@/utils/categoryIcons'
import { categoryIconOf, usageCategoryIconKey } from '@/utils/categoryIcons'
import { useGoBack } from '@/composables/useGoBack'
import AuthThemeToggle from '@/components/ui/AuthThemeToggle.vue'
import PageSkeleton from '@/components/ui/PageSkeleton.vue'
@@ -23,10 +23,6 @@ const seasonCategories = ref<SeasonCategory[]>([])
const loading = ref(true)
const error = ref<string | null>(null)
function usageIconKey(item: ProductCategory) {
return item.icon || (item.id === 'shirt' ? 'id-card' : item.id === 'suit' ? 'bag' : 'grid')
}
function seasonIconKey(item: SeasonCategory) {
return item.icon || (item.id === 'summer' ? 'bolt' : item.id === 'winter' ? 'feather' : 'diamond')
}
@@ -91,7 +87,7 @@ function openSeason(id: string) {
:style="item.imageUrl ? { backgroundImage: `url(${item.imageUrl})` } : undefined"
>
<span v-if="item.showIcon !== false" class="usage-card__icon">
<component :is="categoryIconOf(usageIconKey(item), IconCatalog)" :size="18" />
<component :is="categoryIconOf(usageCategoryIconKey(item), IconCatalog)" :size="18" />
</span>
</div>
<div class="usage-card__body">
@@ -114,7 +110,7 @@ function openSeason(id: string) {
:key="item.id"
type="button"
class="season-card"
:class="`season-card--${item.id}`"
:class="[`season-card--${item.id}`, { 'season-card--banner': !!item.imageUrl }]"
:style="item.imageUrl ? { backgroundImage: `url(${item.imageUrl})` } : undefined"
@click="openSeason(item.id)"
>
@@ -260,7 +256,8 @@ function openSeason(id: string) {
margin: 0;
line-height: normal;
font-family: inherit;
box-shadow: var(--shadow-sm);
box-shadow: none;
border: 1px solid var(--color-border);
}
.usage-card__image {
@@ -297,11 +294,12 @@ function openSeason(id: string) {
width: 28px;
height: 28px;
border-radius: 999px;
background: var(--color-surface);
background: var(--color-primary-soft);
color: var(--color-primary);
display: grid;
place-items: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
box-shadow: none;
border: 1px solid var(--color-border);
}
.usage-card__body {
@@ -335,9 +333,9 @@ function openSeason(id: string) {
position: relative;
isolation: isolate;
display: flex;
align-items: center;
align-items: flex-end;
justify-content: flex-start;
min-height: 124px;
min-height: 148px;
width: 100%;
appearance: none;
-webkit-appearance: none;
@@ -346,17 +344,28 @@ function openSeason(id: string) {
overflow: hidden;
text-align: start;
cursor: pointer;
padding: 16px 18px;
padding: 14px 16px;
margin: 0;
font-family: inherit;
box-shadow: var(--shadow-sm);
box-shadow: none;
border: 1px solid var(--color-border);
background-color: #d6c3a8;
background-image: linear-gradient(160deg, #d6c3a8, #8b7355);
background-size: cover;
background-position: center left;
background-position: center top;
background-repeat: no-repeat;
}
.season-card--banner {
align-items: stretch;
justify-content: flex-start;
padding: 0;
min-height: 156px;
aspect-ratio: 2.35 / 1;
background-size: cover;
background-position: center top;
}
.season-card--summer {
background-image: linear-gradient(160deg, #f3e6c8, #c4a574);
}
@@ -369,25 +378,23 @@ function openSeason(id: string) {
background-image: linear-gradient(160deg, #efe6d6, #b9a48a);
}
.season-card::after {
content: '';
position: absolute;
inset: 0;
z-index: 0;
pointer-events: none;
background: linear-gradient(
to left,
color-mix(in srgb, var(--color-surface) 90%, transparent) 0%,
color-mix(in srgb, var(--color-surface) 72%, transparent) 38%,
color-mix(in srgb, var(--color-surface) 22%, transparent) 68%,
transparent 100%
);
}
.season-card__content {
position: relative;
z-index: 1;
max-width: 58%;
padding: 14px 16px;
}
.season-card--banner .season-card__content {
display: flex;
flex-direction: column;
justify-content: flex-end;
align-self: stretch;
max-width: none;
width: 100%;
min-height: 100%;
margin-top: auto;
padding: 14px 16px;
}
.season-card__content strong {
@@ -415,6 +422,19 @@ function openSeason(id: string) {
flex-shrink: 0;
}
.season-card--banner .season-card__content strong,
.season-card--banner .season-card__content p,
.season-card--banner .season-card__link {
color: #fff;
text-shadow:
0 1px 2px rgba(0, 0, 0, 0.55),
0 2px 12px rgba(0, 0, 0, 0.35);
}
.season-card--banner .season-card__icon {
background: rgba(255, 255, 255, 0.92);
}
.season-card__content p {
margin: 0 0 10px;
color: var(--color-text-muted);
@@ -398,7 +398,7 @@ const currentStep = 1
}
.company-showcase--par .company-showcase__cta {
background: #fff;
background: var(--color-surface);
color: var(--color-primary);
}
-169
View File
@@ -1,169 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useCrmStore } from '@/stores/crm'
import { useGoBack } from '@/composables/useGoBack'
import PageHeader from '@/components/layout/PageHeader.vue'
import BaseInput from '@/components/ui/BaseInput.vue'
import FormErrorBanner from '@/components/ui/FormErrorBanner.vue'
import BaseButton from '@/components/ui/BaseButton.vue'
import { normalizePhone } from '@/utils/format'
import { validatePhone } from '@/utils/validation'
import {
FORM_VALIDATION_SUMMARY,
clearFieldError,
hasFieldErrors,
type FieldErrors,
} from '@/utils/formErrors'
import { ApiError } from '@/services/http'
const router = useRouter()
const auth = useAuthStore()
const crm = useCrmStore()
const { goBack } = useGoBack('/customers')
const firstName = ref('')
const lastName = ref('')
const phone = ref('')
const businessName = ref('')
const fieldErrors = ref<FieldErrors>({})
const formError = ref('')
const saving = ref(false)
function clearField(key: string) {
fieldErrors.value = clearFieldError(fieldErrors.value, key)
if (!hasFieldErrors(fieldErrors.value)) formError.value = ''
}
async function submit() {
fieldErrors.value = {}
formError.value = ''
if (!auth.isAgent) {
formError.value = 'فقط واسطه می‌تواند مشتری به لیست خود اضافه کند.'
return
}
const normalized = normalizePhone(phone.value)
const errors: FieldErrors = {}
if (!firstName.value.trim()) errors.firstName = 'نام الزامی است.'
if (!lastName.value.trim()) errors.lastName = 'نام خانوادگی الزامی است.'
if (!validatePhone(normalized)) errors.phone = 'شماره موبایل معتبر نیست.'
if (hasFieldErrors(errors)) {
fieldErrors.value = errors
formError.value = FORM_VALIDATION_SUMMARY
return
}
saving.value = true
try {
const created = (await crm.registerCustomer({
firstName: firstName.value.trim(),
lastName: lastName.value.trim(),
phone: normalized,
businessName: businessName.value.trim() || undefined,
})) as { id?: string }
if (created?.id) {
await router.replace({ path: '/customer', query: { id: created.id } })
return
}
await router.replace('/customers')
} catch (e) {
formError.value =
e instanceof ApiError || e instanceof Error
? e.message
: 'ثبت مشتری ناموفق بود.'
} finally {
saving.value = false
}
}
</script>
<template>
<div class="page customer-create-page">
<PageHeader title="افزودن مشتری" show-back centered @back="goBack" />
<div class="page__scroll customer-create-scroll">
<p class="intro">
مشتری به لیست شما اضافه می‌شود و بعد از ورود با همین شماره، سفارش‌هایش به حساب شما وصل می‌ماند.
</p>
<FormErrorBanner :message="formError" />
<div class="form-card">
<BaseInput
v-model="firstName"
label="نام"
placeholder="نام مشتری"
:error="fieldErrors.firstName"
@update:model-value="clearField('firstName')"
/>
<BaseInput
v-model="lastName"
label="نام خانوادگی"
placeholder="نام خانوادگی"
:error="fieldErrors.lastName"
@update:model-value="clearField('lastName')"
/>
<BaseInput
v-model="phone"
label="شماره موبایل"
type="tel"
inputmode="tel"
dir="ltr"
placeholder="۰۹۱۲۳۴۵۶۷۸۹"
:error="fieldErrors.phone"
@update:model-value="clearField('phone')"
/>
<BaseInput
v-model="businessName"
label="نام فروشگاه یا برند (اختیاری)"
placeholder="مثلاً بوتیک رز"
/>
</div>
</div>
<footer class="footer">
<BaseButton block :loading="saving" :disabled="saving" @click="submit">
ثبت مشتری
</BaseButton>
</footer>
</div>
</template>
<style scoped>
.customer-create-page {
background: var(--color-bg);
}
.customer-create-scroll {
padding: 8px var(--page-padding) 100px;
display: flex;
flex-direction: column;
gap: 12px;
}
.intro {
margin: 0;
color: var(--color-text-muted);
font-size: 0.875rem;
line-height: 1.7;
}
.form-card {
background: var(--color-surface);
border-radius: var(--radius-md);
padding: 16px;
box-shadow: var(--shadow-sm);
}
.footer {
position: sticky;
bottom: 0;
padding: 12px var(--page-padding) calc(12px + env(safe-area-inset-bottom, 0px));
background: color-mix(in srgb, var(--color-bg) 92%, transparent);
}
</style>
+467 -229
View File
@@ -7,6 +7,7 @@ import type {
CrmCustomer,
CrmMeeting,
CrmOrderSummary,
CustomerStatus,
CustomerTab,
MeetingInteractionType,
} from '@/types/crm'
@@ -21,6 +22,7 @@ import IconClipboardEmpty from '@/components/icons/IconClipboardEmpty.vue'
import IconPhone from '@/components/icons/IconPhone.vue'
import IconMail from '@/components/icons/IconMail.vue'
import IconEditPen from '@/components/icons/IconEditPen.vue'
import MeetingReportOverlay from '@/components/customers/MeetingReportOverlay.vue'
const route = useRoute()
const router = useRouter()
@@ -28,6 +30,7 @@ const crm = useCrmStore()
const auth = useAuthStore()
const id = computed(() => String(route.query.id ?? ''))
const tab = computed(() => (String(route.query.tab ?? 'info') as CustomerTab))
const showMeetingReport = computed(() => route.query.meetingReport === '1')
const customer = ref<CrmCustomer | null>(null)
const orders = ref<CrmOrderSummary[]>([])
const meetings = ref<CrmMeeting[]>([])
@@ -69,6 +72,25 @@ function setTab(next: CustomerTab) {
router.replace({ path: '/customer', query: { id: id.value, tab: next } })
}
function openMeetingReport() {
router.push({
path: '/customer',
query: { id: id.value, tab: 'reports', meetingReport: '1' },
})
}
function closeMeetingReport() {
router.replace({
path: '/customer',
query: { id: id.value, tab: 'reports' },
})
}
async function onMeetingReportSuccess() {
closeMeetingReport()
await load()
}
function typeLabel(type: MeetingInteractionType) {
if (type === 'in_person') return 'ملاقات حضوری'
if (type === 'call') return 'تماس'
@@ -117,6 +139,20 @@ function orderStatusTone(order: CrmOrderSummary): 'warning' | 'success' | 'info'
if (variant === 'warning' || variant === 'danger') return 'warning'
return order.statusTone || 'info'
}
const recentOrders = computed(() => orders.value.slice(0, 3))
function customerStatusLabel(status: CustomerStatus) {
if (status === 'active') return 'فعال'
if (status === 'inactive') return 'غیرفعال'
return 'جدید'
}
function customerStatusClass(status: CustomerStatus) {
if (status === 'active') return 'status-badge--active'
if (status === 'inactive') return 'status-badge--inactive'
return 'status-badge--new'
}
</script>
<template>
@@ -133,22 +169,24 @@ function orderStatusTone(order: CrmOrderSummary): 'warning' | 'success' | 'info'
</PageHeader>
<div class="page__scroll page__scroll--with-nav customer-scroll">
<section v-if="tab !== 'reports'" class="profile-row">
<div class="avatar" :style="{ background: customer.avatarColor }">
<section v-if="tab !== 'reports'" class="profile-card">
<div class="profile-card__avatar" :style="{ background: customer.avatarColor }">
{{ customer.avatarLetter }}
</div>
<div>
<div class="name-row">
<div class="profile-card__info">
<div class="profile-card__name-row">
<h2>{{ customer.name }}</h2>
<span class="badge">فعال</span>
<span class="status-badge" :class="customerStatusClass(customer.status)">
{{ customerStatusLabel(customer.status) }}
</span>
</div>
<p dir="ltr">{{ customer.phone }}</p>
<p class="profile-card__phone" dir="ltr">{{ customer.phone }}</p>
</div>
</section>
<section v-else class="profile-banner">
<div class="profile-banner__top">
<div class="avatar avatar--lg" :style="{ background: customer.avatarColor }">
<div class="profile-card__avatar profile-card__avatar--lg" :style="{ background: customer.avatarColor }">
{{ customer.avatarLetter }}
</div>
<div>
@@ -156,7 +194,7 @@ function orderStatusTone(order: CrmOrderSummary): 'warning' | 'success' | 'info'
<p>{{ customer.contactName }}</p>
<p dir="ltr">{{ customer.phone }}</p>
</div>
<span class="badge badge--light">مشتری فعال</span>
<span class="status-badge status-badge--active status-badge--on-dark">فعال</span>
</div>
<div class="profile-banner__stats">
<div>
@@ -174,54 +212,85 @@ function orderStatusTone(order: CrmOrderSummary): 'warning' | 'success' | 'info'
</div>
</section>
<div class="tabs">
<div class="tabs" role="tablist">
<button
v-for="item in tabs"
:key="item.id"
type="button"
role="tab"
class="tab"
:class="{ 'tab--active': tab === item.id }"
:aria-selected="tab === item.id"
@click="setTab(item.id)"
>
{{ item.label }}
<span class="tab__text">{{ item.label }}</span>
</button>
</div>
<template v-if="tab === 'info'">
<section class="stats">
<h3>اطلاعات خرید</h3>
<div class="stats__grid">
<div>
<span>تعداد سفارش</span>
<strong>{{ formatNumber(customer.orderCount) }}</strong>
<section class="purchase-card">
<h3 class="section-title">اطلاعات خرید</h3>
<div class="purchase-stats">
<div class="purchase-stats__item">
<span class="purchase-stats__label">تعداد سفارش</span>
<strong class="purchase-stats__value">{{ formatNumber(customer.orderCount) }}</strong>
</div>
<div>
<span>مجموع خرید</span>
<strong>{{ formatNumber(customer.totalPurchaseToman) }}</strong>
<em>تومان</em>
<span class="purchase-stats__divider" aria-hidden="true" />
<div class="purchase-stats__item">
<span class="purchase-stats__label">مجموع خرید</span>
<strong class="purchase-stats__value">{{ formatNumber(customer.totalPurchaseToman) }}</strong>
<em class="purchase-stats__unit">تومان</em>
</div>
<div>
<span>میانگین هر سفارش</span>
<strong>{{ formatNumber(customer.averageOrderToman) }}</strong>
<em>تومان</em>
<span class="purchase-stats__divider" aria-hidden="true" />
<div class="purchase-stats__item">
<span class="purchase-stats__label">میانگین هر سفارش</span>
<strong class="purchase-stats__value">{{ formatNumber(customer.averageOrderToman) }}</strong>
<em class="purchase-stats__unit">تومان</em>
</div>
</div>
</section>
<button type="button" class="agent-card" @click="router.push('/profile')">
<div class="agent-card__avatar">{{ agentInitial }}</div>
<div>
<strong>{{ agentName }}</strong>
<p>کد واسطه: {{ agentCode }}</p>
</div>
<IconChevronLeft :size="18" />
</button>
<section class="panel-card">
<button type="button" class="agent-row" @click="router.push('/profile')">
<div class="agent-row__avatar">{{ agentInitial }}</div>
<div class="agent-row__info">
<strong>{{ agentName }}</strong>
<p>کد واسطه: {{ agentCode }}</p>
</div>
<IconChevronLeft :size="18" class="agent-row__chevron" aria-hidden="true" />
</button>
<section>
<div class="section-head">
<h3>آخرین سفارش‌ها</h3>
<button type="button" class="link" @click="setTab('orders')">مشاهده همه</button>
<div class="orders-block">
<div class="orders-block__head">
<h3 class="section-title">آخرین سفارش‌ها</h3>
<button type="button" class="link-btn" @click="setTab('orders')">مشاهده همه</button>
</div>
<article
v-for="order in recentOrders"
:key="order.id"
class="order-row"
role="button"
tabindex="0"
@click="openOrder(order.id)"
@keydown.enter="openOrder(order.id)"
>
<div class="order-row__title">
<strong>{{ order.title }}</strong>
<IconChevronLeft :size="14" class="order-row__chevron" aria-hidden="true" />
</div>
<span class="order-row__amount">{{ formatNumber(order.amountToman) }} تومان</span>
<span class="status-pill" :class="`status-pill--${orderStatusTone(order)}`">
{{ orderStatusLabel(order) }}
</span>
</article>
<p v-if="!recentOrders.length" class="empty-inline">سفارشی ثبت نشده است.</p>
</div>
</section>
</template>
<template v-else-if="tab === 'orders'">
<section class="panel-card panel-card--list">
<article
v-for="order in orders"
:key="order.id"
@@ -231,74 +300,58 @@ function orderStatusTone(order: CrmOrderSummary): 'warning' | 'success' | 'info'
@click="openOrder(order.id)"
@keydown.enter="openOrder(order.id)"
>
<div>
<div class="order-row__title">
<strong>{{ order.title }}</strong>
<p>{{ formatNumber(order.amountToman) }} تومان</p>
<IconChevronLeft :size="14" class="order-row__chevron" aria-hidden="true" />
</div>
<span class="tone" :class="`tone--${orderStatusTone(order)}`">{{ orderStatusLabel(order) }}</span>
<IconChevronLeft :size="16" class="order-row__chevron" />
<span class="order-row__amount">{{ formatNumber(order.amountToman) }} تومان</span>
<span class="status-pill" :class="`status-pill--${orderStatusTone(order)}`">
{{ orderStatusLabel(order) }}
</span>
</article>
<p v-if="!orders.length" class="empty-inline">سفارشی ثبت نشده است.</p>
</section>
</template>
<template v-else-if="tab === 'orders'">
<article
v-for="order in orders"
:key="order.id"
class="order-row"
role="button"
tabindex="0"
@click="openOrder(order.id)"
@keydown.enter="openOrder(order.id)"
>
<div>
<strong>{{ order.title }}</strong>
<p>{{ order.date }} — {{ formatNumber(order.amountToman) }} تومان</p>
</div>
<span class="tone" :class="`tone--${orderStatusTone(order)}`">{{ orderStatusLabel(order) }}</span>
<IconChevronLeft :size="16" class="order-row__chevron" />
</article>
<p v-if="!orders.length" class="empty-inline">سفارشی ثبت نشده است.</p>
</template>
<template v-else>
<h3 class="block-title">تعاملات و پیگیری‌ها</h3>
<button
type="button"
class="dashed-btn"
@click="router.push({ path: '/meeting-report', query: { customerId: customer.id } })"
>
+ ثبت گزارش ملاقات جدید
</button>
<div class="meeting-filters">
<section class="panel-card panel-card--reports">
<h3 class="section-title">تعاملات و پیگیری‌ها</h3>
<button
v-for="chip in meetingFilters"
:key="chip.id"
type="button"
class="meeting-filter"
:class="{ 'meeting-filter--active': meetingFilter === chip.id }"
@click="meetingFilter = chip.id"
class="dashed-btn"
@click="openMeetingReport"
>
{{ chip.label }}
+ ثبت گزارش ملاقات جدید
</button>
</div>
<article v-for="item in filteredMeetings" :key="item.id" class="meeting-row">
<div class="meeting-row__icon" :class="meetingIconClass(item.type)">
<IconClipboardEmpty v-if="item.type === 'in_person'" :size="16" />
<IconPhone v-else-if="item.type === 'call'" :size="16" />
<IconMail v-else-if="item.type === 'message'" :size="16" />
<IconEditPen v-else :size="16" />
<div class="meeting-filters">
<button
v-for="chip in meetingFilters"
:key="chip.id"
type="button"
class="meeting-filter"
:class="{ 'meeting-filter--active': meetingFilter === chip.id }"
@click="meetingFilter = chip.id"
>
{{ chip.label }}
</button>
</div>
<div class="meeting-row__body">
<strong>{{ typeLabel(item.type) }}</strong>
<p>{{ item.description || item.title }}</p>
</div>
<span>{{ item.date }}</span>
</article>
<p v-if="!filteredMeetings.length" class="empty-inline">
گزارشی برای این فیلتر ثبت نشده است.
</p>
<article v-for="item in filteredMeetings" :key="item.id" class="meeting-row">
<div class="meeting-row__icon" :class="meetingIconClass(item.type)">
<IconClipboardEmpty v-if="item.type === 'in_person'" :size="16" />
<IconPhone v-else-if="item.type === 'call'" :size="16" />
<IconMail v-else-if="item.type === 'message'" :size="16" />
<IconEditPen v-else :size="16" />
</div>
<div class="meeting-row__body">
<strong>{{ typeLabel(item.type) }}</strong>
<p>{{ item.description || item.title }}</p>
</div>
<span class="meeting-row__date">{{ item.date }}</span>
</article>
<p v-if="!filteredMeetings.length" class="empty-inline">
گزارشی برای این فیلتر ثبت نشده است.
</p>
</section>
</template>
</div>
@@ -317,6 +370,13 @@ function orderStatusTone(order: CrmOrderSummary): 'warning' | 'success' | 'info'
<div v-else class="page empty">
<p>{{ error || 'مشتری یافت نشد.' }}</p>
</div>
<MeetingReportOverlay
:open="showMeetingReport"
:customer-id="id"
@close="closeMeetingReport"
@success="onMeetingReportSuccess"
/>
</template>
<style scoped>
@@ -325,62 +385,100 @@ function orderStatusTone(order: CrmOrderSummary): 'warning' | 'success' | 'info'
}
.customer-scroll {
padding: 8px var(--page-padding) 100px;
padding: 0 var(--page-padding) 100px;
display: flex;
flex-direction: column;
gap: 14px;
gap: 0;
}
.profile-row {
.profile-card,
.purchase-card,
.panel-card {
background: transparent;
border-radius: 0;
box-shadow: none;
}
.profile-card {
display: flex;
gap: 12px;
align-items: center;
gap: 14px;
padding: 12px 0 16px;
border-bottom: 1px solid var(--color-border);
}
.avatar {
width: 64px;
height: 64px;
.profile-card__avatar {
width: 56px;
height: 56px;
border-radius: 999px;
display: grid;
place-items: center;
font-size: 1.4rem;
font-size: 1.25rem;
font-weight: 700;
color: #111;
flex-shrink: 0;
}
.avatar--lg {
.profile-card__avatar--lg {
width: 56px;
height: 56px;
}
.name-row {
.profile-card__info {
min-width: 0;
flex: 1;
}
.profile-card__name-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.name-row h2,
.profile-card__name-row h2,
.profile-banner h2 {
margin: 0;
font-size: 1.1rem;
}
.profile-row p,
.profile-banner p {
margin: 2px 0 0;
color: var(--color-text-muted);
font-size: 0.85rem;
}
.badge {
background: var(--color-primary-soft);
color: var(--color-primary);
border-radius: 999px;
padding: 2px 10px;
font-size: 0.75rem;
font-size: 1.05rem;
font-weight: 700;
line-height: 1.35;
}
.badge--light {
.profile-card__phone,
.profile-banner p {
margin: 6px 0 0;
color: var(--color-text-muted);
font-size: 0.875rem;
line-height: 1.4;
}
.status-badge {
display: inline-flex;
align-items: center;
height: 24px;
padding: 0 10px;
border-radius: 6px;
font-size: 0.75rem;
font-weight: 600;
white-space: nowrap;
}
.status-badge--active {
background: var(--color-success-light);
color: var(--color-success);
}
.status-badge--inactive {
background: #f3f4f6;
color: #6b7280;
}
.status-badge--new {
background: #ede9fe;
color: #6d28d9;
}
.status-badge--on-dark {
background: rgba(255, 255, 255, 0.2);
color: #fff;
}
@@ -390,6 +488,7 @@ function orderStatusTone(order: CrmOrderSummary): 'warning' | 'success' | 'info'
color: #fff;
border-radius: var(--radius-lg);
padding: 16px;
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.04), 0 6px 16px rgba(16, 24, 40, 0.05);
}
.profile-banner__top {
@@ -422,169 +521,269 @@ function orderStatusTone(order: CrmOrderSummary): 'warning' | 'success' | 'info'
.tabs {
display: flex;
gap: 12px;
width: 100%;
align-items: stretch;
border-bottom: 1px solid var(--color-border);
overflow-x: auto;
}
.tab {
border: none;
background: transparent;
padding: 10px 2px;
color: var(--color-text-muted);
white-space: nowrap;
flex: 1;
display: flex;
justify-content: center;
align-items: flex-end;
min-width: 0;
appearance: none;
-webkit-appearance: none;
border: 0;
background: none;
padding: 10px 0 0;
color: #9ca3af;
font-family: inherit;
font-size: 0.875rem;
font-weight: 400;
line-height: 1.5;
cursor: pointer;
}
.tab__text {
position: relative;
display: inline-block;
padding-bottom: 11px;
white-space: nowrap;
}
.tab--active {
color: var(--color-text);
font-weight: 700;
box-shadow: inset 0 -3px 0 var(--color-text);
}
.stats,
.agent-card,
.order-row,
.meeting-row {
background: var(--color-surface);
border-radius: var(--radius-md);
padding: 14px;
box-shadow: var(--shadow-sm);
.tab--active .tab__text::after {
content: '';
position: absolute;
inset-inline: 0;
bottom: -1px;
height: 3px;
background: var(--color-primary);
border-radius: 2px 2px 0 0;
}
.stats h3,
.section-head h3,
.block-title {
margin: 0 0 10px;
.section-title {
margin: 0;
font-size: 0.95rem;
font-weight: 700;
color: var(--color-text);
}
.stats__grid {
.purchase-card {
padding: 16px 0;
border-bottom: 1px solid var(--color-border);
}
.purchase-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
text-align: center;
grid-template-columns: 1fr 1px 1fr 1px 1fr;
align-items: stretch;
gap: 0;
margin-top: 14px;
}
.stats__grid span,
.stats__grid em {
display: block;
font-size: 0.7rem;
.purchase-stats__item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
gap: 4px;
padding: 0 6px;
text-align: center;
min-width: 0;
}
.purchase-stats__divider {
width: 1px;
background: var(--color-border);
align-self: stretch;
}
.purchase-stats__label {
font-size: 0.72rem;
color: var(--color-text-muted);
line-height: 1.4;
}
.purchase-stats__value {
font-size: 1rem;
font-weight: 700;
line-height: 1.25;
color: var(--color-text);
}
.purchase-stats__unit {
font-size: 0.72rem;
color: var(--color-text-muted);
font-style: normal;
line-height: 1.3;
}
.stats__grid strong {
display: block;
margin: 4px 0;
font-size: 0.9rem;
.panel-card {
padding: 0 0 8px;
overflow: hidden;
}
.agent-card {
width: 100%;
border: none;
display: grid;
grid-template-columns: 44px 1fr 20px;
gap: 10px;
.panel-card--list,
.panel-card--reports {
padding: 16px 0;
}
.agent-row {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 16px 0;
border: none;
border-bottom: 1px solid var(--color-border);
background: transparent;
text-align: start;
cursor: pointer;
font-family: inherit;
}
.agent-card__avatar {
.agent-row__avatar {
width: 44px;
height: 44px;
border-radius: 999px;
background: var(--color-primary-soft);
background: #e8ecea;
color: var(--color-primary);
display: grid;
place-items: center;
font-weight: 700;
}
.agent-card p {
margin: 2px 0 0;
color: var(--color-text-muted);
font-size: 0.78rem;
}
.section-head {
display: flex;
justify-content: space-between;
align-items: center;
}
.link {
border: none;
background: transparent;
color: var(--color-info);
cursor: pointer;
font-size: 0.8rem;
}
.order-row,
.meeting-row {
display: flex;
justify-content: space-between;
gap: 10px;
align-items: center;
margin-bottom: 8px;
}
.order-row {
cursor: pointer;
}
.order-row__chevron {
color: var(--color-text-muted);
flex-shrink: 0;
}
.meeting-row__body {
.agent-row__info {
flex: 1;
min-width: 0;
}
.order-row p,
.meeting-row p {
margin: 2px 0 0;
.agent-row__info strong {
display: block;
font-size: 0.9rem;
font-weight: 700;
}
.agent-row__info p {
margin: 4px 0 0;
color: var(--color-text-muted);
font-size: 0.78rem;
}
.agent-row__chevron {
color: #9ca3af;
flex-shrink: 0;
}
.orders-block {
padding: 14px 0 8px;
}
.orders-block__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 4px;
}
.link-btn {
border: none;
background: transparent;
color: #6b7280;
font-size: 0.8rem;
font-weight: 500;
cursor: pointer;
padding: 0;
font-family: inherit;
}
.order-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 10px;
align-items: center;
padding: 14px 0;
border-bottom: 1px solid var(--color-border);
cursor: pointer;
font-family: inherit;
}
.panel-card--list .order-row:last-of-type,
.orders-block .order-row:last-of-type {
border-bottom: none;
}
.order-row__title {
display: inline-flex;
align-items: center;
gap: 4px;
min-width: 0;
}
.order-row__title strong {
font-size: 0.875rem;
font-weight: 600;
line-height: 1.35;
}
.order-row__chevron {
color: #9ca3af;
flex-shrink: 0;
}
.order-row__amount {
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-text);
white-space: nowrap;
}
.status-pill {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 26px;
padding: 0 10px;
border-radius: 6px;
font-size: 0.7rem;
font-weight: 600;
white-space: nowrap;
}
.status-pill--warning {
background: #fff4e5;
color: #c27803;
}
.status-pill--success {
background: var(--color-success-light);
color: var(--color-success);
}
.status-pill--info {
background: var(--color-info-light);
color: var(--color-info);
}
.empty-inline {
margin: 8px 0 0;
margin: 12px 0 4px;
text-align: center;
color: var(--color-text-muted);
font-size: 0.85rem;
}
.tone {
border-radius: 999px;
padding: 4px 10px;
font-size: 0.72rem;
white-space: nowrap;
}
.tone--warning {
background: var(--color-warning-light);
color: #b45309;
}
.tone--success {
background: var(--color-success-light);
color: var(--color-success);
}
.tone--info {
background: var(--color-info-light);
color: var(--color-info);
}
.edit-btn {
border: none;
background: transparent;
color: var(--color-info, #2563eb);
color: #3b82f6;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
@@ -596,19 +795,20 @@ function orderStatusTone(order: CrmOrderSummary): 'warning' | 'success' | 'info'
display: flex;
gap: 8px;
overflow-x: auto;
margin-bottom: 8px;
margin: 12px 0 8px;
padding-bottom: 4px;
}
.meeting-filter {
border: 1px solid var(--color-border);
background: var(--color-surface);
border-radius: 999px;
background: var(--color-bg);
border-radius: 6px;
padding: 6px 12px;
font-size: 0.72rem;
color: var(--color-text-muted);
cursor: pointer;
white-space: nowrap;
font-family: inherit;
}
.meeting-filter--active {
@@ -627,7 +827,20 @@ function orderStatusTone(order: CrmOrderSummary): 'warning' | 'success' | 'info'
padding: 14px;
font-weight: 700;
cursor: pointer;
margin-bottom: 8px;
margin-top: 12px;
font-family: inherit;
}
.meeting-row {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 0;
border-bottom: 1px solid var(--color-border);
}
.meeting-row:last-of-type {
border-bottom: none;
}
.meeting-row__icon {
@@ -654,11 +867,34 @@ function orderStatusTone(order: CrmOrderSummary): 'warning' | 'success' | 'info'
color: #6d28d9;
}
.meeting-row__body {
flex: 1;
min-width: 0;
}
.meeting-row__body strong {
display: block;
font-size: 0.875rem;
}
.meeting-row__body p {
margin: 4px 0 0;
color: var(--color-text-muted);
font-size: 0.78rem;
line-height: 1.45;
}
.meeting-row__date {
font-size: 0.75rem;
color: var(--color-text-muted);
flex-shrink: 0;
}
.cta-bar {
position: sticky;
bottom: 0;
padding: 12px var(--page-padding) calc(12px + env(safe-area-inset-bottom, 0));
background: linear-gradient(transparent, var(--color-bg) 30%);
background: linear-gradient(180deg, rgba(238, 241, 240, 0) 0%, var(--color-bg) 32%);
}
.cta {
@@ -668,12 +904,14 @@ function orderStatusTone(order: CrmOrderSummary): 'warning' | 'success' | 'info'
border-radius: 14px;
background: var(--color-primary);
color: #fff;
font-size: 0.95rem;
font-weight: 700;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
font-family: inherit;
}
.empty {
+30 -1
View File
@@ -11,6 +11,7 @@ import IconFilter from '@/components/icons/IconFilter.vue'
import IconChevronLeft from '@/components/icons/IconChevronLeft.vue'
import IconPlus from '@/components/icons/IconPlus.vue'
import IconProfile from '@/components/icons/IconProfile.vue'
import CustomerCreateOverlay from '@/components/customers/CustomerCreateOverlay.vue'
const route = useRoute()
const router = useRouter()
@@ -22,6 +23,7 @@ const loading = ref(true)
const error = ref<string | null>(null)
const filtered = computed(() => customers.value)
const showCreate = computed(() => route.query.create === '1')
const activeCount = computed(() => customers.value.filter((c) => c.status === 'active').length)
const totalPurchase = computed(() =>
@@ -81,8 +83,29 @@ function statusLabel(s: CustomerStatus) {
return 'جدید'
}
function customerListQuery(extra: Record<string, string> = {}) {
return {
...(status.value !== 'all' ? { status: status.value } : {}),
...(q.value ? { q: q.value } : {}),
...extra,
}
}
function onRegisterCustomer() {
router.push('/customers/new')
router.push({ path: '/customers', query: { ...customerListQuery(), create: '1' } })
}
function closeCreate() {
router.replace({ path: '/customers', query: customerListQuery() })
}
async function onCustomerCreated(id?: string) {
if (id) {
await router.replace({ path: '/customer', query: { id } })
return
}
closeCreate()
await load()
}
function cycleStatusFilter() {
@@ -196,6 +219,12 @@ function cycleStatusFilter() {
</div>
</section>
</div>
<CustomerCreateOverlay
:open="showCreate"
@close="closeCreate"
@success="onCustomerCreated"
/>
</div>
</template>
-430
View File
@@ -1,430 +0,0 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
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 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 crm = useCrmStore()
const customerId = computed(() => String(route.query.customerId ?? ''))
const customer = ref<CrmCustomer | null>(null)
const loading = ref(true)
const saving = ref(false)
const error = ref<string | null>(null)
const interactionType = ref<MeetingInteractionType>('in_person')
const date = ref('')
const time = ref('')
const subject = ref('')
const content = ref('')
const reminderOn = ref(true)
const reminderTitle = ref('')
const reminderDate = ref('')
const reminderTime = ref('')
const reminderNote = ref('')
const types: Array<{ id: MeetingInteractionType; label: string; icon: 'clipboard' | 'phone' | 'mail' | 'pen' }> = [
{ id: 'in_person', label: 'ملاقات حضوری', icon: 'clipboard' },
{ id: 'call', label: 'تماس تلفنی', icon: 'phone' },
{ id: 'message', label: 'پیام', icon: 'mail' },
{ id: 'note', label: 'یادداشت', icon: 'pen' },
]
onMounted(async () => {
if (!customerId.value) {
loading.value = false
error.value = 'مشتری مشخص نشده است.'
return
}
try {
const data = await crm.loadCustomerDetail(customerId.value)
customer.value = data.customer
} catch (e) {
error.value = e instanceof Error ? e.message : 'بارگذاری مشتری ناموفق بود.'
} finally {
loading.value = false
}
})
async function submit() {
if (!customerId.value || !subject.value.trim() || !date.value.trim()) {
error.value = 'موضوع و تاریخ الزامی است.'
return
}
saving.value = true
error.value = null
try {
await crm.submitMeeting({
customerId: customerId.value,
type: interactionType.value,
title: subject.value.trim(),
subject: subject.value.trim(),
content: content.value.trim() || undefined,
description: content.value.trim() || undefined,
date: date.value.trim(),
time: time.value.trim() || undefined,
createReminder: reminderOn.value,
reminderTitle: reminderOn.value
? reminderTitle.value.trim() || subject.value.trim()
: undefined,
reminderDate: reminderOn.value
? reminderDate.value.trim() || date.value.trim()
: undefined,
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' },
})
} catch (e) {
error.value = e instanceof Error ? e.message : 'ثبت گزارش ناموفق بود.'
} finally {
saving.value = false
}
}
</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="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">
<div class="avatar" :style="{ background: customer.avatarColor }">
{{ customer.avatarLetter }}
</div>
<div>
<strong>{{ customer.name }}</strong>
<p>{{ customer.contactName }}</p>
<p dir="ltr">{{ customer.phone }}</p>
</div>
</section>
<section>
<h3>نوع تعامل</h3>
<div class="type-grid">
<button
v-for="item in types"
:key="item.id"
type="button"
class="type-btn"
:class="{ 'type-btn--active': interactionType === item.id }"
@click="interactionType = item.id"
>
<IconClipboardEmpty v-if="item.icon === 'clipboard'" :size="22" />
<IconPhone v-else-if="item.icon === 'phone'" :size="22" />
<IconMail v-else-if="item.icon === 'mail'" :size="22" />
<IconEditPen v-else :size="22" />
<span>{{ item.label }}</span>
</button>
</div>
</section>
<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>
</section>
<label class="field">
<span>موضوع گزارش</span>
<input v-model="subject" type="text" />
</label>
<label class="field">
<span>محتوای گزارش</span>
<textarea v-model="content" rows="5" maxlength="1000" />
<em>{{ content.length.toLocaleString('fa-IR') }}/۱۰۰۰</em>
</label>
<section>
<h3>تصاویر و فایل‌ها</h3>
<div class="files">
<div class="file-thumb" />
<div class="file-thumb" />
<button type="button" class="file-add" aria-label="افزودن فایل">+</button>
</div>
</section>
<section class="reminder-box">
<div class="reminder-box__head">
<h3>یادآور</h3>
<button
type="button"
class="toggle"
:class="{ 'toggle--on': reminderOn }"
:aria-pressed="reminderOn"
@click="reminderOn = !reminderOn"
>
<span />
</button>
</div>
<template v-if="reminderOn">
<label class="field">
<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>
<label class="field">
<span>یادداشت یادآور (اختیاری)</span>
<input v-model="reminderNote" type="text" />
</label>
</template>
</section>
</div>
<footer class="footer">
<button type="button" class="submit" :disabled="saving" @click="submit">
{{ saving ? 'در حال ثبت...' : 'ثبت گزارش و یادآور' }}
</button>
</footer>
</div>
</template>
<style scoped>
.meeting-page {
background: var(--color-bg);
}
.meeting-scroll {
padding: 8px var(--page-padding) 100px;
display: flex;
flex-direction: column;
gap: 16px;
}
.customer-card {
display: flex;
gap: 12px;
align-items: center;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 12px;
}
.avatar {
width: 52px;
height: 52px;
border-radius: 999px;
display: grid;
place-items: center;
font-weight: 700;
}
.customer-card p {
margin: 2px 0 0;
color: var(--color-text-muted);
font-size: 0.8rem;
}
h3 {
margin: 0 0 8px;
font-size: 0.9rem;
}
.type-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
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);
border-radius: 12px;
min-height: 72px;
padding: 8px 6px;
font-size: 0.72rem;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
}
.type-btn--active {
border-color: var(--color-primary);
border-width: 2px;
color: var(--color-primary);
font-weight: 700;
background: var(--color-primary-soft);
}
.two-cols {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 0.85rem;
}
.field input,
.field textarea {
border: 1px solid var(--color-border);
border-radius: 12px;
background: #f5f5f5;
padding: 12px;
font: inherit;
resize: vertical;
}
.field em {
font-style: normal;
color: var(--color-text-muted);
font-size: 0.75rem;
align-self: flex-end;
}
.files {
display: flex;
gap: 8px;
}
.file-thumb,
.file-add {
width: 64px;
height: 64px;
border-radius: 12px;
}
.file-thumb {
background: linear-gradient(145deg, #4b5563, #1f2937);
}
.file-add {
border: 1.5px dashed var(--color-border);
background: transparent;
font-size: 1.4rem;
color: var(--color-text-muted);
cursor: pointer;
}
.reminder-box {
background: var(--color-surface);
border-radius: var(--radius-md);
padding: 14px;
display: flex;
flex-direction: column;
gap: 12px;
}
.reminder-box__head {
display: flex;
justify-content: space-between;
align-items: center;
}
.toggle {
width: 46px;
height: 28px;
border: none;
border-radius: 999px;
background: #d1d5db;
padding: 3px;
cursor: pointer;
}
.toggle span {
display: block;
width: 22px;
height: 22px;
border-radius: 999px;
background: #fff;
transition: transform 0.15s;
}
.toggle--on {
background: var(--color-primary);
}
.toggle--on span {
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;
border: none;
border-radius: 14px;
background: var(--color-primary);
color: #fff;
font-weight: 700;
cursor: pointer;
}
</style>
+91 -30
View File
@@ -468,10 +468,6 @@ async function toggleFavorite(productId: string) {
padding-bottom: 8px;
}
.home-scroll > .section:last-child {
margin-top: auto;
}
.hero-banner {
--hero-image: #8a7a65;
position: relative;
@@ -515,6 +511,11 @@ async function toggleFavorite(productId: string) {
.hero-banner__content {
position: relative;
z-index: 1;
width: auto;
max-width: 100%;
min-width: 0;
box-sizing: border-box;
text-align: start;
animation: hero-copy 0.7s cubic-bezier(0.22, 1, 0.36, 1) both;
}
@@ -592,6 +593,7 @@ async function toggleFavorite(productId: string) {
line-height: 1.7;
max-width: 16rem;
font-weight: 700;
overflow-wrap: break-word;
}
.hero-banner__title span {
@@ -644,7 +646,7 @@ async function toggleFavorite(productId: string) {
.quick-card {
border: none;
background: transparent;
background: var(--color-surface);
border-radius: var(--radius-md);
padding: 12px 6px;
display: flex;
@@ -652,7 +654,7 @@ async function toggleFavorite(productId: string) {
align-items: center;
gap: 8px;
cursor: pointer;
box-shadow: none;
box-shadow: 0 2px 10px rgba(16, 24, 40, 0.06);
}
.quick-card__icon {
@@ -661,8 +663,9 @@ async function toggleFavorite(productId: string) {
border-radius: 12px;
display: grid;
place-items: center;
background: var(--color-surface);
box-shadow: 0 2px 12px rgba(16, 24, 40, 0.06);
background: transparent;
box-shadow: none;
border: none;
}
.quick-card__image {
@@ -683,8 +686,8 @@ async function toggleFavorite(productId: string) {
background: var(--color-surface);
border-radius: 18px;
padding: 16px;
box-shadow: 0 2px 12px rgba(16, 24, 40, 0.06);
border: 1px solid rgba(230, 232, 230, 0.9);
box-shadow: none;
border: 1px solid var(--color-border);
}
.level-card__top {
@@ -819,7 +822,8 @@ async function toggleFavorite(productId: string) {
text-align: start;
cursor: pointer;
overflow: hidden;
box-shadow: 0 2px 10px rgba(16, 24, 40, 0.05);
box-shadow: none;
border: 1px solid var(--color-border);
scroll-snap-align: start;
}
@@ -912,7 +916,11 @@ async function toggleFavorite(productId: string) {
@media (min-width: 768px) {
.home-page {
padding-top: 8px;
max-width: var(--content-max-width);
margin-inline: auto;
width: 100%;
padding: 8px var(--page-padding) 0;
box-sizing: border-box;
}
.home-desk-head {
@@ -938,38 +946,87 @@ async function toggleFavorite(productId: string) {
}
.home-scroll {
gap: 22px;
padding: 8px 0 24px;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-areas:
'hero hero level'
'quick quick quick'
'products products products';
gap: 18px 20px;
padding: 8px 0 28px;
align-items: stretch;
min-width: 0;
}
.hero-banner {
min-height: 220px;
grid-area: hero;
width: 100%;
max-width: none;
min-width: 0;
min-height: 0;
height: auto;
aspect-ratio: 2.1 / 1;
align-self: start;
border-radius: 20px;
}
.hero-slide {
padding: 28px 32px;
}
.hero-banner__title {
font-size: 1.35rem;
max-width: 22rem;
.level-card {
grid-area: level;
padding: 20px 22px;
align-self: stretch;
display: flex;
flex-direction: column;
justify-content: center;
}
.quick-grid {
grid-area: quick;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
}
.home-scroll > .section {
grid-area: products;
}
.hero-slide {
width: 100%;
box-sizing: border-box;
align-items: flex-end;
justify-content: flex-start;
padding-inline-start: 36px;
padding-inline-end: 28px;
padding-block: 24px;
}
.hero-slide__media {
background-size: cover;
background-position: left center;
background-color: #8a7a65;
}
.hero-banner__content {
max-width: min(52%, 22rem);
}
.hero-banner__title {
font-size: 1.15rem;
max-width: none;
}
.hero-next-enter-from,
.hero-next-leave-to,
.hero-prev-enter-from,
.hero-prev-leave-to {
transform: none;
}
.quick-card {
min-height: 96px;
padding: 18px 14px;
border-radius: 16px;
}
.level-card {
padding: 20px 22px;
box-shadow: 0 3px 12px rgba(16, 24, 40, 0.07);
}
.product-row {
@@ -993,12 +1050,16 @@ async function toggleFavorite(productId: string) {
}
@media (min-width: 1200px) {
.hero-banner {
aspect-ratio: 2.2 / 1;
}
.hero-banner__title {
font-size: 1.25rem;
}
.product-row {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.hero-banner {
min-height: 240px;
}
}
</style>
+25 -17
View File
@@ -11,7 +11,7 @@ import { formatNumber } from '@/utils/format'
import { colorImageAt, colorSwatchStyle } from '@/utils/colorSwatch'
import IconChevronBack from '@/components/icons/IconChevronBack.vue'
import IconCart from '@/components/icons/IconCart.vue'
import IconCatalog from '@/components/icons/IconCatalog.vue'
import quickCatalogRequestIcon from '@/assets/images/quick-catalog-request.webp'
import IconHeart from '@/components/icons/IconHeart.vue'
import IconShare from '@/components/icons/IconShare.vue'
import IconShoppingBag from '@/components/icons/IconShoppingBag.vue'
@@ -379,7 +379,7 @@ function shareProduct() {
<p class="min-note">حداقل سفارش: {{ formatNumber(minRolls) }} طاقه</p>
</div>
<button type="button" class="btn-secondary" @click="router.push({ path: '/catalog-request', query: { productId: product.id } })">
<IconCatalog :size="18" />
<img class="btn-secondary__icon" :src="quickCatalogRequestIcon" alt="" aria-hidden="true" />
درخواست کالیته
</button>
</footer>
@@ -400,7 +400,7 @@ function shareProduct() {
--pd-card-border: 1px solid #eeeef1;
--pd-card-shadow: 0 1px 2px rgba(16, 24, 40, 0.04), 0 6px 16px rgba(16, 24, 40, 0.05);
--pd-card-radius: 16px;
background: #fff;
background: var(--color-surface);
display: flex;
flex-direction: column;
min-height: 100%;
@@ -446,7 +446,7 @@ function shareProduct() {
inset-inline-start: 12px;
background: rgba(0, 0, 0, 0.55);
color: #fff;
border-radius: 999px;
border-radius: 6px;
padding: 4px 10px;
font-size: 0.75rem;
}
@@ -457,7 +457,7 @@ function shareProduct() {
margin: 12px var(--page-padding) 0;
padding: 10px;
overflow-x: auto;
background: #fff;
background: var(--color-surface);
border: var(--pd-card-border);
border-radius: var(--pd-card-radius);
box-shadow: var(--pd-card-shadow);
@@ -470,7 +470,7 @@ function shareProduct() {
height: 52px;
border-radius: 14px;
border: var(--pd-card-border);
background: #fff;
background: var(--color-surface);
box-shadow: var(--pd-card-shadow);
cursor: pointer;
flex-shrink: 0;
@@ -499,7 +499,7 @@ function shareProduct() {
display: flex;
flex-direction: column;
gap: 18px;
background: #fff;
background: var(--color-surface);
}
.title-row {
@@ -541,7 +541,7 @@ function shareProduct() {
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 6px;
padding: 14px 8px;
background: #fff;
background: var(--color-surface);
border: var(--pd-card-border);
border-radius: var(--pd-card-radius);
box-shadow: var(--pd-card-shadow);
@@ -631,7 +631,7 @@ function shareProduct() {
.pd-section--boxed {
padding: 12px 14px;
background: #fff;
background: var(--color-surface);
border: var(--pd-card-border);
border-radius: var(--pd-card-radius);
box-shadow: var(--pd-card-shadow);
@@ -704,8 +704,8 @@ button.tag {
min-height: 32px;
padding: 0 16px;
border: var(--pd-card-border);
border-radius: var(--pd-card-radius);
background: #fff;
border-radius: 6px;
background: var(--color-surface);
color: #6b7280;
font-size: 0.8125rem;
font-weight: 400;
@@ -726,7 +726,7 @@ button.tag--link {
}
button.tag--link:hover {
background: #fff;
background: var(--color-surface);
}
.roll-field {
@@ -734,11 +734,11 @@ button.tag--link:hover {
align-items: center;
width: fit-content;
max-width: 100%;
border: 1px solid #eeeef1;
border-radius: 16px;
background: #fff;
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.04), 0 6px 16px rgba(16, 24, 40, 0.05);
padding: 14px 16px;
border: var(--pd-card-border);
border-radius: 6px;
background: var(--color-surface);
box-shadow: var(--pd-card-shadow);
padding: 0 16px;
color: #6b7280;
font-size: 0.9rem;
}
@@ -780,6 +780,14 @@ button.tag--link:hover {
color: var(--color-primary);
}
.btn-secondary__icon {
width: 18px;
height: 18px;
display: block;
object-fit: contain;
flex-shrink: 0;
}
.btn-primary {
border: none;
background: var(--color-accent-navy);
+211 -34
View File
@@ -9,8 +9,19 @@ import SearchField from '@/components/ui/SearchField.vue'
import IconFilter from '@/components/icons/IconFilter.vue'
import IconChevronLeft from '@/components/icons/IconChevronLeft.vue'
import IconHeart from '@/components/icons/IconHeart.vue'
import IconCatalog from '@/components/icons/IconCatalog.vue'
import IconDiamond from '@/components/icons/IconDiamond.vue'
import { useFavoritesStore } from '@/stores/favorites'
import { colorSwatchStyle } from '@/utils/colorSwatch'
import { categoryIconOf, usageCategoryIconKey } from '@/utils/categoryIcons'
const CATEGORY_TAGLINES: Record<string, string> = {
suit: 'شیک، بادوام و رسمی',
trousers: 'مناسب شلوار و مصارف روزانه',
shirt: 'سبک و مناسب پیراهن',
}
const GENERIC_CATEGORY_CTA = 'مشاهده محصولات'
const route = useRoute()
const router = useRouter()
@@ -43,15 +54,81 @@ const title = computed(() => {
return 'همه محصولات'
})
const bannerSubtitle = computed(() => {
if (catalogId.value) return 'انتخاب محصول از کالیته'
if (categoryId.value === 'suit') return 'شیک، بادوام و رسمی'
if (categoryId.value === 'trousers') return 'مناسب شلوار و مصارف روزانه'
if (categoryId.value === 'shirt') return 'سبک و مناسب پیراهن'
if (season.value) return 'فیلتر شده بر اساس فصل'
return 'مشاهده و ثبت سفارش'
function splitCategoryTitle(fullTitle: string) {
const trimmed = fullTitle.trim()
const prefix = 'پارچه‌های '
if (trimmed.startsWith(prefix)) {
return { lead: 'پارچه‌های', headline: trimmed.slice(prefix.length) }
}
return { lead: undefined, headline: trimmed }
}
function seasonIconKey(item: SeasonCategory) {
return item.icon || (item.id === 'summer' ? 'bolt' : item.id === 'winter' ? 'feather' : 'diamond')
}
const bannerCopy = computed(() => {
if (categoryId.value) {
const cat = crm.usageCategories.find((c) => c.id === categoryId.value)
const parts = splitCategoryTitle(cat?.title ?? title.value)
const tagline =
CATEGORY_TAGLINES[categoryId.value] ??
(cat?.subtitle && cat.subtitle !== GENERIC_CATEGORY_CTA ? cat.subtitle : undefined)
return { ...parts, tagline }
}
if (season.value) {
const item = seasons.value.find((s) => s.id === season.value)
return {
lead: undefined,
headline: item?.title ?? title.value,
tagline: item?.description || 'فیلتر شده بر اساس فصل',
}
}
if (catalogId.value) {
return {
lead: 'کالیته',
headline: catalogTitle.value || title.value,
tagline: 'انتخاب محصول از کالیته',
}
}
return {
lead: undefined,
headline: title.value,
tagline: q.value ? 'نتایج جستجو' : 'مشاهده و ثبت سفارش',
}
})
const bannerIcon = computed(() => {
if (categoryId.value) {
const cat = crm.usageCategories.find((c) => c.id === categoryId.value)
if (cat) return categoryIconOf(usageCategoryIconKey(cat), IconCatalog)
}
if (season.value) {
const item = seasons.value.find((s) => s.id === season.value)
if (item) return categoryIconOf(seasonIconKey(item), IconDiamond)
}
return null
})
const bannerImageUrl = computed(() => {
if (categoryId.value) {
const cat = crm.usageCategories.find((c) => c.id === categoryId.value)
if (cat?.imageUrl) return cat.imageUrl
}
if (season.value) {
const item = seasons.value.find((s) => s.id === season.value)
if (item?.imageUrl) return item.imageUrl
}
if (catalogId.value) {
return products.value.find((item) => item.imageUrl)?.imageUrl
}
return undefined
})
const bannerStyle = computed(() =>
bannerImageUrl.value ? { backgroundImage: `url(${bannerImageUrl.value})` } : undefined,
)
const showSearch = computed(() => !categoryId.value && !catalogId.value)
const sortedProducts = computed(() => {
@@ -75,6 +152,10 @@ function cycleSort() {
sortMode.value = sortMode.value === 'newest' ? 'name' : 'newest'
}
const sortAriaLabel = computed(() =>
sortMode.value === 'newest' ? 'مرتب‌سازی: جدیدترین' : 'مرتب‌سازی: نام',
)
function applySearch(value: string) {
searchDraft.value = value
router.replace({
@@ -168,11 +249,18 @@ function toggleFavorite(productId: string) {
<p v-if="error">{{ error }}</p>
<PageSkeleton v-else-if="loading" variant="grid" />
<template v-else>
<section class="cat-banner">
<div class="cat-banner__icon" aria-hidden="true" />
<div>
<h2>{{ title }}</h2>
<p>{{ bannerSubtitle }}</p>
<section
class="cat-banner"
:class="{ 'cat-banner--photo': !!bannerImageUrl }"
:style="bannerStyle"
>
<div v-if="bannerIcon" class="cat-banner__icon-ring" aria-hidden="true">
<component :is="bannerIcon" :size="26" color="#ffffff" />
</div>
<div class="cat-banner__copy">
<p v-if="bannerCopy.lead" class="cat-banner__lead">{{ bannerCopy.lead }}</p>
<h2 class="cat-banner__title">{{ bannerCopy.headline }}</h2>
<p v-if="bannerCopy.tagline" class="cat-banner__tagline">{{ bannerCopy.tagline }}</p>
</div>
</section>
@@ -199,8 +287,34 @@ function toggleFavorite(productId: string) {
<div class="list-meta">
<span>تعداد محصولات: {{ sortedProducts.length.toLocaleString('fa-IR') }}</span>
<button type="button" class="list-meta__sort" @click="cycleSort">
مرتب‌سازی: {{ sortMode === 'newest' ? 'جدیدترین' : 'نام' }}
<button
type="button"
class="list-meta__sort"
:aria-label="sortAriaLabel"
@click="cycleSort"
>
<span class="list-meta__sort-icon" aria-hidden="true">
<svg width="15" height="12" viewBox="0 0 15 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.5 2.5V9.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" />
<path
d="M2.5 7.5L4.5 9.5L6.5 7.5"
stroke="currentColor"
stroke-width="1.3"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path d="M10.5 9.5V2.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" />
<path
d="M8.5 4.5L10.5 2.5L12.5 4.5"
stroke="currentColor"
stroke-width="1.3"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</span>
<span class="list-meta__sort-badge">۱</span>
<span class="list-meta__sort-label">مرتب‌سازی</span>
</button>
</div>
@@ -288,35 +402,86 @@ function toggleFavorite(productId: string) {
}
.cat-banner {
position: relative;
isolation: isolate;
display: flex;
align-items: center;
gap: 12px;
min-height: 96px;
padding: 16px;
justify-content: space-between;
gap: 16px;
min-height: 112px;
padding: 18px 20px;
border-radius: var(--radius-lg);
overflow: hidden;
background:
linear-gradient(100deg, rgba(var(--color-primary-rgb), 0.75), rgba(var(--color-primary-rgb), 0.35)),
#2f3430;
background-size: cover;
background-position: center top;
background-repeat: no-repeat;
color: #fff;
}
.cat-banner__icon {
width: 48px;
height: 48px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.2);
flex-shrink: 0;
.cat-banner--photo::before {
content: '';
position: absolute;
inset: 0;
z-index: 0;
background: linear-gradient(
90deg,
rgba(0, 0, 0, 0.58) 0%,
rgba(0, 0, 0, 0.28) 42%,
rgba(0, 0, 0, 0.08) 68%,
transparent 100%
);
pointer-events: none;
}
.cat-banner h2 {
margin: 0 0 4px;
font-size: 1rem;
.cat-banner__copy {
position: relative;
z-index: 1;
min-width: 0;
flex: 1;
text-align: start;
}
.cat-banner p {
.cat-banner__lead {
margin: 0 0 2px;
font-size: 0.82rem;
font-weight: 500;
line-height: 1.4;
opacity: 0.95;
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.35);
}
.cat-banner__title {
margin: 0 0 6px;
font-size: 1.35rem;
font-weight: 700;
line-height: 1.25;
text-shadow: 0 1px 8px rgba(0, 0, 0, 0.35);
}
.cat-banner__tagline {
margin: 0;
font-size: 0.8rem;
opacity: 0.9;
font-size: 0.78rem;
font-weight: 400;
line-height: 1.5;
opacity: 0.92;
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.35);
}
.cat-banner__icon-ring {
position: relative;
z-index: 1;
width: 56px;
height: 56px;
border-radius: 999px;
border: 1.5px solid rgba(255, 255, 255, 0.88);
background: rgba(0, 0, 0, 0.16);
display: grid;
place-items: center;
flex-shrink: 0;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
}
.chips {
@@ -327,9 +492,9 @@ function toggleFavorite(productId: string) {
.chip {
border: none;
border-radius: 999px;
border-radius: var(--radius-lg);
padding: 8px 14px;
background: #ecefee;
background: var(--color-bg);
color: var(--color-text);
white-space: nowrap;
cursor: pointer;
@@ -350,15 +515,27 @@ function toggleFavorite(productId: string) {
}
.list-meta__sort {
display: inline-flex;
align-items: center;
gap: 4px;
border: none;
background: transparent;
color: var(--color-primary);
color: inherit;
font: inherit;
font-weight: 700;
cursor: pointer;
padding: 0;
}
.list-meta__sort-icon {
display: inline-flex;
flex-shrink: 0;
line-height: 0;
}
.list-meta__sort-badge {
font-variant-numeric: tabular-nums;
}
.product-item {
display: grid;
grid-template-columns: 112px 1fr;
@@ -415,7 +592,7 @@ function toggleFavorite(productId: string) {
font-size: 0.65rem;
font-weight: 700;
padding: 2px 8px;
border-radius: 999px;
border-radius: 6px;
white-space: nowrap;
}
@@ -33,7 +33,7 @@ async function open(item: AppNotification) {
</script>
<template>
<ProfileSubpageLayout title="اعلان‌ها" subtitle="آخرین خبرهای سفارش‌های شما">
<ProfileSubpageLayout title="اعلان‌ها" subtitle="سفارش‌ها، پیشنهاد محصول و سایر اعلان‌ها">
<div v-if="items.length" class="notif-box">
<div class="notif-box__head">
<span class="notif-box__count">
@@ -75,7 +75,7 @@ async function open(item: AppNotification) {
v-else
icon="vuesax-linear:notification-bing"
title="اعلانی ندارید"
message="وقتی فاکتور سفارشتان صادر شود یا وضعیت چک و پرداخت تغییر کند، همین‌جا خبردار می‌شوید."
message="وقتی فاکتور سفارشتان صادر شود، پیشنهاد محصولی برایتان ثبت شود یا وضعیت پرداخت تغییر کند، همین‌جا خبردار می‌شوید."
>
<BaseButton variant="outline" @click="router.push('/orders')">سفارش‌های من</BaseButton>
</EmptyState>
@@ -129,7 +129,7 @@ async function open(item: AppNotification) {
}
.notif-row--unread {
background: var(--color-elevated);
background: var(--color-tab-inactive-bg);
}
.notif-row__icon {
@@ -93,7 +93,7 @@ const ordersCount = computed(() => profile.value?.ordersCount ?? orderStore.orde
const salesVolume = computed(() => profile.value?.salesVolumeToman ?? 0)
const profit = computed(() => profile.value?.profitToman ?? 0)
const levelProgress = computed(
() => profile.value?.levelProgress ?? auth.user?.levelProgress ?? 72,
() => profile.value?.levelProgress ?? auth.user?.levelProgress ?? 0,
)
const unreadLabel = computed(() =>
notificationStore.unreadCount > 9 ? '9+' : String(notificationStore.unreadCount),
@@ -257,9 +257,11 @@ onMounted(async () => {
</svg>
</div>
<p class="loyalty__text">
شما در سطح {{ levelLabel }} قرار دارید. تا سطح
{{ nextLevelLabel }} فقط
{{ formatNumber(pointsToNext) }} تومان فروش دیگر نیاز دارید.
<span>شما در سطح {{ levelLabel }} قرار دارید.</span>
<span class="loyalty__text-next">
تا سطح {{ nextLevelLabel }} فقط
{{ formatNumber(pointsToNext) }} تومان فروش دیگر نیاز دارید.
</span>
</p>
</div>
<div class="loyalty__bar">
@@ -605,11 +607,16 @@ onMounted(async () => {
}
.loyalty__text {
font-weight: bold;
margin: 0;
font-size: 0.8125rem;
font-size: 0.75rem;
line-height: 1.75;
color: var(--color-text);
text-align: start;
text-align: center;
}
.loyalty__text-next {
display: block;
}
.loyalty__bar {
+1 -1
View File
@@ -288,7 +288,7 @@ const chartBars = computed(() => {
.btn-outline {
border: 1px solid var(--color-border);
background: #fff;
background: var(--color-surface);
}
.btn-primary {
File diff suppressed because it is too large Load Diff