change UI & fix bugs

This commit is contained in:
2026-09-14 16:12:53 +03:30
parent 6944788b10
commit 2b7746be75
202 changed files with 1040 additions and 418 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

+5
View File
@@ -442,6 +442,11 @@ input[type='number'] {
display: none !important;
}
.desktop-only-flex,
.desktop-only-grid {
display: none !important;
}
.mobile-hidden {
display: block;
}
+4 -4
View File
@@ -2,7 +2,7 @@
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useCartStore } from '@/stores/cart'
import { MAIN_NAV_ITEMS, isNavItemActive } from '@/utils/navigation'
import { MAIN_NAV_ITEMS, isNavItemActive, resolveCartNavPath } from '@/utils/navigation'
const route = useRoute()
const router = useRouter()
@@ -14,9 +14,9 @@ function navigate(path: string, event: Event) {
const target = event.currentTarget
if (target instanceof HTMLElement) target.blur()
// «ثبت سفارش»: سبد خالی → دسته‌بندی‌ها؛ در غیر این صورت سبد خرید
if (path === '/cart' && cartStore.itemCount === 0) {
router.push('/categories')
// «ثبت سفارش»: سبد خالی → کالیته‌ها؛ در غیر این صورت سبد خرید
if (path === '/cart') {
router.push(resolveCartNavPath(cartStore.itemCount))
return
}
router.push(path)
+1 -1
View File
@@ -45,7 +45,7 @@ function goProfile() {
function logout() {
showUserMenu.value = false
authStore.logout()
router.replace('/login')
router.replace('/welcome')
}
</script>
+5 -6
View File
@@ -2,10 +2,10 @@
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useCartStore } from '@/stores/cart'
import { useOrderStore } from '@/stores/order'
import {
DESKTOP_NAV_ITEMS,
isNavItemActive,
resolveCartNavPath,
resolveNavPath,
} from '@/utils/navigation'
import IconHeadset from '@/components/icons/IconHeadset.vue'
@@ -14,15 +14,17 @@ import IconQemashMark from '@/components/icons/IconQemashMark.vue'
const route = useRoute()
const router = useRouter()
const cartStore = useCartStore()
const orderStore = useOrderStore()
const cartCount = computed(() => cartStore.itemCount)
const draftCheckCount = computed(() => orderStore.draftChecks.length)
const activeCompanyId = computed(
() => cartStore.selectedCompanyId || cartStore.activeCart?.companyId || null,
)
function navigate(item: (typeof DESKTOP_NAV_ITEMS)[number]) {
if (item.name === 'cart') {
router.push(resolveCartNavPath(cartStore.itemCount))
return
}
router.push(resolveNavPath(item, activeCompanyId.value))
}
</script>
@@ -53,9 +55,6 @@ function navigate(item: (typeof DESKTOP_NAV_ITEMS)[number]) {
<span v-if="item.name === 'cart' && cartCount > 0" class="desktop-sidebar__badge">
{{ cartCount }}
</span>
<span v-if="item.name === 'checks' && draftCheckCount > 0" class="desktop-sidebar__badge">
{{ draftCheckCount }}
</span>
</button>
</nav>
+4 -1
View File
@@ -250,7 +250,10 @@ router.beforeEach((to) => {
const authed = isAuthenticated()
if (to.meta.requiresAuth && !authed) {
return { name: 'login', query: { redirect: to.fullPath } }
if (to.name === 'home' || to.path === '/') {
return { name: 'welcome' }
}
return { name: 'welcome', query: { redirect: to.fullPath } }
}
if (to.meta.guest && authed) {
+36 -4
View File
@@ -38,6 +38,7 @@ export type BrowseProductDto = {
weightGrams?: number
weaveType?: string
colors?: string[]
colorImages?: Array<string | null>
tags?: string[]
isNew?: boolean
subtitle?: string
@@ -63,14 +64,42 @@ async function agentRequest<T>(path: string, options?: Parameters<typeof apiRequ
export async function fetchCatalogCategories() {
try {
return await apiRequest<{
usage: Array<{ id: string; title: string; subtitle: string; imageUrl?: string }>
seasons: Array<{ id: string; title: string; description: string; imageUrl?: string }>
usage: Array<{
id: string
title: string
subtitle: string
imageUrl?: string
showTitle?: boolean
showSubtitle?: boolean
}>
seasons: Array<{
id: string
title: string
description: string
imageUrl?: string
showTitle?: boolean
showDescription?: boolean
}>
}>('/catalog/categories')
} catch {
try {
return await apiRequest<{
usage: Array<{ id: string; title: string; subtitle: string; imageUrl?: string }>
seasons: Array<{ id: string; title: string; description: string; imageUrl?: string }>
usage: Array<{
id: string
title: string
subtitle: string
imageUrl?: string
showTitle?: boolean
showSubtitle?: boolean
}>
seasons: Array<{
id: string
title: string
description: string
imageUrl?: string
showTitle?: boolean
showDescription?: boolean
}>
}>('/products/meta/categories')
} catch {
return getCatalogCategories()
@@ -103,6 +132,9 @@ export type AppBannerDto = {
ctaLabel: string
ctaLink: string
imageUrl: string
showTitle?: boolean
showSubtitle?: boolean
showCta?: boolean
}
export async function fetchBanners(slot?: string) {
+5 -1
View File
@@ -38,6 +38,7 @@ function mapProduct(p: {
weightGrams?: number
weaveType?: string
colors?: string[]
colorImages?: Array<string | null>
tags?: string[]
isNew?: boolean
subtitle?: string
@@ -56,9 +57,11 @@ function mapProduct(p: {
weightGrams: p.weightGrams,
weaveType: p.weaveType,
colors: p.colors?.length ? p.colors : ['#6b7280'],
colorImages: p.colorImages,
tags: p.tags ?? [],
isNew: !!p.isNew,
imageUrl: p.imageUrl || p.imageUrls?.[0],
imageUrl: p.imageUrl || p.colorImages?.find(Boolean) || p.imageUrls?.[0],
imageUrls: p.imageUrls,
subtitle: p.subtitle,
rollMeters: p.rollMeters ?? 50,
companyId: p.companyId,
@@ -121,6 +124,7 @@ export const useCrmStore = defineStore('crm', () => {
weightGrams?: number
weaveType?: string
colors?: string[]
colorImages?: Array<string | null>
tags?: string[]
isNew?: boolean
subtitle?: string
+12
View File
@@ -63,6 +63,10 @@ export interface ProductCategory {
title: string
subtitle: string
imageUrl?: string
icon?: string
showTitle?: boolean
showSubtitle?: boolean
showIcon?: boolean
}
export interface SeasonCategory {
@@ -70,6 +74,12 @@ export interface SeasonCategory {
title: string
description: string
imageUrl?: string
ctaLabel?: string
icon?: string
showTitle?: boolean
showDescription?: boolean
showCta?: boolean
showIcon?: boolean
}
export interface CatalogProductCard {
@@ -83,6 +93,8 @@ export interface CatalogProductCard {
weightGrams?: number
weaveType?: string
colors: string[]
colorImages?: Array<string | null>
imageUrls?: string[]
tags: string[]
isNew: boolean
imageUrl?: string
+10 -1
View File
@@ -25,6 +25,7 @@ export interface CatalogPatternCard {
imageUrl?: string
colorCount: number
colors: string[]
colorImages: Array<string | undefined>
kind: PatternKind | 'other'
items: CatalogProductCard[]
}
@@ -87,12 +88,20 @@ export function groupCatalogPatterns(products: CatalogProductCard[]): CatalogPat
),
),
]
const colorImages = colors.map((name) => {
for (const item of items) {
const idx = item.colors.indexOf(name)
if (idx >= 0 && item.colorImages?.[idx]) return item.colorImages[idx] ?? undefined
}
return undefined
})
return {
id: items[0].id,
name: items[0].name,
imageUrl: items.find((item) => item.imageUrl)?.imageUrl,
imageUrl: colorImages.find(Boolean) || items.find((item) => item.imageUrl)?.imageUrl,
colorCount: Math.max(colors.length, items.length),
colors: colors.slice(0, 5),
colorImages: colorImages.slice(0, 5),
kind: patternKindOf(items[0]),
items,
}
+26
View File
@@ -0,0 +1,26 @@
import type { Component } from 'vue'
import IconBolt from '@/components/icons/IconBolt.vue'
import IconFeather from '@/components/icons/IconFeather.vue'
import IconDiamond from '@/components/icons/IconDiamond.vue'
import IconLayoutGrid from '@/components/icons/IconLayoutGrid.vue'
import IconShoppingBag from '@/components/icons/IconShoppingBag.vue'
import IconIdCard from '@/components/icons/IconIdCard.vue'
import IconCatalog from '@/components/icons/IconCatalog.vue'
import IconHeadset from '@/components/icons/IconHeadset.vue'
import IconHome from '@/components/icons/IconHome.vue'
export const CATEGORY_ICON_MAP: Record<string, Component> = {
bolt: IconBolt,
feather: IconFeather,
diamond: IconDiamond,
grid: IconLayoutGrid,
bag: IconShoppingBag,
'id-card': IconIdCard,
catalog: IconCatalog,
headset: IconHeadset,
home: IconHome,
}
export function categoryIconOf(icon?: string, fallback: Component = IconDiamond) {
return (icon && CATEGORY_ICON_MAP[icon]) || fallback
}
+34
View File
@@ -0,0 +1,34 @@
export function isCssColorToken(value: string) {
const token = value.trim()
if (!token || /[\u0600-\u06FF]/.test(token)) return false
return (
token.startsWith('#') ||
/^(rgb|hsl)a?\(/i.test(token) ||
/^(transparent|currentcolor|black|white|navy|beige|gray|grey|red|blue|green|brown|gold|silver|ivory|khaki|olive|teal|maroon|purple|orange|pink|cyan|magenta|lime|aqua|fuchsia|indigo|violet|tan|wheat|snow)$/i.test(
token,
)
)
}
export function colorSwatchStyle(color: string, imageUrl?: string | null) {
if (imageUrl) {
return {
backgroundImage: `url(${imageUrl})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
}
}
if (isCssColorToken(color)) return { background: color }
return { background: '#c5cdc9' }
}
export function colorImageAt(
colors: string[],
colorImages: Array<string | null | undefined> | undefined,
imageUrls: string[] | undefined,
index: number,
fallback?: string,
) {
return colorImages?.[index] || imageUrls?.[index] || fallback || ''
}
+16 -22
View File
@@ -4,7 +4,6 @@ import IconCart from '@/components/icons/IconCart.vue'
import IconOrders from '@/components/icons/IconOrders.vue'
import IconProfile from '@/components/icons/IconProfile.vue'
import IconCatalog from '@/components/icons/IconCatalog.vue'
import IconCheckBook from '@/components/icons/IconCheckBook.vue'
import IconPlus from '@/components/icons/IconPlus.vue'
export interface NavItem {
@@ -15,7 +14,7 @@ export interface NavItem {
dynamicPath?: boolean
}
/** Mobile bottom navigation — mockup shell with center FAB */
/** Mobile bottom navigation — center FAB for سفارش */
export const MAIN_NAV_ITEMS: NavItem[] = [
{ name: 'home', path: '/', label: 'خانه', icon: IconHome },
{ name: 'catalogs', path: '/catalogs', label: 'کالیته‌ها', icon: IconCatalog },
@@ -24,13 +23,15 @@ export const MAIN_NAV_ITEMS: NavItem[] = [
{ name: 'profile', path: '/profile', label: 'پروفایل', icon: IconProfile },
]
/** Desktop sidebar — matches mockup */
/**
* Desktop sidebar — same destinations as mobile.
* FAB becomes a normal «سبد / ثبت سفارش» item (better for mouse UI).
*/
export const DESKTOP_NAV_ITEMS: NavItem[] = [
{ name: 'home', path: '/', label: 'خانه', icon: IconHome },
{ name: 'ordering', path: '/', label: 'سفارش‌گذاری', icon: IconCatalog, dynamicPath: true },
{ name: 'cart', path: '/cart', label: 'سبد خرید', icon: IconCart },
{ name: 'catalogs', path: '/catalogs', label: 'کالیته‌ها', icon: IconCatalog },
{ name: 'cart', path: '/cart', label: 'ثبت سفارش', icon: IconCart },
{ name: 'orders', path: '/orders', label: 'سفارش‌ها', icon: IconOrders },
{ name: 'checks', path: '/profile/checks', label: 'لیست چک‌ها', icon: IconCheckBook },
{ name: 'profile', path: '/profile', label: 'پروفایل', icon: IconProfile },
]
@@ -51,14 +52,6 @@ const CATALOG_PATHS = [
]
export function isNavItemActive(name: string, path: string, currentPath: string): boolean {
if (name === 'ordering') {
return (
currentPath.startsWith('/categories') ||
currentPath.startsWith('/products') ||
currentPath.startsWith('/product') ||
currentPath.startsWith('/catalogs')
)
}
if (name === 'home') {
return currentPath === '/'
}
@@ -67,24 +60,25 @@ export function isNavItemActive(name: string, path: string, currentPath: string)
(p) => currentPath === p || currentPath.startsWith(`${p}/`),
)
}
if (name === 'order-fab') {
if (name === 'order-fab' || name === 'cart') {
return currentPath === '/cart' || currentPath.startsWith('/cart/')
}
if (name === 'checks') {
return currentPath.startsWith('/profile/checks')
}
if (name === 'profile') {
return currentPath === '/profile'
return currentPath === '/profile' || currentPath.startsWith('/profile/')
}
if (path === '/profile') return currentPath === '/profile'
if (path === '/') return currentPath === '/'
return currentPath.startsWith(path)
}
export function resolveNavPath(
item: NavItem,
activeCompanyId: string | null | undefined,
_activeCompanyId: string | null | undefined,
): string {
if (item.name === 'ordering') return getOrderingPath(activeCompanyId)
return item.path
}
/** Empty cart: mirror mobile FAB → browse catalogs */
export function resolveCartNavPath(cartItemCount: number): string {
if (cartItemCount === 0) return '/catalogs'
return '/cart'
}
+1 -1
View File
@@ -88,7 +88,7 @@ const desktopFilterTabs: { id: DesktopOrderFilterId; label: string }[] = [
@set-desktop-filter="setDesktopFilter"
@select="goToOrder"
@set-page="setPage"
@new-order="router.push('/categories')"
@new-order="router.push('/catalogs')"
/>
</div>
</template>
+1 -1
View File
@@ -24,7 +24,7 @@ const {
} = useCartPage()
function goToOrdering() {
router.push('/categories')
router.push('/catalogs')
}
</script>
+13 -14
View File
@@ -3,7 +3,6 @@ import { useRouter } from 'vue-router'
import CheckoutStepper from '@/components/layout/CheckoutStepper.vue'
import IconCart from '@/components/icons/IconCart.vue'
import IconTrash from '@/components/icons/IconTrash.vue'
import IconBuilding from '@/components/icons/IconBuilding.vue'
import IconChevronLeft from '@/components/icons/IconChevronLeft.vue'
import IconOrders from '@/components/icons/IconOrders.vue'
import IconClipboardEmpty from '@/components/icons/IconClipboardEmpty.vue'
@@ -41,18 +40,18 @@ const router = useRouter()
const quickLinks = [
{
title: 'انتخاب شرکت',
desc: 'شروع سفارش از انتخاب شرکت',
icon: IconBuilding,
title: 'کالیته‌ها',
desc: 'مرور کالیته و طرح‌ها',
icon: IconLayoutGrid,
tone: 'blue',
action: () => emit('selectCompany'),
action: () => router.push('/catalogs'),
},
{
title: 'سفارش‌گذاری',
desc: 'مشاهده کاتالوگ و افزودن کالا',
icon: IconCart,
title: 'دسته‌بندی',
desc: 'مشاهده محصولات بر اساس کاربرد',
icon: IconDiamond,
tone: 'green',
action: () => emit('back'),
action: () => router.push('/categories'),
},
{
title: 'سفارش‌های من',
@@ -92,12 +91,12 @@ const quickLinks = [
</p>
<div class="cart-empty-card__actions">
<button type="button" class="cart-btn cart-btn--primary" @click="emit('selectCompany')">
<IconBuilding :size="18" />
مشاهده محصولات
<IconLayoutGrid :size="18" />
مشاهده کالیته‌ها
</button>
<button type="button" class="cart-btn cart-btn--outline" @click="emit('back')">
<IconChevronLeft :size="16" />
بازگشت به سفارش‌گذاری
<button type="button" class="cart-btn cart-btn--outline" @click="router.push('/categories')">
<IconDiamond :size="16" />
دسته‌بندی محصولات
</button>
</div>
</div>
+4 -1
View File
@@ -22,6 +22,7 @@ import {
type PatternKind,
} from '@/utils/catalogGallery'
import { formatNumber } from '@/utils/format'
import { colorSwatchStyle } from '@/utils/colorSwatch'
import { demoProductsFor, findMockCalite } from '@/data/caliteCollections'
const route = useRoute()
@@ -375,7 +376,7 @@ async function addAllToInquiry() {
<i
v-for="(color, idx) in pattern.colors"
:key="idx"
:style="{ background: color }"
:style="colorSwatchStyle(color, pattern.colorImages[idx])"
/>
</div>
</div>
@@ -709,6 +710,8 @@ async function addAllToInquiry() {
height: 10px;
border-radius: 999px;
border: 1px solid rgba(0, 0, 0, 0.08);
background-size: cover;
background-position: center;
}
.inquiry-bar {
+59 -54
View File
@@ -43,25 +43,30 @@ function getProductCount(catalogId: string) {
</script>
<template>
<!-- Mobile -->
<div class="page catalog-mobile-page mobile-only">
<div class="page catalog-list-page">
<PageHeader
class="mobile-only"
:title="company?.name ?? 'کالیته‌ها'"
subtitle="کالیته مورد نظر را انتخاب کنید"
show-back
centered
@back="router.push('/')"
@back="router.push('/companies')"
/>
<header class="catalog-list-page__desk-head desktop-only">
<h1>{{ company?.name ?? 'کالیته‌ها' }}</h1>
<p>کالیته مورد نظر را انتخاب کنید</p>
</header>
<SearchField
v-model="search"
placeholder="جستجو بر اساس کد یا نام کالیته..."
class="catalog-page__search"
class="catalog-list-page__search"
/>
<div class="page__scroll page__scroll--with-nav catalog-mobile-scroll">
<div class="page__scroll page__scroll--with-nav catalog-list-page__scroll">
<PageSkeleton v-if="loading" variant="cards" :rows="5" />
<div v-else-if="catalogs.length > 0" class="catalog-mobile-list">
<div v-else-if="catalogs.length > 0" class="catalog-list">
<button
v-for="catalog in catalogs"
:key="catalog.id"
@@ -84,64 +89,43 @@ function getProductCount(catalogId: string) {
/>
</div>
</div>
<!-- Desktop -->
<div class="desktop-page catalog-desktop-page desktop-only">
<header class="desktop-page__head">
<h1 class="desktop-page__title">{{ company?.name ?? 'کالیته‌ها' }}</h1>
<p class="desktop-page__subtitle">کالیته مورد نظر را انتخاب کنید</p>
</header>
<SearchField
v-model="search"
placeholder="جستجو بر اساس کد یا نام کالیته..."
class="catalog-page__search"
/>
<div v-if="catalogs.length > 0" class="desktop-grid-3">
<button
v-for="catalog in catalogs"
:key="catalog.id"
type="button"
class="catalog-card catalog-card--desktop"
@click="openCatalog(catalog.id)"
>
<span class="catalog-card__icon"><IconCatalog :size="24" /></span>
<div class="catalog-card__info">
<h3>کالیته کد {{ catalog.code }}</h3>
<p>{{ catalog.name }}</p>
</div>
<span class="catalog-card__count">{{ getProductCount(catalog.id) }} محصول</span>
</button>
</div>
<EmptyState
v-else
:variant="search ? 'search' : 'catalogs'"
:message="search ? 'با این جستجو کالیته‌ای پیدا نشد' : 'برای این شرکت هنوز کالیته‌ای ثبت نشده است'"
/>
</div>
</template>
<style scoped>
.catalog-mobile-page {
.catalog-list-page {
background: var(--color-bg);
color: var(--color-text);
padding: 12px 0 0;
}
.catalog-page__search {
.catalog-list-page__desk-head {
margin-bottom: 16px;
}
.catalog-mobile-scroll {
.catalog-list-page__desk-head h1 {
margin: 0 0 6px;
font-size: 1.375rem;
font-weight: 700;
}
.catalog-list-page__desk-head p {
margin: 0;
font-size: 0.875rem;
color: var(--color-text-muted);
}
.catalog-list-page__search {
margin-bottom: 16px;
}
.catalog-list-page__scroll {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
.catalog-mobile-list {
.catalog-list {
display: flex;
flex-direction: column;
gap: 12px;
@@ -160,13 +144,7 @@ function getProductCount(catalogId: string) {
color: var(--color-text);
text-align: right;
cursor: pointer;
}
.catalog-card--desktop {
flex-direction: column;
align-items: flex-start;
min-height: 140px;
padding: 20px;
font-family: inherit;
}
.catalog-card:hover {
@@ -183,10 +161,12 @@ function getProductCount(catalogId: string) {
background: var(--color-primary-light);
border-radius: var(--radius-sm);
color: var(--color-primary);
flex-shrink: 0;
}
.catalog-card__info {
flex: 1;
min-width: 0;
}
.catalog-card__info h3 {
@@ -205,5 +185,30 @@ function getProductCount(catalogId: string) {
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-primary);
flex-shrink: 0;
}
@media (min-width: 768px) {
.catalog-list-page {
background: transparent;
padding: 8px 0 28px;
}
.catalog-list-page__scroll {
overflow: visible;
}
.catalog-list {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
}
.catalog-card {
flex-direction: column;
align-items: flex-start;
min-height: 148px;
padding: 20px;
}
}
</style>
+109 -61
View File
@@ -7,14 +7,10 @@ import IconBell from '@/components/icons/IconBell.vue'
import IconSearch from '@/components/icons/IconSearch.vue'
import IconChevronBack from '@/components/icons/IconChevronBack.vue'
import IconChevronLeft from '@/components/icons/IconChevronLeft.vue'
import IconShoppingBag from '@/components/icons/IconShoppingBag.vue'
import IconLayoutGrid from '@/components/icons/IconLayoutGrid.vue'
import IconIdCard from '@/components/icons/IconIdCard.vue'
import IconHeadset from '@/components/icons/IconHeadset.vue'
import IconDiamond from '@/components/icons/IconDiamond.vue'
import IconCatalog from '@/components/icons/IconCatalog.vue'
import IconBolt from '@/components/icons/IconBolt.vue'
import IconFeather from '@/components/icons/IconFeather.vue'
import { categoryIconOf } from '@/utils/categoryIcons'
import { useGoBack } from '@/composables/useGoBack'
import AuthThemeToggle from '@/components/ui/AuthThemeToggle.vue'
import PageSkeleton from '@/components/ui/PageSkeleton.vue'
@@ -27,16 +23,12 @@ const seasonCategories = ref<SeasonCategory[]>([])
const loading = ref(true)
const error = ref<string | null>(null)
const usageIcons: Record<string, unknown> = {
shirt: IconIdCard,
suit: IconShoppingBag,
trousers: IconLayoutGrid,
function usageIconKey(item: ProductCategory) {
return item.icon || (item.id === 'shirt' ? 'id-card' : item.id === 'suit' ? 'bag' : 'grid')
}
const seasonIcons: Record<string, unknown> = {
summer: IconBolt,
winter: IconFeather,
all_season: IconDiamond,
function seasonIconKey(item: SeasonCategory) {
return item.icon || (item.id === 'summer' ? 'bolt' : item.id === 'winter' ? 'feather' : 'diamond')
}
onMounted(async () => {
@@ -98,14 +90,14 @@ function openSeason(id: string) {
:class="`usage-card__image--${item.id}`"
:style="item.imageUrl ? { backgroundImage: `url(${item.imageUrl})` } : undefined"
>
<span class="usage-card__icon">
<component :is="usageIcons[item.id] || IconLayoutGrid" :size="18" />
<span v-if="item.showIcon !== false" class="usage-card__icon">
<component :is="categoryIconOf(usageIconKey(item), IconCatalog)" :size="18" />
</span>
</div>
<div class="usage-card__body">
<strong>{{ item.title }}</strong>
<span class="usage-card__link">
مشاهده محصولات
<strong v-if="item.showTitle !== false">{{ item.title }}</strong>
<span v-if="item.showSubtitle !== false" class="usage-card__link">
{{ item.subtitle || 'مشاهده محصولات' }}
<IconChevronLeft :size="14" />
</span>
</div>
@@ -122,26 +114,26 @@ function openSeason(id: string) {
:key="item.id"
type="button"
class="season-card"
:class="`season-card--${item.id}`"
:style="item.imageUrl ? { backgroundImage: `url(${item.imageUrl})` } : undefined"
@click="openSeason(item.id)"
>
<div class="season-card__body">
<div class="season-card__title-row">
<span class="season-card__icon">
<component :is="seasonIcons[item.id] || IconDiamond" :size="16" />
</span>
<strong>{{ item.title }}</strong>
<div class="season-card__content">
<div
v-if="item.showTitle !== false || item.showIcon !== false"
class="season-card__title-row"
>
<span v-if="item.showIcon !== false" class="season-card__icon">
<component :is="categoryIconOf(seasonIconKey(item), IconDiamond)" :size="16" />
</span>
<strong v-if="item.showTitle !== false">{{ item.title }}</strong>
</div>
<p>{{ item.description }}</p>
<span class="usage-card__link">
مشاهده محصولات
<p v-if="item.showDescription !== false">{{ item.description }}</p>
<span v-if="item.showCta !== false" class="season-card__link">
{{ item.ctaLabel || 'مشاهده محصولات' }}
<IconChevronLeft :size="14" />
</span>
</div>
<div
class="season-card__image"
:class="`season-card__image--${item.id}`"
:style="item.imageUrl ? { backgroundImage: `url(${item.imageUrl})` } : undefined"
/>
</button>
</div>
</section>
@@ -251,6 +243,13 @@ function openSeason(id: string) {
}
.usage-card {
display: flex;
flex-direction: column;
align-items: stretch;
width: 100%;
height: 100%;
appearance: none;
-webkit-appearance: none;
border: none;
background: var(--color-surface);
border-radius: var(--radius-md);
@@ -258,25 +257,36 @@ function openSeason(id: string) {
text-align: start;
cursor: pointer;
padding: 0;
margin: 0;
line-height: normal;
font-family: inherit;
box-shadow: var(--shadow-sm);
}
.usage-card__image {
position: relative;
height: 96px;
background: linear-gradient(140deg, #6b7280, #1f2937) center / cover no-repeat;
width: 100%;
aspect-ratio: 4 / 3;
height: auto;
flex: 0 0 auto;
margin: 0;
background-color: #e8eceb;
background-image: linear-gradient(140deg, #6b7280, #1f2937);
background-size: cover;
background-position: center;
background-repeat: no-repeat;
}
.usage-card__image--suit {
background: linear-gradient(150deg, #2c3038, #111318) center / cover no-repeat;
background-image: linear-gradient(150deg, #2c3038, #111318);
}
.usage-card__image--shirt {
background: linear-gradient(150deg, #e8eef3, #9aa8b5) center / cover no-repeat;
background-image: linear-gradient(150deg, #e8eef3, #9aa8b5);
}
.usage-card__image--trousers {
background: linear-gradient(150deg, #1a1d24, #0b0d12) center / cover no-repeat;
background-image: linear-gradient(150deg, #1a1d24, #0b0d12);
}
.usage-card__icon {
@@ -299,6 +309,7 @@ function openSeason(id: string) {
display: flex;
flex-direction: column;
gap: 6px;
flex: 1 1 auto;
}
.usage-card__body strong {
@@ -310,7 +321,7 @@ function openSeason(id: string) {
display: inline-flex;
align-items: center;
gap: 2px;
color: var(--color-info);
color: var(--color-text-muted);
font-size: 0.65rem;
}
@@ -321,67 +332,103 @@ function openSeason(id: string) {
}
.season-card {
display: grid;
grid-template-columns: 1fr 42%;
position: relative;
isolation: isolate;
display: flex;
align-items: center;
justify-content: flex-start;
min-height: 124px;
width: 100%;
appearance: none;
-webkit-appearance: none;
border: none;
background: var(--color-surface);
border-radius: var(--radius-lg);
overflow: hidden;
text-align: start;
cursor: pointer;
padding: 0;
padding: 16px 18px;
margin: 0;
font-family: inherit;
box-shadow: var(--shadow-sm);
background-color: #d6c3a8;
background-image: linear-gradient(160deg, #d6c3a8, #8b7355);
background-size: cover;
background-position: center left;
background-repeat: no-repeat;
}
.season-card__image {
min-height: 124px;
background: linear-gradient(160deg, #d6c3a8, #8b7355) center / cover no-repeat;
.season-card--summer {
background-image: linear-gradient(160deg, #f3e6c8, #c4a574);
}
.season-card__image--summer {
background: linear-gradient(160deg, #f3e6c8, #c4a574) center / cover no-repeat;
.season-card--winter {
background-image: linear-gradient(160deg, #cfd6de, #7d8b99);
}
.season-card__image--winter {
background: linear-gradient(160deg, #cfd6de, #7d8b99) center / cover no-repeat;
.season-card--all_season {
background-image: linear-gradient(160deg, #efe6d6, #b9a48a);
}
.season-card__image--all_season {
background: linear-gradient(160deg, #efe6d6, #b9a48a) center / cover no-repeat;
.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__body {
padding: 12px;
.season-card__content {
position: relative;
z-index: 1;
max-width: 58%;
}
.season-card__body strong {
.season-card__content strong {
display: block;
margin-bottom: 0;
font-size: 1.05rem;
font-weight: 700;
color: var(--color-text);
}
.season-card__title-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
margin-bottom: 6px;
}
.season-card__icon {
width: 28px;
height: 28px;
border-radius: 999px;
background: var(--color-primary-soft);
background: color-mix(in srgb, var(--color-surface) 80%, transparent);
color: var(--color-primary);
display: grid;
place-items: center;
flex-shrink: 0;
}
.season-card__body p {
margin: 0 0 8px;
.season-card__content p {
margin: 0 0 10px;
color: var(--color-text-muted);
font-size: 0.75rem;
line-height: 1.6;
line-height: 1.7;
}
.season-card__link {
display: inline-flex;
align-items: center;
gap: 2px;
color: var(--color-text-muted);
font-size: 0.72rem;
font-weight: 600;
}
.feature-row {
@@ -396,7 +443,8 @@ function openSeason(id: string) {
align-items: center;
gap: 8px;
min-width: 0;
background: #f3f4f6;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 12px;
padding: 10px 8px;
}
+9 -21
View File
@@ -6,7 +6,6 @@ import { useCartStore } from '@/stores/cart'
import IconParLogo from '@/components/icons/IconParLogo.vue'
import IconFamBaftLogo from '@/components/icons/IconFamBaftLogo.vue'
import MobileCompanySelect from '@/views/companies/components/MobileCompanySelect.vue'
import DesktopHomeDashboard from '@/views/companies/components/DesktopHomeDashboard.vue'
import type { Company } from '@/types'
import { useGoBack } from '@/composables/useGoBack'
import PageSkeleton from '@/components/ui/PageSkeleton.vue'
@@ -54,24 +53,13 @@ function companyLogo(slug: string) {
<div v-if="loading" class="page page__scroll">
<PageSkeleton variant="cards" />
</div>
<template v-else>
<MobileCompanySelect
class="mobile-only"
:companies="companies"
:card-variant="cardVariant"
:company-description="companyDescription"
:company-logo="companyLogo"
@select="selectCompany"
@back="goBack"
/>
<DesktopHomeDashboard
class="desktop-only"
:companies="companies"
:card-variant="cardVariant"
:company-description="companyDescription"
:company-logo="companyLogo"
@select="selectCompany"
/>
</template>
<MobileCompanySelect
v-else
:companies="companies"
:card-variant="cardVariant"
:company-description="companyDescription"
:company-logo="companyLogo"
@select="selectCompany"
@back="goBack"
/>
</template>
@@ -2,6 +2,7 @@
import type { Company } from '@/types'
import type { Component } from 'vue'
import IconChevronBack from '@/components/icons/IconChevronBack.vue'
import IconChevronLeft from '@/components/icons/IconChevronLeft.vue'
defineProps<{
companies: Company[]
@@ -17,19 +18,24 @@ defineEmits<{
</script>
<template>
<div class="company-mobile-page">
<header class="company-mobile-page__header">
<div class="company-mobile-page__nav">
<div class="company-select-page">
<header class="company-select-page__header mobile-only">
<div class="company-select-page__nav">
<button type="button" class="nav-btn" aria-label="بازگشت" @click="$emit('back')">
<IconChevronBack :size="20" />
</button>
<h1 class="company-mobile-page__title">انتخاب شرکت</h1>
<h1 class="company-select-page__title">انتخاب شرکت</h1>
<span class="nav-btn nav-btn--spacer" aria-hidden="true" />
</div>
<p class="company-mobile-page__subtitle">شرکت مورد نظر خود را انتخاب کنید</p>
<p class="company-select-page__subtitle">شرکت مورد نظر خود را انتخاب کنید</p>
</header>
<div class="company-mobile-page__list">
<header class="company-select-page__desk-head desktop-only">
<h1>انتخاب شرکت</h1>
<p>شرکت موردنظر را انتخاب کنید تا کاتالوگ و سفارش‌گذاری شروع شود</p>
</header>
<div class="company-select-page__list">
<button
v-for="company in companies"
:key="company.id"
@@ -43,13 +49,17 @@ defineEmits<{
</span>
<h2 class="company-brand-card__name">{{ company.name }}</h2>
<p class="company-brand-card__desc">{{ companyDescription(company) }}</p>
<span class="company-brand-card__cta desktop-only">
ورود به کاتالوگ‌ها
<IconChevronLeft :size="16" />
</span>
</button>
</div>
</div>
</template>
<style scoped>
.company-mobile-page {
.company-select-page {
flex: 1;
display: flex;
flex-direction: column;
@@ -58,12 +68,12 @@ defineEmits<{
color: var(--color-text);
}
.company-mobile-page__header {
.company-select-page__header {
padding: 8px var(--page-padding) 12px;
background: var(--color-surface);
}
.company-mobile-page__nav {
.company-select-page__nav {
display: grid;
grid-template-columns: 40px 1fr 40px;
align-items: center;
@@ -87,76 +97,121 @@ defineEmits<{
pointer-events: none;
}
.company-mobile-page__title {
.company-select-page__title {
margin: 0;
font-size: 1.0625rem;
font-weight: 700;
text-align: center;
}
.company-mobile-page__subtitle {
.company-select-page__subtitle {
margin: 0;
font-size: 0.875rem;
color: var(--color-text-muted);
text-align: center;
}
.company-mobile-page__list {
.company-select-page__desk-head {
margin-bottom: 20px;
}
.company-select-page__desk-head h1 {
margin: 0 0 6px;
font-size: 1.375rem;
font-weight: 700;
}
.company-select-page__desk-head p {
margin: 0;
font-size: 0.875rem;
color: var(--color-text-muted);
}
.company-select-page__list {
display: flex;
flex-direction: column;
gap: 16px;
overflow-y: auto;
padding-inline: var(--page-padding);
padding-bottom: 24px;
gap: 14px;
padding: 12px var(--page-padding) 24px;
}
.company-brand-card {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
min-height: 200px;
padding: 32px 24px;
gap: 8px;
padding: 28px 20px 24px;
border: none;
border-radius: 20px;
cursor: pointer;
text-align: center;
color: #fff;
font-family: inherit;
}
.company-brand-card--par {
background: var(--color-primary);
color: #fff;
box-shadow: var(--shadow-md);
background: linear-gradient(145deg, #1a4a52 0%, #0a353c 70%);
}
.company-brand-card--fam-baft {
background: var(--color-brand-warm);
color: var(--color-brand-warm-text);
box-shadow: var(--shadow-card);
border: 1px solid var(--color-border);
background: linear-gradient(145deg, #2a5f68 0%, #123f46 70%);
}
.company-brand-card__logo {
margin-bottom: 16px;
display: grid;
place-items: center;
margin-bottom: 4px;
}
.company-brand-card__name {
margin: 0 0 8px;
margin: 0;
font-size: 1.125rem;
font-weight: 700;
}
.company-brand-card__desc {
margin: 0;
font-size: 0.875rem;
opacity: 0.9;
font-size: 0.8125rem;
opacity: 0.88;
}
.company-brand-card--par .company-brand-card__desc {
color: rgba(255, 255, 255, 0.85);
.company-brand-card__cta {
display: inline-flex;
align-items: center;
gap: 4px;
margin-top: 10px;
padding: 8px 14px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.14);
font-size: 0.8125rem;
font-weight: 600;
}
.company-brand-card--fam-baft .company-brand-card__desc {
color: var(--color-text-muted);
@media (min-width: 768px) {
.company-select-page {
background: transparent;
padding: 8px 0 28px;
max-width: var(--content-max-width);
margin-inline: auto;
width: 100%;
}
.company-select-page__list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18px;
padding: 0;
}
.company-brand-card {
min-height: 260px;
justify-content: center;
transition: transform 0.15s ease, box-shadow 0.15s ease;
box-shadow: var(--shadow-sm);
}
.company-brand-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
}
</style>
+30 -15
View File
@@ -172,16 +172,20 @@ function cycleStatusFilter() {
<div class="customer-card__body">
<div class="customer-card__top">
<strong>{{ item.name }}</strong>
<span
class="status"
:class="item.status === 'active' ? 'status--ok' : 'status--muted'"
>
{{ statusLabel(item.status) }}
</span>
</div>
<p dir="ltr">{{ item.phone }}</p>
<p>{{ item.phone }}</p>
<p>مجموع خرید: {{ formatNumber(item.totalPurchaseToman) }} تومان</p>
</div>
<span
class="status"
:class="{
'status--ok': item.status === 'active',
'status--off': item.status === 'inactive',
'status--new': item.status === 'new',
}"
>
{{ statusLabel(item.status) }}
</span>
<IconChevronLeft :size="18" class="chevron" />
</button>
@@ -339,7 +343,7 @@ function cycleStatusFilter() {
.customer-card {
display: grid;
grid-template-columns: 48px 1fr 20px;
grid-template-columns: 48px 1fr auto 18px;
gap: 10px;
align-items: center;
border: none;
@@ -349,6 +353,7 @@ function cycleStatusFilter() {
text-align: start;
cursor: pointer;
box-shadow: var(--shadow-sm);
font-family: inherit;
}
.customer-card__avatar {
@@ -375,23 +380,33 @@ function cycleStatusFilter() {
}
.status {
font-size: 0.72rem;
padding: 2px 8px;
flex-shrink: 0;
font-size: 0.75rem;
font-weight: 600;
line-height: 1;
padding: 6px 12px;
border-radius: 999px;
white-space: nowrap;
}
.status--ok {
background: var(--color-primary-soft);
color: var(--color-primary);
background: #e8f5e9;
color: #2e7d32;
}
.status--muted {
background: #eef0ef;
color: var(--color-text-muted);
.status--off {
background: #f5f5f5;
color: #757575;
}
.status--new {
background: #e3f2fd;
color: #1565c0;
}
.chevron {
color: var(--color-text-muted);
flex-shrink: 0;
}
.promo {
+305 -56
View File
@@ -7,12 +7,12 @@ import { useFavoritesStore } from '@/stores/favorites'
import { fetchAgentProfile, fetchBanners, type AppBannerDto } from '@/services/agentApi'
import IconBell from '@/components/icons/IconBell.vue'
import IconChevronLeft from '@/components/icons/IconChevronLeft.vue'
import IconMedal from '@/components/icons/IconMedal.vue'
import levelMedalIcon from '@/assets/images/level-medal.webp'
import quickClubCrownIcon from '@/assets/images/quick-club-crown.webp'
import quickCategoriesGridIcon from '@/assets/images/quick-categories-grid.webp'
import quickCatalogRequestIcon from '@/assets/images/quick-catalog-request.webp'
import quickNewProductsIcon from '@/assets/images/quick-new-products.webp'
import IconHeart from '@/components/icons/IconHeart.vue'
import IconCatalog from '@/components/icons/IconCatalog.vue'
import IconDiamond from '@/components/icons/IconDiamond.vue'
import IconLayoutGrid from '@/components/icons/IconLayoutGrid.vue'
import IconProfile from '@/components/icons/IconProfile.vue'
import IconChevronBack from '@/components/icons/IconChevronBack.vue'
import { formatNumber } from '@/utils/format'
import type { CatalogProductCard } from '@/types/crm'
@@ -38,18 +38,26 @@ const agentLevel = ref<{
progressPercent: number
} | null>(null)
const customerActions: Array<{ id: string; label: string; to: string; icon: Component }> = [
{ id: 'qa-club', label: 'باشگاه مشتریان', to: '/profile', icon: IconProfile },
{ id: 'qa-cat', label: 'دسته بندی', to: '/categories', icon: IconLayoutGrid },
{ id: 'qa-new', label: 'محصولات جدید', to: '/products', icon: IconDiamond },
{ id: 'qa-swatch', label: 'درخواست کالیته', to: '/catalog-request', icon: IconCatalog },
type QuickAction = {
id: string
label: string
to: string
icon?: Component
image?: string
}
const customerActions: QuickAction[] = [
{ id: 'qa-club', label: 'باشگاه مشتریان', to: '/profile', image: quickClubCrownIcon },
{ id: 'qa-cat', label: 'دسته بندی', to: '/categories', image: quickCategoriesGridIcon },
{ id: 'qa-new', label: 'محصولات جدید', to: '/products', image: quickNewProductsIcon },
{ id: 'qa-swatch', label: 'درخواست کالیته', to: '/catalog-request', image: quickCatalogRequestIcon },
]
const agentActions: Array<{ id: string; label: string; to: string; icon: Component }> = [
{ id: 'qa-customers', label: 'مشتریان من', to: '/customers', icon: IconProfile },
{ id: 'qa-reminders', label: 'یادآورها', to: '/reminders', icon: IconBell },
{ id: 'qa-cat', label: 'دسته بندی', to: '/categories', icon: IconLayoutGrid },
{ id: 'qa-swatch', label: 'درخواست کالیته', to: '/catalog-request', icon: IconCatalog },
const agentActions: QuickAction[] = [
{ id: 'qa-customers', label: 'باشگاه مشتریان', to: '/customers', image: quickClubCrownIcon },
{ id: 'qa-new', label: 'محصولات جدید', to: '/products', image: quickNewProductsIcon },
{ id: 'qa-cat', label: 'دسته بندی', to: '/categories', image: quickCategoriesGridIcon },
{ id: 'qa-swatch', label: 'درخواست کالیته', to: '/catalog-request', image: quickCatalogRequestIcon },
]
const actions = computed(() => (auth.isAgent ? agentActions : customerActions))
@@ -62,26 +70,42 @@ const FALLBACK_BANNER: AppBannerDto = {
ctaLabel: 'مشاهده کالکشن',
ctaLink: '/categories',
imageUrl: '',
showTitle: true,
showSubtitle: true,
showCta: true,
}
const banners = ref<AppBannerDto[]>([FALLBACK_BANNER])
const bannerIndex = ref(0)
const heroAnim = ref<'hero-next' | 'hero-prev'>('hero-next')
let bannerTimer: number | undefined
const banner = computed(() => banners.value[bannerIndex.value] ?? FALLBACK_BANNER)
const heroStyle = computed(() => {
const image = banner.value.imageUrl
if (!image) return undefined
return { '--hero-image': `url("${image}")` }
})
function slideStyle(item: AppBannerDto) {
if (!item.imageUrl) return undefined
return { '--hero-image': `url("${item.imageUrl}")` }
}
function moveBanner(index: number, dir?: 1 | -1) {
const len = banners.value.length
if (!len) return
const next = ((index % len) + len) % len
if (next === bannerIndex.value) return
if (dir === 1) heroAnim.value = 'hero-next'
else if (dir === -1) heroAnim.value = 'hero-prev'
else if (bannerIndex.value === len - 1 && next === 0) heroAnim.value = 'hero-next'
else if (bannerIndex.value === 0 && next === len - 1) heroAnim.value = 'hero-prev'
else heroAnim.value = next > bannerIndex.value ? 'hero-next' : 'hero-prev'
bannerIndex.value = next
}
function startBannerRotation() {
stopBannerRotation()
if (banners.value.length < 2) return
bannerTimer = window.setInterval(() => {
bannerIndex.value = (bannerIndex.value + 1) % banners.value.length
}, 5000)
moveBanner(bannerIndex.value + 1, 1)
}, 5500)
}
function stopBannerRotation() {
@@ -90,7 +114,7 @@ function stopBannerRotation() {
}
function setBanner(index: number) {
bannerIndex.value = index
moveBanner(index)
startBannerRotation()
}
@@ -166,7 +190,7 @@ async function toggleFavorite(productId: string) {
<template>
<div class="page home-page">
<header class="home-header">
<header class="home-header mobile-only">
<button type="button" class="icon-btn" aria-label="بازگشت" @click="goBack">
<IconChevronBack :size="20" />
</button>
@@ -192,29 +216,45 @@ async function toggleFavorite(productId: string) {
</div>
</header>
<header class="home-desk-head desktop-only">
<div>
<h1 class="home-desk-head__title">خانه</h1>
<p class="home-desk-head__sub">
سلام {{ displayFirst }} {{ displayLast }} — خوش آمدید
</p>
</div>
</header>
<div class="page__scroll page__scroll--with-nav home-scroll">
<p v-if="error" class="home-error">{{ error }}</p>
<PageSkeleton v-else-if="loading" variant="home" />
<template v-else>
<section
class="hero-banner"
:aria-label="banner.title"
:style="heroStyle"
>
<div class="hero-banner__content">
<h2 class="hero-banner__title">
{{ banner.title }}
<span v-if="banner.subtitle">{{ banner.subtitle }}</span>
</h2>
<button
v-if="banner.ctaLabel"
type="button"
class="hero-banner__cta"
@click="goBanner"
>
{{ banner.ctaLabel }}
</button>
</div>
<section class="hero-banner" :aria-label="banner.title">
<TransitionGroup :name="heroAnim" tag="div" class="hero-banner__stage">
<article :key="banner.id" class="hero-slide" :style="slideStyle(banner)">
<div class="hero-slide__media" aria-hidden="true" />
<div class="hero-banner__content">
<h2 v-if="banner.showTitle !== false" class="hero-banner__title">
{{ banner.title }}
<span v-if="banner.showSubtitle !== false && banner.subtitle">{{ banner.subtitle }}</span>
</h2>
<p
v-else-if="banner.showSubtitle !== false && banner.subtitle"
class="hero-banner__title"
>
<span>{{ banner.subtitle }}</span>
</p>
<button
v-if="banner.showCta !== false && banner.ctaLabel"
type="button"
class="hero-banner__cta"
@click="goBanner"
>
{{ banner.ctaLabel }}
</button>
</div>
</article>
</TransitionGroup>
<div v-if="banners.length > 1" class="hero-banner__dots" role="tablist" aria-label="بنرها">
<button
v-for="(item, index) in banners"
@@ -238,7 +278,8 @@ async function toggleFavorite(productId: string) {
@click="go(action.to)"
>
<span class="quick-card__icon" aria-hidden="true">
<component :is="action.icon" :size="20" />
<img v-if="action.image" class="quick-card__image" :src="action.image" alt="" />
<component v-else :is="action.icon" :size="20" />
</span>
<span class="quick-card__label">{{ action.label }}</span>
</button>
@@ -247,7 +288,14 @@ async function toggleFavorite(productId: string) {
<section class="level-card" aria-label="سطح عضویت">
<div class="level-card__top">
<div class="level-card__identity">
<IconMedal :size="48" />
<img
class="level-card__medal"
:src="levelMedalIcon"
alt=""
width="48"
height="48"
aria-hidden="true"
/>
<div class="level-card__text">
<span class="level-card__eyebrow">سطح شما</span>
<strong class="level-card__name">{{ homeLevel.label }}</strong>
@@ -415,14 +463,106 @@ async function toggleFavorite(productId: string) {
position: relative;
min-height: 160px;
border-radius: var(--radius-lg);
color: #fff;
overflow: hidden;
isolation: isolate;
}
.hero-banner__stage {
position: absolute;
inset: 0;
}
.hero-slide {
position: absolute;
inset: 0;
display: flex;
align-items: flex-end;
padding: 20px;
z-index: 1;
}
.hero-slide__media {
position: absolute;
inset: 0;
z-index: 0;
background:
linear-gradient(120deg, rgba(var(--color-accent-navy-rgb), 0.72), rgba(var(--color-primary-rgb), 0.45)),
var(--hero-image);
background-size: cover;
background-position: center;
color: #fff;
padding: 20px;
overflow: hidden;
var(--hero-image) center / cover no-repeat;
}
.hero-banner__content {
position: relative;
z-index: 1;
animation: hero-copy 0.7s cubic-bezier(0.22, 1, 0.36, 1) both;
}
.hero-next-enter-active,
.hero-next-leave-active,
.hero-prev-enter-active,
.hero-prev-leave-active {
transition:
opacity 0.7s cubic-bezier(0.22, 1, 0.36, 1),
transform 0.85s cubic-bezier(0.22, 1, 0.36, 1);
}
.hero-next-leave-active,
.hero-prev-leave-active {
z-index: 1;
}
.hero-next-enter-active,
.hero-prev-enter-active {
z-index: 2;
}
.hero-next-enter-from {
opacity: 0;
transform: translate3d(-16%, 0, 0);
}
.hero-next-leave-to {
opacity: 0;
transform: translate3d(12%, 0, 0);
}
.hero-prev-enter-from {
opacity: 0;
transform: translate3d(16%, 0, 0);
}
.hero-prev-leave-to {
opacity: 0;
transform: translate3d(-12%, 0, 0);
}
@keyframes hero-copy {
from {
opacity: 0;
transform: translate3d(0, 12px, 0);
}
to {
opacity: 1;
transform: none;
}
}
@media (prefers-reduced-motion: reduce) {
.hero-banner__content,
.hero-next-enter-active,
.hero-next-leave-active,
.hero-prev-enter-active,
.hero-prev-leave-active {
animation: none;
transition: opacity 0.2s ease;
}
.hero-next-enter-from,
.hero-next-leave-to,
.hero-prev-enter-from,
.hero-prev-leave-to {
transform: none;
}
}
.hero-banner__title {
@@ -456,6 +596,7 @@ async function toggleFavorite(productId: string) {
transform: translateX(-50%);
display: flex;
gap: 6px;
z-index: 3;
}
.dot {
@@ -466,6 +607,7 @@ async function toggleFavorite(productId: string) {
border-radius: 999px;
background: rgba(255, 255, 255, 0.45);
cursor: pointer;
transition: width 0.35s ease, background 0.35s ease;
}
.dot--active {
@@ -493,18 +635,25 @@ async function toggleFavorite(productId: string) {
}
.quick-card__icon {
width: 40px;
height: 40px;
width: 60px;
height: 60px;
border-radius: 12px;
background: var(--color-primary-soft);
display: grid;
place-items: center;
color: var(--color-primary);
background: var(--color-surface);
box-shadow: 0 2px 12px rgba(16, 24, 40, 0.06);
}
.quick-card__image {
width: 32px;
height: 32px;
display: block;
object-fit: contain;
}
.quick-card__label {
font-weight: bold;
font-size: 0.7rem;
font-weight: 600;
text-align: center;
line-height: 1.4;
}
@@ -531,6 +680,14 @@ async function toggleFavorite(productId: string) {
min-width: 0;
}
.level-card__medal {
width: 48px;
height: 48px;
flex-shrink: 0;
display: block;
object-fit: contain;
}
.level-card__text {
display: flex;
flex-direction: column;
@@ -731,4 +888,96 @@ async function toggleFavorite(productId: string) {
.product-card__fav--on {
color: var(--color-primary);
}
@media (min-width: 768px) {
.home-page {
padding-top: 8px;
}
.home-desk-head {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
margin-bottom: 8px;
padding: 0;
}
.home-desk-head__title {
margin: 0 0 4px;
font-size: 1.375rem;
font-weight: 700;
color: var(--color-text);
}
.home-desk-head__sub {
margin: 0;
font-size: 0.875rem;
color: var(--color-text-muted);
}
.home-scroll {
gap: 22px;
padding: 8px 0 24px;
}
.hero-banner {
min-height: 220px;
border-radius: 20px;
}
.hero-slide {
padding: 28px 32px;
}
.hero-banner__title {
font-size: 1.35rem;
max-width: 22rem;
}
.quick-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
}
.quick-card {
min-height: 96px;
padding: 18px 14px;
border-radius: 16px;
}
.level-card {
padding: 20px 22px;
}
.product-row {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
overflow: visible;
margin-inline: 0;
padding-inline: 0;
scroll-snap-type: none;
}
.product-card {
flex: none;
width: auto;
}
.product-card__image {
height: 160px;
}
}
@media (min-width: 1200px) {
.product-row {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.hero-banner {
min-height: 240px;
}
}
</style>
+14 -5
View File
@@ -8,6 +8,7 @@ import { useCatalogStore } from '@/stores/catalog'
import type { CatalogProductCard } from '@/types/crm'
import type { Product } from '@/types'
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'
@@ -50,6 +51,12 @@ const adding = ref(false)
const showAllSpecs = ref(false)
const rollMeters = computed(() => product.value?.rollMeters ?? 50)
const currentGalleryImage = computed(() => {
const p = product.value
if (!p) return ''
return colorImageAt(p.colors, p.colorImages, p.imageUrls, selectedColor.value, p.imageUrl)
})
const currentColorName = computed(() => product.value?.colors[selectedColor.value] ?? '')
const orderMeters = computed(() => rolls.value * rollMeters.value)
const minOrderMeters = computed(() => {
const min = product.value?.minMeterage
@@ -238,13 +245,13 @@ function shareProduct() {
<div
class="gallery__main"
:style="
product.imageUrl
currentGalleryImage
? {
backgroundImage: `url(${product.imageUrl})`,
backgroundImage: `url(${currentGalleryImage})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}
: { background: product.colors[selectedColor] || '#6b7280' }
: colorSwatchStyle(currentColorName)
"
>
<span class="gallery__count">
@@ -260,7 +267,7 @@ function shareProduct() {
:class="{ 'thumb--active': selectedColor === idx }"
@click="selectedColor = idx"
>
<span class="thumb__swatch" :style="{ background: color }" />
<span class="thumb__swatch" :style="colorSwatchStyle(color, product.colorImages?.[idx] || product.imageUrls?.[idx])" />
</button>
</div>
</div>
@@ -333,7 +340,7 @@ function shareProduct() {
type="button"
class="color-dot"
:class="{ 'color-dot--active': selectedColor === idx }"
:style="{ background: color }"
:style="colorSwatchStyle(color, product.colorImages?.[idx] || product.imageUrls?.[idx])"
@click="selectedColor = idx"
/>
</div>
@@ -670,6 +677,8 @@ function shareProduct() {
flex-shrink: 0;
box-sizing: border-box;
padding: 0;
background-size: cover;
background-position: center;
}
.color-dot--active {
+4 -1
View File
@@ -10,6 +10,7 @@ import IconFilter from '@/components/icons/IconFilter.vue'
import IconChevronLeft from '@/components/icons/IconChevronLeft.vue'
import IconHeart from '@/components/icons/IconHeart.vue'
import { useFavoritesStore } from '@/stores/favorites'
import { colorSwatchStyle } from '@/utils/colorSwatch'
const route = useRoute()
const router = useRouter()
@@ -247,7 +248,7 @@ function toggleFavorite(productId: string) {
v-for="(color, idx) in item.colors.slice(0, 6)"
:key="idx"
class="swatch"
:style="{ background: color }"
:style="colorSwatchStyle(color, item.colorImages?.[idx] || item.imageUrls?.[idx])"
/>
</div>
<button type="button" class="details-link" @click.stop="openProduct(item.id)">
@@ -456,6 +457,8 @@ function toggleFavorite(productId: string) {
height: 14px;
border-radius: 999px;
border: 1px solid rgba(0, 0, 0, 0.08);
background-size: cover;
background-position: center;
}
.details-link {
+1 -1
View File
@@ -55,7 +55,7 @@ function save() {
function logout() {
auth.logout()
router.replace('/login')
router.replace('/welcome')
}
</script>
+1 -13
View File
@@ -1,19 +1,7 @@
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import MobileProfilePage from '@/views/profile/components/MobileProfilePage.vue'
import DesktopProfilePage from '@/views/profile/components/DesktopProfilePage.vue'
const router = useRouter()
const auth = useAuthStore()
function logout() {
auth.logout()
router.replace('/login')
}
</script>
<template>
<MobileProfilePage class="mobile-only" />
<DesktopProfilePage class="desktop-only" @logout="logout" />
<MobileProfilePage />
</template>
@@ -130,7 +130,7 @@ onMounted(async () => {
<div class="page__scroll page__scroll--with-nav profile-scroll">
<header class="hero">
<div class="hero__pin">
<div class="hero__top">
<div class="hero__top mobile-only">
<button
type="button"
class="icon-btn"
@@ -176,6 +176,17 @@ onMounted(async () => {
سطح: {{ levelLabel }}
</span>
</div>
<div class="hero__desk-actions desktop-only-flex">
<button type="button" class="hero__desk-btn" @click="router.push('/profile/edit')">
ویرایش اطلاعات
</button>
<button type="button" class="hero__desk-btn" @click="router.push('/profile/checks')">
لیست چک‌ها
</button>
<button type="button" class="hero__desk-btn" @click="router.push('/profile/display')">
تنظیمات
</button>
</div>
</header>
<div class="profile-sheet">
@@ -256,7 +267,7 @@ onMounted(async () => {
</div>
</section>
<section>
<section class="quick-section">
<h3 class="section-title">دسترسی سریع</h3>
<div class="quick-grid">
<button
@@ -799,4 +810,138 @@ onMounted(async () => {
background: var(--color-elevated);
text-align: start;
}
.hero__desk-actions {
display: none;
}
@media (min-width: 768px) {
.profile-page {
background: transparent;
padding: 8px 0 28px;
max-width: var(--content-max-width);
margin-inline: auto;
}
.profile-scroll {
overflow: visible;
display: flex;
flex-direction: column;
gap: 0;
}
.hero {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 20px;
border-radius: 20px;
padding: 24px 28px;
text-align: start;
overflow: hidden;
}
.hero__pin {
position: static;
display: block;
padding: 0;
background: transparent;
}
.hero__avatar {
margin: 0;
width: 88px !important;
height: 88px !important;
}
.hero__meta {
padding: 0;
}
.hero h2 {
font-size: 1.5rem;
}
.hero__desk-btn {
border: 1px solid rgba(255, 255, 255, 0.35);
background: rgba(255, 255, 255, 0.12);
color: #fff;
border-radius: 999px;
padding: 8px 14px;
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
font-family: inherit;
}
.hero__desk-btn:hover {
background: rgba(255, 255, 255, 0.2);
}
.hero__desk-actions {
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
max-width: 280px;
}
.profile-sheet {
margin-top: 18px;
border-radius: 20px;
padding: 22px 24px 28px;
box-shadow: var(--shadow-sm);
border: 1px solid var(--color-border);
gap: 22px;
}
.stats {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.quick-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.quick-item {
min-height: 88px;
}
}
@media (min-width: 1100px) {
.profile-sheet {
display: grid;
grid-template-columns: 1.2fr 0.8fr;
grid-template-areas:
'panel panel'
'loyalty loyalty'
'quick quick'
'orders price';
align-items: start;
}
.profile-sheet > .panel {
grid-area: panel;
}
.profile-sheet > .loyalty {
grid-area: loyalty;
}
.profile-sheet > .quick-section {
grid-area: quick;
}
.profile-sheet > .orders-box {
grid-area: orders;
}
.profile-sheet > .price-section {
grid-area: price;
}
.quick-grid {
grid-template-columns: repeat(6, minmax(0, 1fr));
}
}
</style>