Files
nademitex_user/src/views/profile/components/MobileProfilePage.vue
T
2026-09-13 15:36:48 +03:30

803 lines
21 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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;
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>