Files
nademitex_user/src/views/profile/components/MobileProfilePage.vue
T

803 lines
21 KiB
Vue
Raw Normal View History

<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useOrderStore } from '@/stores/order'
import { useCrmStore } from '@/stores/crm'
import { useNotificationStore } from '@/stores/notification'
import { fetchAgentProfile, type AgentProfileDto } from '@/services/agentApi'
import type { CatalogProductCard } from '@/types/crm'
import { formatDate, formatNumber } from '@/utils/format'
import { ORDER_STATUS_LABELS, STATUS_BADGE_VARIANT } from '@/utils/constants'
import IconBell from '@/components/icons/IconBell.vue'
import IconSettings from '@/components/icons/IconSettings.vue'
import IconChevronLeft from '@/components/icons/IconChevronLeft.vue'
import IconCart from '@/components/icons/IconCart.vue'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import PageSkeleton from '@/components/ui/PageSkeleton.vue'
import UserAvatar from '@/components/ui/UserAvatar.vue'
const SAMPLE_PRODUCT: CatalogProductCard = {
id: 'sample-base-price',
name: 'پارچه فاستونی ایتالیایی',
code: 'DF-1204',
categoryId: '',
season: 'all_season',
widthCm: 160,
composition: '',
colors: ['1', '2', '3', '4', '5', '6'],
tags: [],
isNew: false,
rollMeters: 0,
basePrice: 380_000,
}
const router = useRouter()
const auth = useAuthStore()
const orderStore = useOrderStore()
const crm = useCrmStore()
const notificationStore = useNotificationStore()
const profile = ref<AgentProfileDto | null>(null)
const sampleProduct = ref<CatalogProductCard>(SAMPLE_PRODUCT)
const loading = ref(false)
/** مطابق موکاپ ۱۱ — بدون gate نقش */
const quickActions = [
{ label: 'ثبت مشتری جدید', to: '/customers', icon: 'vuesax-linear:add' },
{ label: 'سفارش‌ها', to: '/orders', icon: 'vuesax-linear:document-text' },
{ label: 'مشتریان من', to: '/customers', icon: 'vuesax-linear:people' },
{ label: 'گزارش‌ها', to: '/reminders', icon: 'vuesax-linear:chart-2' },
{ label: 'درخواست کالیته', to: '/catalog-request', icon: 'vuesax-linear:category' },
{ label: 'قیمت‌گذاری', to: '/categories', icon: 'vuesax-linear:money-4' },
] as const
const recentOrders = computed(() =>
[...orderStore.orders]
.sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)))
.slice(0, 3)
.map((o) => {
const variant = STATUS_BADGE_VARIANT[o.status] ?? 'info'
const tone =
variant === 'danger' || variant === 'neutral'
? 'danger'
: variant === 'success'
? 'success'
: variant === 'warning'
? 'warning'
: 'info'
return {
id: o.id,
title: `سفارش #${o.orderNumber}`,
date: o.createdAt ? formatDate(o.createdAt) : '',
customer: o.companyName || '',
statusLabel: ORDER_STATUS_LABELS[o.status] || o.status,
statusTone: tone as 'warning' | 'success' | 'info' | 'danger',
}
}),
)
const displayName = computed(() => auth.fullName || 'کاربر')
const code = computed(() => profile.value?.code || auth.user?.agentCode || '—')
const levelLabel = computed(
() => profile.value?.levelLabel || auth.user?.levelLabel || 'برنزی',
)
const nextLevelLabel = computed(
() => profile.value?.nextLevelLabel || auth.user?.nextLevelLabel || 'نقره‌ای',
)
const pointsToNext = computed(
() => profile.value?.pointsToNext ?? auth.user?.pointsToNext ?? 0,
)
const periodLabel = computed(() => profile.value?.periodLabel || '۳۰ روز اخیر')
const customersCount = computed(() => profile.value?.customersCount ?? 0)
const ordersCount = computed(() => profile.value?.ordersCount ?? orderStore.orders.length)
const salesVolume = computed(() => profile.value?.salesVolumeToman ?? 0)
const profit = computed(() => profile.value?.profitToman ?? 0)
const levelProgress = computed(
() => profile.value?.levelProgress ?? auth.user?.levelProgress ?? 72,
)
const unreadLabel = computed(() =>
notificationStore.unreadCount > 9 ? '9+' : String(notificationStore.unreadCount),
)
const samplePrice = computed(() => sampleProduct.value.basePrice ?? SAMPLE_PRODUCT.basePrice ?? 0)
onMounted(async () => {
loading.value = true
try {
void notificationStore.hydrate()
try {
profile.value = await fetchAgentProfile()
} catch {
profile.value = null
}
try {
const products = await crm.loadBrowseProducts()
const first = products[0]
sampleProduct.value = first
? { ...SAMPLE_PRODUCT, ...first, basePrice: first.basePrice ?? SAMPLE_PRODUCT.basePrice }
: SAMPLE_PRODUCT
} catch {
sampleProduct.value = SAMPLE_PRODUCT
}
if (!orderStore.orders.length) await orderStore.hydrate()
} finally {
loading.value = false
}
})
</script>
<template>
<div class="page profile-page">
<div class="page__scroll page__scroll--with-nav profile-scroll">
<header class="hero">
<div class="hero__pin">
<div class="hero__top">
<button
type="button"
class="icon-btn"
aria-label="تنظیمات"
@click="router.push('/profile/display')"
>
<IconSettings :size="20" color="#ffffff" />
</button>
<h1>پروفایل من</h1>
<button
type="button"
class="icon-btn"
aria-label="اعلان‌ها"
@click="router.push('/profile/notifications')"
>
<IconBell :size="20" color="#ffffff" />
<span
v-if="notificationStore.hasUnread"
class="icon-btn__badge"
:aria-label="`${notificationStore.unreadCount} اعلان خوانده‌نشده`"
>
{{ unreadLabel }}
</span>
</button>
</div>
<UserAvatar
class="hero__avatar"
:src="auth.user?.avatarUrl"
:initials="displayName.charAt(0)"
:size="84"
/>
</div>
<div class="hero__meta">
<h2>{{ displayName }}</h2>
<p>کد واسطه: {{ code }}</p>
<span class="level-pill">
<svg class="level-pill__crown" viewBox="0 0 24 24" aria-hidden="true">
<path
fill="currentColor"
d="M3.5 17.5h17l-1.2-8.2a1 1 0 0 0-1.5-.7L14 11l-2.5-5.2a1 1 0 0 0-1.8 0L7.2 11 4.2 8.6a1 1 0 0 0-1.5.7L3.5 17.5Zm1.7 1.5h13.6v1.2H5.2V19Z"
/>
</svg>
سطح: {{ levelLabel }}
</span>
</div>
</header>
<div class="profile-sheet">
<PageSkeleton v-if="loading" variant="profile" />
<template v-else>
<section class="panel">
<div class="panel__head">
<h3>خلاصه عملکرد</h3>
<span class="panel__period">
{{ periodLabel }}
<BasicIcon icon="vuesax-linear:arrow-down" :size="14" :stroke-width="1.5" />
</span>
</div>
<div class="stats">
<div class="stat-card">
<div class="stat-card__text">
<span class="stat-card__label">سفارش‌ها</span>
<strong class="stat-card__value">{{ formatNumber(ordersCount) }}</strong>
<em class="stat-card__hint">سفارش</em>
</div>
<span class="stat-card__icon stat-card__icon--orders">
<IconCart :size="18" />
</span>
</div>
<div class="stat-card">
<div class="stat-card__text">
<span class="stat-card__label">مشتریان من</span>
<strong class="stat-card__value">{{ formatNumber(customersCount) }}</strong>
<em class="stat-card__hint">مشتری فعال</em>
</div>
<span class="stat-card__icon stat-card__icon--customers">
<BasicIcon icon="vuesax-linear:people" :size="18" :stroke-width="1.5" />
</span>
</div>
<div class="stat-card">
<div class="stat-card__text">
<span class="stat-card__label">سود شما (ریال)</span>
<strong class="stat-card__value">{{ formatNumber(profit) }}</strong>
<em class="stat-card__hint">تومان</em>
</div>
<span class="stat-card__icon stat-card__icon--profit">
<BasicIcon icon="vuesax-linear:chart-2" :size="18" :stroke-width="1.5" />
</span>
</div>
<div class="stat-card">
<div class="stat-card__text">
<span class="stat-card__label">حجم فروش (ریال)</span>
<strong class="stat-card__value">{{ formatNumber(salesVolume) }}</strong>
<em class="stat-card__hint">تومان</em>
</div>
<span class="stat-card__icon stat-card__icon--sales">
<BasicIcon icon="vuesax-linear:wallet" :size="18" :stroke-width="1.5" />
</span>
</div>
</div>
</section>
<section class="loyalty">
<div class="loyalty__row">
<div class="loyalty__crown" aria-hidden="true">
<svg viewBox="0 0 48 48" width="40" height="40">
<path
fill="#E0C35A"
d="M8 34h32l-2.4-16.2a2 2 0 0 0-3-.1L28 24l-4.8-10a2 2 0 0 0-3.6 0L14.8 24 9.4 17.7a2 2 0 0 0-3.1.1L8 34Z"
/>
<path fill="#C9A227" d="M10 36h28v3H10z" />
<circle cx="24" cy="28" r="3" fill="#F7E7A1" />
</svg>
</div>
<p class="loyalty__text">
شما در سطح {{ levelLabel }} قرار دارید. تا سطح
{{ nextLevelLabel }} فقط
{{ formatNumber(pointsToNext) }} تومان فروش دیگر نیاز دارید.
</p>
</div>
<div class="loyalty__bar">
<span :style="{ width: `${Math.min(100, Math.max(0, levelProgress))}%` }" />
</div>
</section>
<section>
<h3 class="section-title">دسترسی سریع</h3>
<div class="quick-grid">
<button
v-for="item in quickActions"
:key="item.label"
type="button"
class="quick-item"
@click="router.push(item.to)"
>
<span class="quick-item__icon">
<BasicIcon :icon="item.icon" :size="22" :stroke-width="1.5" />
</span>
{{ item.label }}
</button>
</div>
</section>
<section class="orders-box">
<div class="section-head">
<h3>آخرین سفارش‌ها</h3>
<button type="button" class="link" @click="router.push('/orders')">مشاهده همه</button>
</div>
<article
v-for="order in recentOrders"
:key="order.id"
class="order-row"
role="button"
tabindex="0"
@click="router.push({ path: '/order', query: { id: order.id } })"
@keydown.enter="router.push({ path: '/order', query: { id: order.id } })"
>
<div class="order-row__meta">
<span class="order-row__date">{{ order.date }}</span>
<strong class="order-row__title">{{ order.title }}</strong>
<p v-if="order.customer" class="order-row__customer">مشتری: {{ order.customer }}</p>
</div>
<span class="tone" :class="`tone--${order.statusTone}`">{{ order.statusLabel }}</span>
<IconChevronLeft :size="16" class="order-row__chevron" />
</article>
<button type="button" class="orders-all" @click="router.push('/orders')">
مشاهده همه سفارش‌ها
</button>
</section>
<section class="price-section">
<h3 class="section-title">قیمت‌های پایه (نمونه)</h3>
<div class="price-card">
<div class="price-card__body">
<div class="price-card__info">
<strong>{{ sampleProduct.name }}</strong>
<p>کد: {{ sampleProduct.code }}</p>
<p>عرض: {{ formatNumber(sampleProduct.widthCm) }} سانتی‌متر</p>
<p>رنگ‌ها: {{ formatNumber(sampleProduct.colors.length) }} رنگ</p>
</div>
<div
class="price-card__image"
:style="
sampleProduct.imageUrl
? { backgroundImage: `url(${sampleProduct.imageUrl})` }
: undefined
"
/>
</div>
<footer>قیمت پایه به شما: {{ formatNumber(samplePrice) }} تومان / متر</footer>
</div>
</section>
</template>
</div>
</div>
</div>
</template>
<style scoped>
.profile-page {
background: var(--color-primary);
}
.hero {
color: #fff;
padding: 0 0 28px;
text-align: center;
background: linear-gradient(
145deg,
var(--color-primary) 0%,
color-mix(in srgb, var(--color-primary) 78%, #0a1f18) 100%
);
}
.hero__pin {
position: sticky;
top: 0;
z-index: 3;
padding: 12px var(--page-padding) 10px;
background: linear-gradient(
145deg,
var(--color-primary) 0%,
color-mix(in srgb, var(--color-primary) 78%, #0a1f18) 100%
);
}
.hero__top {
display: grid;
grid-template-columns: 40px 1fr 40px;
align-items: center;
margin-bottom: 12px;
}
.hero__top h1 {
margin: 0;
font-size: 1rem;
font-weight: 700;
}
.icon-btn {
position: relative;
width: 40px;
height: 40px;
border: none;
background: transparent;
display: grid;
place-items: center;
cursor: pointer;
color: #fff;
}
.icon-btn__badge {
position: absolute;
top: 4px;
inset-inline-start: 4px;
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 999px;
background: #f59e0b;
color: #fff;
font-size: 0.625rem;
font-weight: 700;
line-height: 16px;
text-align: center;
}
.hero__avatar {
margin: 0 auto;
border: 3px solid rgba(255, 255, 255, 0.35);
color: #fff;
}
.hero__meta {
padding: 10px var(--page-padding) 0;
}
.hero h2 {
margin: 0;
font-size: 1.2rem;
font-weight: 700;
}
.hero p {
margin: 6px 0 12px;
opacity: 0.88;
font-size: 0.85rem;
}
.level-pill {
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid var(--color-level-gold);
border-radius: 999px;
padding: 5px 14px;
font-size: 0.8125rem;
background: color-mix(in srgb, var(--color-level-gold) 16%, transparent);
color: #fff;
}
.level-pill__crown {
width: 14px;
height: 14px;
color: var(--color-level-gold);
}
.profile-scroll {
flex: 1;
min-height: 0;
background: transparent;
padding: 0;
}
.profile-sheet {
position: relative;
z-index: 1;
margin-top: -20px;
padding: 20px var(--page-padding) 24px;
display: flex;
flex-direction: column;
gap: 18px;
background: var(--color-bg);
border-radius: 24px 24px 0 0;
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.06);
}
.profile-sheet > * {
flex-shrink: 0;
}
.panel__head,
.section-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.panel__head h3,
.section-head h3,
.section-title {
margin: 0;
font-size: 0.95rem;
font-weight: 700;
color: var(--color-text);
}
.section-title {
margin-bottom: 12px;
}
.panel__period {
display: inline-flex;
align-items: center;
gap: 2px;
color: var(--color-text-muted);
font-size: 0.8rem;
}
.link {
border: none;
background: transparent;
color: var(--color-primary);
font-weight: 600;
font-size: 0.8rem;
cursor: pointer;
font-family: inherit;
}
.stats {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.stat-card {
background: var(--color-surface);
border-radius: 14px;
padding: 12px;
box-shadow: var(--shadow-sm);
border: 1px solid var(--color-border);
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.stat-card__text {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
text-align: start;
}
.stat-card__icon {
width: 32px;
height: 32px;
border-radius: 10px;
display: grid;
place-items: center;
flex-shrink: 0;
}
.stat-card__icon--orders,
.stat-card__icon--customers {
background: color-mix(in srgb, var(--color-success) 14%, transparent);
color: var(--color-success);
}
.stat-card__icon--profit {
background: color-mix(in srgb, var(--color-info) 14%, transparent);
color: var(--color-info);
}
.stat-card__icon--sales {
background: color-mix(in srgb, var(--color-level-gold) 22%, transparent);
color: #b8860b;
}
.stat-card__label {
font-size: 0.75rem;
color: var(--color-text-muted);
}
.stat-card__value {
font-size: 1.05rem;
font-weight: 700;
color: var(--color-text);
line-height: 1.3;
}
.stat-card__hint {
font-size: 0.7rem;
font-style: normal;
color: var(--color-text-muted);
}
.loyalty {
background: var(--color-primary-soft);
border-radius: 14px;
padding: 14px;
}
.loyalty__row {
display: flex;
align-items: flex-start;
gap: 12px;
margin-bottom: 12px;
}
.loyalty__crown {
flex-shrink: 0;
width: 40px;
height: 40px;
display: grid;
place-items: center;
}
.loyalty__text {
margin: 0;
font-size: 0.8125rem;
line-height: 1.75;
color: var(--color-text);
text-align: start;
}
.loyalty__bar {
height: 8px;
border-radius: 999px;
background: rgba(var(--color-primary-rgb), 0.15);
overflow: hidden;
}
.loyalty__bar span {
display: block;
height: 100%;
background: var(--color-primary);
border-radius: inherit;
}
.quick-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
}
.quick-item {
border: 1px solid var(--color-border);
background: var(--color-surface);
border-radius: 14px;
padding: 14px 8px 12px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
font-size: 0.72rem;
color: var(--color-text);
cursor: pointer;
font-family: inherit;
text-align: center;
line-height: 1.4;
min-height: 88px;
}
.quick-item__icon {
width: 40px;
height: 40px;
color: var(--color-primary);
display: grid;
place-items: center;
}
.orders-box {
flex-shrink: 0;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 14px;
overflow: hidden;
padding: 4px 0 0;
}
.orders-box .section-head {
margin: 0;
padding: 12px 14px 10px;
}
.order-row {
display: grid;
grid-template-columns: 1fr auto 16px;
gap: 8px;
align-items: center;
background: transparent;
border: none;
border-top: 1px solid var(--color-border);
border-radius: 0;
padding: 12px 14px;
margin: 0;
cursor: pointer;
}
.orders-all {
width: 100%;
margin: 0;
padding: 12px 14px;
border: none;
border-top: 1px solid var(--color-border);
border-radius: 0;
background: transparent;
color: var(--color-primary);
font: inherit;
font-weight: 600;
font-size: 0.875rem;
cursor: pointer;
}
.order-row__meta {
min-width: 0;
text-align: start;
}
.order-row__date {
display: block;
font-size: 0.75rem;
color: var(--color-text-muted);
margin-bottom: 2px;
}
.order-row__title {
display: block;
font-size: 0.875rem;
}
.order-row__customer {
margin: 2px 0 0;
color: var(--color-text-muted);
font-size: 0.75rem;
}
.order-row__chevron {
color: var(--color-text-muted);
}
.tone {
border-radius: 999px;
padding: 4px 10px;
font-size: 0.7rem;
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);
}
.tone--danger {
background: var(--color-danger-light);
color: var(--color-danger);
}
.price-section {
flex-shrink: 0;
}
.price-section .section-title {
margin-bottom: 10px;
}
.price-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 14px;
overflow: hidden;
}
.price-card__body {
display: grid;
grid-template-columns: 1fr 88px;
gap: 12px;
padding: 12px;
align-items: start;
}
.price-card__image {
border-radius: 12px;
2026-09-13 15:36:48 +03:30
background: linear-gradient(145deg, var(--color-primary), var(--color-primary-dark));
background-size: cover;
background-position: center;
min-height: 88px;
}
.price-card__info {
text-align: start;
}
.price-card__info strong {
display: block;
font-size: 0.9rem;
margin-bottom: 4px;
}
.price-card__info p {
margin: 2px 0 0;
color: var(--color-text-muted);
font-size: 0.75rem;
}
.price-card footer {
border-top: 1px solid var(--color-border);
padding: 10px 12px;
font-size: 0.8rem;
font-weight: 600;
background: var(--color-elevated);
text-align: start;
}
</style>