.
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 178 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 112 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 144 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 109 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 218 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 89 KiB |
@@ -6,8 +6,8 @@
|
||||
--color-primary-dark: #062428;
|
||||
--color-primary-light: #e8f4f1;
|
||||
--color-primary-rgb: 10, 53, 60;
|
||||
--color-accent-navy: #1b223c;
|
||||
--color-accent-navy-rgb: 27, 34, 60;
|
||||
--color-accent-navy: #0a353c;
|
||||
--color-accent-navy-rgb: 10, 53, 60;
|
||||
--color-success: #1b8a5a;
|
||||
--color-success-light: #eaf8f1;
|
||||
--color-success-soft: #eaf8f1;
|
||||
@@ -69,8 +69,8 @@ html.dark {
|
||||
--color-primary-dark: #7ad0c5;
|
||||
--color-primary-light: #14353a;
|
||||
--color-primary-rgb: 94, 184, 173;
|
||||
--color-accent-navy: #9bb0d0;
|
||||
--color-accent-navy-rgb: 155, 176, 208;
|
||||
--color-accent-navy: #5eb8ad;
|
||||
--color-accent-navy-rgb: 94, 184, 173;
|
||||
--color-success: #3dd68c;
|
||||
--color-success-light: #123528;
|
||||
--color-success-soft: #123528;
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import milano from '@/assets/images/catalogs/milano.jpg'
|
||||
import najran from '@/assets/images/catalogs/najran.jpg'
|
||||
import payizeh from '@/assets/images/catalogs/payizeh.jpg'
|
||||
import classic from '@/assets/images/catalogs/classic.jpg'
|
||||
import tarhdar from '@/assets/images/catalogs/tarhdar.jpg'
|
||||
import fastoni from '@/assets/images/catalogs/fastoni.jpg'
|
||||
import type { CatalogGalleryCard } from '@/utils/catalogGallery'
|
||||
import type { CatalogProductCard } from '@/types/crm'
|
||||
|
||||
export const CALITE_COVERS = [milano, najran, payizeh, classic, tarhdar, fastoni]
|
||||
|
||||
export const MOCK_CALITE_CARDS: CatalogGalleryCard[] = [
|
||||
{
|
||||
id: 'demo-milano',
|
||||
companyId: '',
|
||||
name: 'میلانو',
|
||||
title: 'کالیته میلانو',
|
||||
latinName: 'Milano',
|
||||
subtitle: 'پارچههای کتوشلواری',
|
||||
imageUrl: milano,
|
||||
patternCount: 16,
|
||||
badge: 'new',
|
||||
season: 'all_season',
|
||||
isNew: true,
|
||||
isPopular: false,
|
||||
},
|
||||
{
|
||||
id: 'demo-najran',
|
||||
companyId: '',
|
||||
name: 'نجران',
|
||||
title: 'کالیته نجران',
|
||||
latinName: 'Najran',
|
||||
subtitle: 'پارچههای پیراهنی',
|
||||
imageUrl: najran,
|
||||
patternCount: 14,
|
||||
season: 'all_season',
|
||||
isNew: false,
|
||||
isPopular: false,
|
||||
},
|
||||
{
|
||||
id: 'demo-payizeh',
|
||||
companyId: '',
|
||||
name: 'پاییزه',
|
||||
title: 'کالیته پاییزه',
|
||||
latinName: 'Payizeh',
|
||||
subtitle: 'پارچههای شلواری',
|
||||
imageUrl: payizeh,
|
||||
patternCount: 18,
|
||||
season: 'all_season',
|
||||
isNew: false,
|
||||
isPopular: false,
|
||||
},
|
||||
{
|
||||
id: 'demo-classic',
|
||||
companyId: '',
|
||||
name: 'کلاسیک',
|
||||
title: 'کالیته کلاسیک',
|
||||
latinName: 'Classic',
|
||||
subtitle: 'پارچههای کتوشلواری',
|
||||
imageUrl: classic,
|
||||
patternCount: 16,
|
||||
season: 'all_season',
|
||||
isNew: false,
|
||||
isPopular: false,
|
||||
},
|
||||
{
|
||||
id: 'demo-tarhdar',
|
||||
companyId: '',
|
||||
name: 'طرحدار',
|
||||
title: 'کالیته طرحدار',
|
||||
latinName: 'Tarhdar',
|
||||
subtitle: 'پارچههای مجلسی',
|
||||
imageUrl: tarhdar,
|
||||
patternCount: 20,
|
||||
badge: 'popular',
|
||||
season: 'winter',
|
||||
isNew: false,
|
||||
isPopular: true,
|
||||
},
|
||||
{
|
||||
id: 'demo-fastoni',
|
||||
companyId: '',
|
||||
name: 'فاستونی',
|
||||
title: 'کالیته فاستونی',
|
||||
latinName: 'Fastoni',
|
||||
subtitle: 'پارچههای مناسب چهار فصل',
|
||||
imageUrl: fastoni,
|
||||
patternCount: 20,
|
||||
season: 'all_season',
|
||||
isNew: false,
|
||||
isPopular: false,
|
||||
},
|
||||
]
|
||||
|
||||
export function coverForIndex(index: number) {
|
||||
return CALITE_COVERS[index % CALITE_COVERS.length]
|
||||
}
|
||||
|
||||
export function seasonLine(card: CatalogGalleryCard) {
|
||||
if (card.id === 'demo-classic') return 'مناسب چهار فصل'
|
||||
if (card.season === 'winter') return 'مناسب زمستان'
|
||||
if (card.season === 'summer') return 'مناسب تابستان'
|
||||
return 'مناسب چهار فصل'
|
||||
}
|
||||
|
||||
export function findMockCalite(id: string) {
|
||||
return MOCK_CALITE_CARDS.find((card) => card.id === id)
|
||||
}
|
||||
|
||||
export function demoProductsFor(card: CatalogGalleryCard): CatalogProductCard[] {
|
||||
const names = [`${card.name} ۱۰۱`, `${card.name} ۱۰۲`, `${card.name} ۱۰۳`, `${card.name} ۱۰۴`]
|
||||
const kinds = ['plain', 'melange', 'patterned', 'plain'] as const
|
||||
return names.map((name, index) => ({
|
||||
id: `${card.id}-${index + 1}`,
|
||||
name,
|
||||
code: `${card.latinName || card.name}-${101 + index}`,
|
||||
categoryId: card.subtitle.includes('پیراهن')
|
||||
? 'shirt'
|
||||
: card.subtitle.includes('شلوار')
|
||||
? 'trousers'
|
||||
: 'suit',
|
||||
season: card.season,
|
||||
widthCm: 150,
|
||||
composition: 'پشم و پلیاستر',
|
||||
weaveType: kinds[index] === 'plain' ? 'ساده' : kinds[index] === 'melange' ? 'ملانژ' : 'طرحدار',
|
||||
colors: ['#111827', '#6b7280', '#d6d3d1', '#1e3a5f'].slice(0, 3 + (index % 2)),
|
||||
tags: [kinds[index] === 'patterned' ? 'طرحدار' : kinds[index] === 'melange' ? 'ملانژ' : 'ساده'],
|
||||
isNew: index === 0,
|
||||
imageUrl: card.imageUrl,
|
||||
subtitle: card.subtitle,
|
||||
rollMeters: 50,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -33,7 +33,9 @@ const isWideGuest = computed(() => {
|
||||
return wideRoutes.includes(route.name as string)
|
||||
})
|
||||
|
||||
const isImmersiveAuth = computed(() => route.name === 'login' || route.name === 'register')
|
||||
const isImmersiveAuth = computed(
|
||||
() => route.name === 'login' || route.name === 'register' || route.name === 'otp',
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -58,6 +58,12 @@ const router = createRouter({
|
||||
component: () => import('@/views/catalogs/CatalogsHubView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/catalogs/:catalogId',
|
||||
name: 'catalog-detail',
|
||||
component: () => import('@/views/catalogs/CatalogDetailView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/catalog-request',
|
||||
name: 'catalog-request',
|
||||
|
||||
@@ -79,6 +79,7 @@ export interface Catalog {
|
||||
name: string
|
||||
code: string
|
||||
description?: string
|
||||
imageUrl?: string
|
||||
displayOrder: number
|
||||
isActive: boolean
|
||||
productCount?: number
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import type { Catalog } from '@/types'
|
||||
import type { CatalogProductCard, SeasonKind } from '@/types/crm'
|
||||
|
||||
export type CatalogGalleryFilter = 'all' | 'newest' | 'popular' | 'season'
|
||||
export type PatternKind = 'plain' | 'melange' | 'patterned'
|
||||
|
||||
export interface CatalogGalleryCard {
|
||||
id: string
|
||||
companyId: string
|
||||
name: string
|
||||
title: string
|
||||
latinName: string
|
||||
subtitle: string
|
||||
imageUrl?: string
|
||||
patternCount: number
|
||||
badge?: 'new' | 'popular'
|
||||
season: SeasonKind
|
||||
isNew: boolean
|
||||
isPopular: boolean
|
||||
}
|
||||
|
||||
export interface CatalogPatternCard {
|
||||
id: string
|
||||
name: string
|
||||
imageUrl?: string
|
||||
colorCount: number
|
||||
colors: string[]
|
||||
kind: PatternKind | 'other'
|
||||
items: CatalogProductCard[]
|
||||
}
|
||||
|
||||
const USAGE_SUBTITLE: Record<string, string> = {
|
||||
suit: 'پارچههای کتوشلواری',
|
||||
trousers: 'پارچههای شلواری',
|
||||
shirt: 'پارچههای پیراهنی',
|
||||
}
|
||||
|
||||
export const SEASON_LABEL: Record<SeasonKind, string> = {
|
||||
summer: 'تابستانه',
|
||||
winter: 'زمستانه',
|
||||
all_season: 'چهار فصل',
|
||||
}
|
||||
|
||||
export function catalogDisplayTitle(name: string) {
|
||||
const trimmed = name.trim()
|
||||
return trimmed.includes('کالیته') ? trimmed : `کالیته ${trimmed}`
|
||||
}
|
||||
|
||||
export function catalogLatinName(catalog: Catalog) {
|
||||
const fromDescription = catalog.description?.match(/[A-Za-z][A-Za-z0-9 .&-]{1,28}/)?.[0]?.trim()
|
||||
if (fromDescription) return fromDescription
|
||||
if (/^[A-Za-z]/.test(catalog.code)) return catalog.code
|
||||
return ''
|
||||
}
|
||||
|
||||
function majority<T extends string>(values: T[], fallback: T): T {
|
||||
if (!values.length) return fallback
|
||||
const counts = new Map<T, number>()
|
||||
for (const value of values) {
|
||||
counts.set(value, (counts.get(value) ?? 0) + 1)
|
||||
}
|
||||
return [...counts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? fallback
|
||||
}
|
||||
|
||||
export function patternKindOf(product: CatalogProductCard): PatternKind | 'other' {
|
||||
const hay = `${product.weaveType ?? ''} ${product.tags.join(' ')} ${product.name}`
|
||||
if (/ملانژ|melange|mélange|heather/i.test(hay)) return 'melange'
|
||||
if (/طرح?دار|striped?|check|print|jacquard|pattern/i.test(hay)) return 'patterned'
|
||||
if (/ساده|plain|solid/i.test(hay)) return 'plain'
|
||||
return 'other'
|
||||
}
|
||||
|
||||
export function groupCatalogPatterns(products: CatalogProductCard[]): CatalogPatternCard[] {
|
||||
const groups = new Map<string, CatalogProductCard[]>()
|
||||
for (const product of products) {
|
||||
const key = product.name.trim() || product.code
|
||||
const list = groups.get(key) ?? []
|
||||
list.push(product)
|
||||
groups.set(key, list)
|
||||
}
|
||||
|
||||
return [...groups.entries()].map(([, items]) => {
|
||||
const colors = [
|
||||
...new Set(
|
||||
items.flatMap((item) =>
|
||||
item.colors.length ? item.colors : item.color ? [item.color] : [],
|
||||
),
|
||||
),
|
||||
]
|
||||
return {
|
||||
id: items[0].id,
|
||||
name: items[0].name,
|
||||
imageUrl: items.find((item) => item.imageUrl)?.imageUrl,
|
||||
colorCount: Math.max(colors.length, items.length),
|
||||
colors: colors.slice(0, 5),
|
||||
kind: patternKindOf(items[0]),
|
||||
items,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function buildCatalogGallery(
|
||||
catalogs: Catalog[],
|
||||
products: CatalogProductCard[],
|
||||
): CatalogGalleryCard[] {
|
||||
const byCatalog = new Map<string, CatalogProductCard[]>()
|
||||
for (const product of products) {
|
||||
if (!product.catalogId) continue
|
||||
const list = byCatalog.get(product.catalogId) ?? []
|
||||
list.push(product)
|
||||
byCatalog.set(product.catalogId, list)
|
||||
}
|
||||
|
||||
const counts = catalogs.map((catalog) => byCatalog.get(catalog.id)?.length ?? catalog.productCount ?? 0)
|
||||
const popularThreshold = Math.max(4, [...counts].sort((a, b) => b - a)[Math.floor(catalogs.length / 3)] ?? 4)
|
||||
|
||||
return catalogs.map((catalog) => {
|
||||
const items = byCatalog.get(catalog.id) ?? []
|
||||
const patternCount = groupCatalogPatterns(items).length || items.length || catalog.productCount || 0
|
||||
const usage = majority(
|
||||
items.map((item) => item.categoryId),
|
||||
'suit',
|
||||
)
|
||||
const season = majority(
|
||||
items.map((item) => item.season),
|
||||
'all_season',
|
||||
)
|
||||
const isNew = items.some((item) => item.isNew)
|
||||
const isPopular = patternCount >= popularThreshold
|
||||
return {
|
||||
id: catalog.id,
|
||||
companyId: catalog.companyId,
|
||||
name: catalog.name,
|
||||
title: catalogDisplayTitle(catalog.name),
|
||||
latinName: catalogLatinName(catalog),
|
||||
subtitle: catalog.description?.trim() || USAGE_SUBTITLE[usage] || 'پارچههای منتخب',
|
||||
imageUrl: catalog.imageUrl || items.find((item) => item.imageUrl)?.imageUrl,
|
||||
patternCount,
|
||||
badge: isNew ? 'new' : isPopular ? 'popular' : undefined,
|
||||
season,
|
||||
isNew,
|
||||
isPopular,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function filterCatalogGallery(
|
||||
cards: CatalogGalleryCard[],
|
||||
filter: CatalogGalleryFilter,
|
||||
query: string,
|
||||
) {
|
||||
const needle = query.trim().toLowerCase()
|
||||
let list = cards.filter((card) => {
|
||||
if (!needle) return true
|
||||
return [card.title, card.name, card.latinName, card.subtitle]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(needle)
|
||||
})
|
||||
|
||||
if (filter === 'newest') {
|
||||
return list.sort((a, b) => Number(b.isNew) - Number(a.isNew) || b.patternCount - a.patternCount)
|
||||
}
|
||||
if (filter === 'popular') {
|
||||
return list.sort((a, b) => Number(b.isPopular) - Number(a.isPopular) || b.patternCount - a.patternCount)
|
||||
}
|
||||
if (filter === 'season') {
|
||||
const order: SeasonKind[] = ['summer', 'winter', 'all_season']
|
||||
return list.sort((a, b) => order.indexOf(a.season) - order.indexOf(b.season) || a.title.localeCompare(b.title, 'fa'))
|
||||
}
|
||||
return list
|
||||
}
|
||||
@@ -393,7 +393,7 @@ onMounted(async () => {
|
||||
padding: 0 44px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: #1e4644;
|
||||
background: var(--color-primary);
|
||||
color: #f7f4ef;
|
||||
font-family: inherit;
|
||||
font-size: 0.9375rem;
|
||||
@@ -404,7 +404,7 @@ onMounted(async () => {
|
||||
|
||||
.login-submit:not(:disabled):hover,
|
||||
.login-submit:not(:disabled):focus-visible {
|
||||
background: #173835;
|
||||
background: var(--color-primary-hover);
|
||||
}
|
||||
|
||||
.login-submit:focus-visible {
|
||||
@@ -445,7 +445,7 @@ onMounted(async () => {
|
||||
|
||||
.login-register a {
|
||||
font-weight: 700;
|
||||
color: #1e4644;
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
|
||||
+314
-61
@@ -4,10 +4,10 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import { useIntervalFn } from '@vueuse/core'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import FormErrorBanner from '@/components/ui/FormErrorBanner.vue'
|
||||
import BaseButton from '@/components/ui/BaseButton.vue'
|
||||
import AuthThemeToggle from '@/components/ui/AuthThemeToggle.vue'
|
||||
import IconEditPen from '@/components/icons/IconEditPen.vue'
|
||||
import IconChevronBack from '@/components/icons/IconChevronBack.vue'
|
||||
import IconChevronLeft from '@/components/icons/IconChevronLeft.vue'
|
||||
import IconQemashMark from '@/components/icons/IconQemashMark.vue'
|
||||
import { toPersianDigits, extractNumericString, normalizePhone } from '@/utils/format'
|
||||
import {
|
||||
FORM_VALIDATION_SUMMARY,
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
type FieldErrors,
|
||||
} from '@/utils/formErrors'
|
||||
import { getPendingRegister } from '@/utils/pendingRegister'
|
||||
import fabricUrl from '@/assets/images/login-fabric.jpg'
|
||||
|
||||
const OTP_LENGTH = 4
|
||||
const RESEND_SECONDS = 60
|
||||
@@ -231,29 +232,40 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page auth-page">
|
||||
<header class="auth-top">
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
aria-label="بازگشت"
|
||||
@click="router.push(isRegister ? '/register' : '/login')"
|
||||
>
|
||||
<IconChevronBack :size="20" />
|
||||
</button>
|
||||
<h1 class="auth-top__title">تأیید موبایل</h1>
|
||||
<AuthThemeToggle variant="inline" />
|
||||
</header>
|
||||
<div class="page otp-page">
|
||||
<img class="otp-page__bg" :src="fabricUrl" alt="" />
|
||||
|
||||
<div class="page__scroll auth-body">
|
||||
<p class="auth-lead">کد ارسالشده به {{ toPersianDigits(phone) }} را وارد کنید.</p>
|
||||
<div class="otp-page__scroll">
|
||||
<div class="otp-page__stage">
|
||||
<header class="otp-top">
|
||||
<button
|
||||
type="button"
|
||||
class="otp-top__back"
|
||||
aria-label="بازگشت"
|
||||
@click="router.push(isRegister ? '/register' : '/login')"
|
||||
>
|
||||
<IconChevronBack :size="20" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<form class="card" @submit.prevent="submit">
|
||||
<FormErrorBanner :message="formError" />
|
||||
<div class="otp-brand">
|
||||
<IconQemashMark :size="72" />
|
||||
<h1 class="otp-brand__fa">نادمی تکس</h1>
|
||||
<p class="otp-brand__en">NADEMITEX</p>
|
||||
</div>
|
||||
|
||||
<form class="otp-card" @submit.prevent="submit">
|
||||
<h2 class="otp-card__title">تأیید موبایل</h2>
|
||||
<p class="otp-card__lead">
|
||||
کد ارسالشده به
|
||||
<span dir="ltr">{{ toPersianDigits(phone) }}</span>
|
||||
را وارد کنید.
|
||||
</p>
|
||||
|
||||
<FormErrorBanner :message="formError" />
|
||||
|
||||
<div class="form-group">
|
||||
<div
|
||||
class="otp-row flex w-full max-w-full justify-center ltr"
|
||||
class="otp-row"
|
||||
dir="ltr"
|
||||
role="group"
|
||||
aria-label="کد تأیید"
|
||||
@@ -276,55 +288,178 @@ onMounted(() => {
|
||||
@paste="onDigitPaste(index, $event)"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="fieldErrors.code" class="input-error-text">{{ fieldErrors.code }}</p>
|
||||
</div>
|
||||
<p v-if="fieldErrors.code" class="otp-card__error">{{ fieldErrors.code }}</p>
|
||||
|
||||
<BaseButton block type="submit" :loading="auth.loading">تأیید و ورود</BaseButton>
|
||||
<BaseButton
|
||||
block
|
||||
variant="outline"
|
||||
type="button"
|
||||
class="mt-2"
|
||||
:disabled="!canResend"
|
||||
@click="resend"
|
||||
>
|
||||
<template v-if="canResend">ارسال مجدد کد</template>
|
||||
<template v-else>ارسال مجدد کد ({{ timerLabel }})</template>
|
||||
</BaseButton>
|
||||
<button class="otp-submit" type="submit" :disabled="auth.loading">
|
||||
<span v-if="auth.loading" class="otp-submit__spinner" aria-hidden="true" />
|
||||
<span class="otp-submit__chevron" aria-hidden="true">
|
||||
<IconChevronLeft :size="18" color="#F4EDE4" />
|
||||
</span>
|
||||
<span>تأیید و ورود</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="mt-3 mx-auto flex items-center justify-center gap-1.5 border-0 bg-transparent p-0 text-sm text-muted cursor-pointer transition-colors hover:text-text"
|
||||
@click="changePhone"
|
||||
>
|
||||
<span class="ltr tabular-nums" dir="ltr">{{ toPersianDigits(phone) }}</span>
|
||||
<span>ویرایش شماره</span>
|
||||
<IconEditPen :size="18" :stroke-width="1.5" />
|
||||
</button>
|
||||
</form>
|
||||
<button
|
||||
class="otp-resend"
|
||||
type="button"
|
||||
:disabled="!canResend"
|
||||
@click="resend"
|
||||
>
|
||||
<template v-if="canResend">ارسال مجدد کد</template>
|
||||
<template v-else>ارسال مجدد کد ({{ timerLabel }})</template>
|
||||
</button>
|
||||
|
||||
<button type="button" class="otp-edit" @click="changePhone">
|
||||
<span dir="ltr">{{ toPersianDigits(phone) }}</span>
|
||||
<span>ویرایش شماره</span>
|
||||
<IconEditPen :size="16" :stroke-width="1.5" />
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="otp-spacer" aria-hidden="true" />
|
||||
<p class="otp-watermark">MORE<br />THAN<br />FABRIC</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.otp-page {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
color: #f4ede4;
|
||||
background: #1a1612;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.otp-page__bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center center;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.otp-page__scroll {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
height: 100%;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.otp-page__stage {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100%;
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
margin-inline: auto;
|
||||
padding: calc(10px + env(safe-area-inset-top, 0px)) 16px
|
||||
calc(14px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.otp-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.otp-top__back {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: rgba(244, 237, 228, 0.14);
|
||||
color: #f3e6d6;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.otp-brand {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
margin-top: 18px;
|
||||
margin-bottom: 18px;
|
||||
color: #f3e6d6;
|
||||
}
|
||||
|
||||
.otp-brand__fa {
|
||||
margin: 12px 0 0;
|
||||
font-size: 1.625rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.otp-brand__en {
|
||||
margin: 6px 0 0;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.28em;
|
||||
color: #ead7c0;
|
||||
}
|
||||
|
||||
.otp-card {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 22px 18px 18px;
|
||||
border: none;
|
||||
border-radius: 28px;
|
||||
background: #f4f1ed;
|
||||
box-shadow: 0 18px 40px rgba(12, 10, 8, 0.22);
|
||||
color: #2a2622;
|
||||
}
|
||||
|
||||
.otp-card__title {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.5;
|
||||
color: #1c1b19;
|
||||
}
|
||||
|
||||
.otp-card__lead {
|
||||
margin: 6px 0 0;
|
||||
text-align: center;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.7;
|
||||
color: #8a847c;
|
||||
}
|
||||
|
||||
.otp-card :deep(.form-error-banner) {
|
||||
margin-top: 14px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.otp-row {
|
||||
gap: clamp(0.25rem, 1.8vw, 0.625rem);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.otp-box {
|
||||
flex: 1 1 0;
|
||||
width: 0;
|
||||
min-width: 0;
|
||||
max-width: 48px;
|
||||
aspect-ratio: 48 / 52;
|
||||
height: auto;
|
||||
width: 52px;
|
||||
height: 56px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font-size: clamp(0.95rem, 4.2vw, 1.25rem);
|
||||
font-weight: 600;
|
||||
border: 1px solid #e4e0db;
|
||||
border-radius: 16px;
|
||||
background: #f7f5f1;
|
||||
color: #1c1b19;
|
||||
font-family: inherit;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
caret-color: var(--color-primary);
|
||||
transition: border-color 0.15s;
|
||||
@@ -333,15 +468,133 @@ onMounted(() => {
|
||||
.otp-box:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.otp-box--error {
|
||||
border-color: var(--color-danger);
|
||||
border-color: color-mix(in srgb, var(--color-danger) 55%, #e4e0db);
|
||||
}
|
||||
|
||||
.input-error-text {
|
||||
.otp-card__error {
|
||||
margin: 8px 4px 0;
|
||||
color: var(--color-danger);
|
||||
font-size: 0.75rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.otp-submit {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
margin-top: 16px;
|
||||
padding: 0 44px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: var(--color-primary);
|
||||
color: #f7f4ef;
|
||||
font-family: inherit;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.otp-submit:not(:disabled):hover,
|
||||
.otp-submit:not(:disabled):focus-visible {
|
||||
background: var(--color-primary-hover);
|
||||
}
|
||||
|
||||
.otp-submit:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.otp-submit__chevron {
|
||||
position: absolute;
|
||||
inset-inline-end: 16px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.otp-submit__spinner {
|
||||
position: absolute;
|
||||
inset-inline-start: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.28);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: otp-spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
.otp-resend {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
margin-top: 10px;
|
||||
border: 1px solid #d8d3cd;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: #6f6b66;
|
||||
font-family: inherit;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.otp-resend:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.otp-edit {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
margin-top: 14px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #8a847c;
|
||||
font-family: inherit;
|
||||
font-size: 0.8125rem;
|
||||
margin: 4px 0 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.otp-spacer {
|
||||
flex: 1 1 auto;
|
||||
min-height: 16px;
|
||||
}
|
||||
|
||||
.otp-watermark {
|
||||
margin: 0;
|
||||
padding-bottom: 8px;
|
||||
color: rgba(244, 237, 228, 0.58);
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.2em;
|
||||
line-height: 1.35;
|
||||
text-align: left;
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
@keyframes otp-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.otp-page {
|
||||
padding: 0;
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -473,7 +473,7 @@ async function submit() {
|
||||
}
|
||||
|
||||
.job-trigger.is-open {
|
||||
border-color: #1e4644;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.job-chevron {
|
||||
@@ -517,7 +517,7 @@ async function submit() {
|
||||
|
||||
.job-option.is-active {
|
||||
background: #e8f0ee;
|
||||
color: #1e4644;
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -532,7 +532,7 @@ async function submit() {
|
||||
padding: 0 44px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: #1e4644;
|
||||
background: var(--color-primary);
|
||||
color: #f7f4ef;
|
||||
font: inherit;
|
||||
font-size: 0.9375rem;
|
||||
@@ -543,7 +543,7 @@ async function submit() {
|
||||
|
||||
.register-submit:not(:disabled):hover,
|
||||
.register-submit:not(:disabled):focus-visible {
|
||||
background: #173835;
|
||||
background: var(--color-primary-hover);
|
||||
}
|
||||
|
||||
.register-submit:disabled {
|
||||
@@ -605,8 +605,8 @@ async function submit() {
|
||||
}
|
||||
|
||||
.register-terms__box.is-on {
|
||||
border-color: #1e4644;
|
||||
background: #1e4644;
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-primary);
|
||||
}
|
||||
|
||||
.register-terms__box svg {
|
||||
|
||||
@@ -0,0 +1,830 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import PageSkeleton from '@/components/ui/PageSkeleton.vue'
|
||||
import IconChevronBack from '@/components/icons/IconChevronBack.vue'
|
||||
import IconChevronLeft from '@/components/icons/IconChevronLeft.vue'
|
||||
import IconShare from '@/components/icons/IconShare.vue'
|
||||
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
|
||||
import { useCatalogStore } from '@/stores/catalog'
|
||||
import { useCrmStore } from '@/stores/crm'
|
||||
import { useCartStore } from '@/stores/cart'
|
||||
import { useFavoritesStore } from '@/stores/favorites'
|
||||
import { useGoBack } from '@/composables/useGoBack'
|
||||
import type { Catalog } from '@/types'
|
||||
import type { Product } from '@/types'
|
||||
import type { CatalogProductCard } from '@/types/crm'
|
||||
import {
|
||||
SEASON_LABEL,
|
||||
catalogDisplayTitle,
|
||||
catalogLatinName,
|
||||
groupCatalogPatterns,
|
||||
type PatternKind,
|
||||
} from '@/utils/catalogGallery'
|
||||
import { formatNumber } from '@/utils/format'
|
||||
import { demoProductsFor, findMockCalite } from '@/data/caliteCollections'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { goBack } = useGoBack('/catalogs')
|
||||
const catalogStore = useCatalogStore()
|
||||
const crm = useCrmStore()
|
||||
const cartStore = useCartStore()
|
||||
const favorites = useFavoritesStore()
|
||||
|
||||
const catalogId = computed(() => String(route.params.catalogId ?? ''))
|
||||
const catalog = ref<Catalog | null>(null)
|
||||
const products = ref<CatalogProductCard[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const adding = ref(false)
|
||||
const toast = ref('')
|
||||
const tab = ref<'all' | PatternKind>('all')
|
||||
|
||||
const title = computed(() => catalogDisplayTitle(catalog.value?.name || 'کالیته'))
|
||||
const heroImage = computed(
|
||||
() =>
|
||||
catalog.value?.imageUrl
|
||||
|| (catalog.value ? findMockCalite(catalog.value.id)?.imageUrl : undefined)
|
||||
|| products.value.find((item) => item.imageUrl)?.imageUrl,
|
||||
)
|
||||
|
||||
const latinName = computed(() => {
|
||||
const mock = catalog.value ? findMockCalite(catalog.value.id) : undefined
|
||||
if (mock?.latinName) return mock.latinName
|
||||
return catalog.value ? catalogLatinName(catalog.value) : ''
|
||||
})
|
||||
|
||||
const usageLine = computed(() => {
|
||||
const mock = catalog.value ? findMockCalite(catalog.value.id) : undefined
|
||||
if (mock?.subtitle) return mock.subtitle
|
||||
const id = products.value[0]?.categoryId
|
||||
if (id === 'trousers') return 'پارچههای شلواری'
|
||||
if (id === 'shirt') return 'پارچههای پیراهنی'
|
||||
return 'پارچههای کتوشلواری'
|
||||
})
|
||||
|
||||
const patterns = computed(() => groupCatalogPatterns(products.value))
|
||||
const visiblePatterns = computed(() =>
|
||||
tab.value === 'all' ? patterns.value : patterns.value.filter((item) => item.kind === tab.value),
|
||||
)
|
||||
|
||||
const seasonLabel = computed(() => {
|
||||
const mock = catalog.value ? findMockCalite(catalog.value.id) : undefined
|
||||
if (mock) return SEASON_LABEL[mock.season]
|
||||
const counts: Record<string, number> = {}
|
||||
for (const product of products.value) {
|
||||
counts[product.season] = (counts[product.season] ?? 0) + 1
|
||||
}
|
||||
const top = Object.entries(counts).sort((a, b) => b[1] - a[1])[0]?.[0] as keyof typeof SEASON_LABEL | undefined
|
||||
return top ? SEASON_LABEL[top] : SEASON_LABEL.all_season
|
||||
})
|
||||
|
||||
const tabs = computed(() => [
|
||||
{ id: 'all' as const, label: 'همه طرحها', count: patterns.value.length },
|
||||
{
|
||||
id: 'plain' as const,
|
||||
label: 'ساده',
|
||||
count: patterns.value.filter((item) => item.kind === 'plain').length,
|
||||
},
|
||||
{
|
||||
id: 'melange' as const,
|
||||
label: 'ملانژ',
|
||||
count: patterns.value.filter((item) => item.kind === 'melange').length,
|
||||
},
|
||||
{
|
||||
id: 'patterned' as const,
|
||||
label: 'طرحدار',
|
||||
count: patterns.value.filter((item) => item.kind === 'patterned').length,
|
||||
},
|
||||
])
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
catalog.value = null
|
||||
products.value = []
|
||||
tab.value = 'all'
|
||||
try {
|
||||
const companies = await catalogStore.loadCompanies()
|
||||
if (!catalogStore.getCatalog(catalogId.value)) {
|
||||
await Promise.all(companies.map((company) => catalogStore.loadCatalogs(company.id)))
|
||||
}
|
||||
catalog.value = catalogStore.getCatalog(catalogId.value) ?? null
|
||||
const mock = findMockCalite(catalogId.value)
|
||||
if (!catalog.value && mock) {
|
||||
catalog.value = {
|
||||
id: mock.id,
|
||||
companyId: '',
|
||||
name: mock.name,
|
||||
code: mock.latinName || mock.name,
|
||||
description: 'ترکیبی از اصالت، کیفیت و استایل مدرن',
|
||||
displayOrder: 0,
|
||||
isActive: true,
|
||||
}
|
||||
products.value = demoProductsFor(mock)
|
||||
return
|
||||
}
|
||||
if (!catalog.value) {
|
||||
error.value = 'کالیته مورد نظر یافت نشد.'
|
||||
return
|
||||
}
|
||||
products.value = await crm.loadBrowseProducts({
|
||||
catalogId: catalog.value.id,
|
||||
companyId: catalog.value.companyId,
|
||||
})
|
||||
if (!products.value.length && mock) {
|
||||
products.value = demoProductsFor(mock)
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'بارگذاری کالیته ناموفق بود.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await favorites.hydrate()
|
||||
await load()
|
||||
})
|
||||
watch(catalogId, load)
|
||||
|
||||
function openProduct(id: string) {
|
||||
if (id.startsWith('demo-')) return
|
||||
router.push({ path: '/product', query: { id } })
|
||||
}
|
||||
|
||||
function toggleFavorite(id: string) {
|
||||
void favorites.toggle(id)
|
||||
}
|
||||
|
||||
function requestPhysical() {
|
||||
router.push({
|
||||
path: '/catalog-request',
|
||||
query: { catalogId: catalogId.value },
|
||||
})
|
||||
}
|
||||
|
||||
function shareCatalog() {
|
||||
const url = window.location.href
|
||||
if (navigator.share) {
|
||||
void navigator.share({ title: title.value, url }).catch(() => undefined)
|
||||
return
|
||||
}
|
||||
void navigator.clipboard?.writeText(url)
|
||||
}
|
||||
|
||||
function toCartProduct(item: CatalogProductCard): Product {
|
||||
return {
|
||||
id: item.id,
|
||||
companyId: item.companyId || catalog.value?.companyId || '',
|
||||
catalogId: item.catalogId || catalogId.value,
|
||||
name: item.name,
|
||||
code: item.code,
|
||||
color: item.color || item.colors[0] || '—',
|
||||
minMeterage: item.minMeterage ?? 30,
|
||||
orderMultiplier: item.orderMultiplier ?? 10,
|
||||
basePrice: item.basePrice,
|
||||
isActive: true,
|
||||
}
|
||||
}
|
||||
|
||||
async function addAllToInquiry() {
|
||||
if (!products.value.length || adding.value) return
|
||||
adding.value = true
|
||||
toast.value = ''
|
||||
let added = 0
|
||||
let failed = ''
|
||||
try {
|
||||
for (const item of products.value) {
|
||||
const product = toCartProduct(item)
|
||||
const result = await cartStore.addItem(
|
||||
product,
|
||||
catalog.value?.code || item.catalogCode || item.code,
|
||||
product.minMeterage,
|
||||
)
|
||||
if (result.success) added += 1
|
||||
else failed = result.message || failed
|
||||
}
|
||||
toast.value = added
|
||||
? `${formatNumber(added)} طرح به سبد استعلام اضافه شد.`
|
||||
: failed || 'افزودن به سبد ناموفق بود.'
|
||||
if (added) {
|
||||
window.setTimeout(() => router.push('/cart'), 650)
|
||||
}
|
||||
} finally {
|
||||
adding.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page calite-detail">
|
||||
<div class="page__scroll page__scroll--with-nav calite-detail__scroll">
|
||||
<section class="calite-hero">
|
||||
<div
|
||||
class="calite-hero__photo"
|
||||
:style="heroImage ? { backgroundImage: `url(${heroImage})` } : undefined"
|
||||
/>
|
||||
<header class="calite-hero__nav">
|
||||
<button type="button" class="icon-btn" aria-label="بازگشت" @click="goBack">
|
||||
<IconChevronBack :size="22" color="#ffffff" />
|
||||
</button>
|
||||
<h1>{{ loading ? 'کالیته' : title }}</h1>
|
||||
<button type="button" class="icon-btn" aria-label="اشتراکگذاری" @click="shareCatalog">
|
||||
<IconShare :size="20" color="#ffffff" />
|
||||
</button>
|
||||
</header>
|
||||
<div v-if="!loading && !error" class="calite-hero__copy">
|
||||
<h2>{{ title }}</h2>
|
||||
<p class="calite-hero__usage">{{ usageLine }}</p>
|
||||
<strong v-if="latinName" class="calite-hero__latin">{{ latinName }}</strong>
|
||||
<span class="calite-hero__tagline">{{
|
||||
catalog?.description || 'ترکیبی از اصالت، کیفیت و استایل مدرن'
|
||||
}}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="calite-detail__body">
|
||||
<PageSkeleton v-if="loading" variant="cards" :rows="5" />
|
||||
<p v-else-if="error" class="calite-detail__error">{{ error }}</p>
|
||||
<template v-else>
|
||||
|
||||
<div class="attr-row">
|
||||
<span class="attr">
|
||||
<svg class="attr__icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
{{ formatNumber(patterns.length) }} طرح
|
||||
</span>
|
||||
<span class="attr">
|
||||
<svg class="attr__icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="4" stroke="currentColor" stroke-width="1.5" />
|
||||
<path
|
||||
d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
{{ seasonLabel }}
|
||||
</span>
|
||||
<span class="attr">
|
||||
<svg class="attr__icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
پرطرفدار
|
||||
</span>
|
||||
<span class="attr">
|
||||
<svg class="attr__icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M2.7 10.3a2.41 2.41 0 0 0 0 3.41l7.59 7.59a2.41 2.41 0 0 0 3.41 0l7.59-7.59a2.41 2.41 0 0 0 0-3.41L13.7 2.71a2.41 2.41 0 0 0-3.41 0z"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
کیفیت ممتاز
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button type="button" class="physical-cta" @click="requestPhysical">
|
||||
<span class="physical-cta__icon" aria-hidden="true">
|
||||
<BasicIcon icon="vuesax-linear:truck-time" :size="20" color="#ffffff" />
|
||||
</span>
|
||||
درخواست دریافت فیزیکی کالیته
|
||||
<span class="physical-cta__chevron" aria-hidden="true">
|
||||
<IconChevronLeft :size="18" color="#ffffff" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div class="pattern-tabs" role="tablist" aria-label="نوع طرح">
|
||||
<button
|
||||
v-for="item in tabs"
|
||||
:key="item.id"
|
||||
type="button"
|
||||
class="pattern-tab"
|
||||
:class="{ 'is-on': tab === item.id }"
|
||||
@click="tab = item.id"
|
||||
>
|
||||
{{ item.label }}<template v-if="item.id === 'all'"> ({{ formatNumber(item.count) }})</template>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="pattern-grid">
|
||||
<article
|
||||
v-for="pattern in visiblePatterns"
|
||||
:key="pattern.id"
|
||||
class="pattern-card"
|
||||
@click="openProduct(pattern.id)"
|
||||
>
|
||||
<div
|
||||
class="pattern-card__photo"
|
||||
:style="pattern.imageUrl ? { backgroundImage: `url(${pattern.imageUrl})` } : undefined"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="pattern-card__fav"
|
||||
:aria-pressed="favorites.isFavorite(pattern.id)"
|
||||
:aria-label="
|
||||
favorites.isFavorite(pattern.id) ? 'حذف از علاقهمندیها' : 'افزودن به علاقهمندیها'
|
||||
"
|
||||
@click.stop="toggleFavorite(pattern.id)"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M12.1 20.3s-6.6-4.1-8.4-8.1c-1.4-3.1.1-6.7 3.4-7.5 1.9-.5 3.7.3 4.9 1.8 1.2-1.5 3-2.3 4.9-1.8 3.3.8 4.8 4.4 3.4 7.5-1.8 4-8.2 8.1-8.2 8.1Z"
|
||||
:fill="favorites.isFavorite(pattern.id) ? 'var(--color-primary)' : 'none'"
|
||||
stroke="var(--color-primary)"
|
||||
stroke-width="1.5"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="pattern-card__meta">
|
||||
<strong>{{ pattern.name }}</strong>
|
||||
<div class="pattern-card__colors">
|
||||
<span>{{ formatNumber(pattern.colorCount) }} رنگ</span>
|
||||
<div class="swatches">
|
||||
<i
|
||||
v-for="(color, idx) in pattern.colors"
|
||||
:key="idx"
|
||||
:style="{ background: color }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<p v-if="!visiblePatterns.length" class="calite-detail__error">طرحی در این دسته نیست.</p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!loading && products.length" class="sticky-footer">
|
||||
<button type="button" class="inquiry-bar" :disabled="adding" @click="addAllToInquiry">
|
||||
{{ adding ? 'در حال افزودن...' : 'افزودن همه به سبد استعلام' }}
|
||||
<small>{{ formatNumber(patterns.length) }} طرح انتخاب میشود</small>
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="toast" class="toast">{{ toast }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.calite-detail {
|
||||
background: #f4f0ed;
|
||||
color: #2a2622;
|
||||
}
|
||||
|
||||
.calite-detail__nav,
|
||||
.calite-hero__nav {
|
||||
display: grid;
|
||||
grid-template-columns: 44px 1fr 44px;
|
||||
align-items: center;
|
||||
padding: calc(6px + env(safe-area-inset-top, 0px)) 8px 2px;
|
||||
}
|
||||
|
||||
.calite-hero__nav {
|
||||
position: absolute;
|
||||
inset-inline: 0;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.calite-hero__nav h1 {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #fff;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.calite-detail__scroll {
|
||||
padding: 0 0 112px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.calite-hero {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 340px;
|
||||
height: min(52vh, 440px);
|
||||
overflow: hidden;
|
||||
border-radius: 0;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.calite-hero__photo {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(160deg, #3d4a55, #151b22);
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
.calite-hero__photo::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(12, 10, 8, 0.28) 0%,
|
||||
rgba(12, 10, 8, 0.08) 38%,
|
||||
rgba(12, 10, 8, 0.55) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.calite-hero__copy {
|
||||
position: absolute;
|
||||
inset-inline: 20px 20px;
|
||||
bottom: 22px;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-end;
|
||||
gap: 2px;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.calite-hero__copy h2 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
text-shadow: 0 1px 8px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.calite-hero__usage {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 400;
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.calite-hero__latin {
|
||||
margin: 2px 0 0;
|
||||
font-family: Georgia, "Times New Roman", serif;
|
||||
font-size: 1.75rem;
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.02em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.calite-hero__tagline {
|
||||
margin-top: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 400;
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.calite-detail__body {
|
||||
padding: 8px 16px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.attr-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0;
|
||||
padding: 4px 0 8px;
|
||||
}
|
||||
|
||||
.attr {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 0;
|
||||
padding: 10px 8px;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: #6f6b66;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
line-height: 1.4;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.attr:not(:last-child)::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset-inline-end: 0;
|
||||
top: 16%;
|
||||
bottom: 16%;
|
||||
width: 1px;
|
||||
background: #e4dfd8;
|
||||
}
|
||||
|
||||
.attr__icon {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.physical-cta {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-height: 52px;
|
||||
padding: 0 44px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
font: inherit;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.physical-cta__icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.physical-cta__chevron {
|
||||
position: absolute;
|
||||
inset-inline-end: 16px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.pattern-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.pattern-tab {
|
||||
flex-shrink: 0;
|
||||
height: 34px;
|
||||
padding: 0 14px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: #ece8e2;
|
||||
color: #6a645e;
|
||||
font: inherit;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pattern-tab.is-on {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.pattern-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.pattern-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 0 0 10px;
|
||||
overflow: hidden;
|
||||
border: none;
|
||||
border-radius: 20px;
|
||||
background: #fff;
|
||||
text-align: start;
|
||||
cursor: pointer;
|
||||
color: #1c1c1c;
|
||||
box-shadow: 0 1px 3px rgba(40, 32, 24, 0.05);
|
||||
}
|
||||
|
||||
.pattern-card__photo {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 1.08;
|
||||
border-radius: 0;
|
||||
background: linear-gradient(145deg, #2a3544, #111827);
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
.pattern-card__fav {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pattern-card__meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 0 10px 2px;
|
||||
}
|
||||
|
||||
.pattern-card strong {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.pattern-card__colors {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pattern-card__colors span {
|
||||
color: #8a847c;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.swatches {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.swatches i {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.inquiry-bar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
width: 100%;
|
||||
min-height: 56px;
|
||||
border: none;
|
||||
border-radius: 20px;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
font: inherit;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.inquiry-bar small {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
opacity: 0.78;
|
||||
}
|
||||
|
||||
.inquiry-bar:disabled {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.calite-detail__error {
|
||||
margin: 8px 0 0;
|
||||
text-align: center;
|
||||
color: #8a847c;
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: calc(var(--nav-height) + 84px);
|
||||
z-index: 20;
|
||||
transform: translateX(-50%);
|
||||
padding: 8px 14px;
|
||||
border-radius: 999px;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
font-size: 0.75rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
html.dark .calite-detail {
|
||||
background: #0b0f14;
|
||||
color: #eef2f8;
|
||||
}
|
||||
|
||||
html.dark .calite-hero__nav h1,
|
||||
html.dark .icon-btn {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
html.dark .attr {
|
||||
background: transparent;
|
||||
color: #c5d0de;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
html.dark .attr:not(:last-child)::after {
|
||||
background: #2a3444;
|
||||
}
|
||||
|
||||
html.dark .physical-cta {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
html.dark .pattern-tab {
|
||||
background: #1a2230;
|
||||
color: #c5d0de;
|
||||
}
|
||||
|
||||
html.dark .pattern-tab.is-on {
|
||||
background: #e8e4df;
|
||||
color: #121821;
|
||||
}
|
||||
|
||||
html.dark .pattern-card {
|
||||
background: #121821;
|
||||
color: #eef2f8;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
html.dark .pattern-card__colors span,
|
||||
html.dark .calite-detail__error {
|
||||
color: #9aa8bc;
|
||||
}
|
||||
|
||||
html.dark .inquiry-bar {
|
||||
background: #5eb8ad;
|
||||
color: #0b0f14;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.calite-detail {
|
||||
padding: 0;
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.calite-hero {
|
||||
min-height: 380px;
|
||||
height: min(48vh, 460px);
|
||||
}
|
||||
|
||||
.toast {
|
||||
bottom: 96px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -32,10 +32,7 @@ onMounted(load)
|
||||
watch(companyId, load)
|
||||
|
||||
function openCatalog(catalogId: string) {
|
||||
router.push({
|
||||
path: '/products',
|
||||
query: { catalogId, companyId: companyId.value },
|
||||
})
|
||||
router.push({ name: 'catalog-detail', params: { catalogId } })
|
||||
}
|
||||
|
||||
function getProductCount(catalogId: string) {
|
||||
|
||||
@@ -21,6 +21,7 @@ const products = ref<CatalogProductCard[]>([])
|
||||
const quantities = ref<Record<string, number>>({})
|
||||
|
||||
const preselectId = computed(() => String(route.query.productId ?? ''))
|
||||
const catalogId = computed(() => String(route.query.catalogId ?? ''))
|
||||
|
||||
const selectedItems = computed(() =>
|
||||
products.value
|
||||
@@ -37,7 +38,9 @@ onMounted(async () => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const list = await crm.loadBrowseProducts()
|
||||
const list = await crm.loadBrowseProducts(
|
||||
catalogId.value ? { catalogId: catalogId.value } : undefined,
|
||||
)
|
||||
const selected = preselectId.value
|
||||
products.value = selected
|
||||
? [...list].sort((a, b) => Number(b.id === selected) - Number(a.id === selected))
|
||||
|
||||
@@ -1,165 +1,411 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import PageHeader from '@/components/layout/PageHeader.vue'
|
||||
import SearchField from '@/components/ui/SearchField.vue'
|
||||
import IconSearch from '@/components/icons/IconSearch.vue'
|
||||
import IconChevronBack from '@/components/icons/IconChevronBack.vue'
|
||||
import AuthThemeToggle from '@/components/ui/AuthThemeToggle.vue'
|
||||
import PageSkeleton from '@/components/ui/PageSkeleton.vue'
|
||||
import { useCatalogStore } from '@/stores/catalog'
|
||||
import { useCrmStore } from '@/stores/crm'
|
||||
import { useGoBack } from '@/composables/useGoBack'
|
||||
import type { Catalog } from '@/types'
|
||||
import IconChevronLeft from '@/components/icons/IconChevronLeft.vue'
|
||||
import type { CatalogProductCard } from '@/types/crm'
|
||||
import {
|
||||
buildCatalogGallery,
|
||||
filterCatalogGallery,
|
||||
type CatalogGalleryFilter,
|
||||
} from '@/utils/catalogGallery'
|
||||
import { formatNumber } from '@/utils/format'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { goBack } = useGoBack('/')
|
||||
const catalogStore = useCatalogStore()
|
||||
const crm = useCrmStore()
|
||||
|
||||
const catalogs = ref<Catalog[]>([])
|
||||
const products = ref<CatalogProductCard[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const searchOpen = ref(false)
|
||||
const search = ref('')
|
||||
const filter = ref<CatalogGalleryFilter>('all')
|
||||
|
||||
const filters: { id: CatalogGalleryFilter; label: string }[] = [
|
||||
{ id: 'all', label: 'همه' },
|
||||
{ id: 'newest', label: 'جدیدترین' },
|
||||
{ id: 'popular', label: 'محبوبترین' },
|
||||
{ id: 'season', label: 'بر اساس فصل' },
|
||||
]
|
||||
|
||||
const gallery = computed(() => buildCatalogGallery(catalogs.value, products.value))
|
||||
const cards = computed(() => filterCatalogGallery(gallery.value, filter.value, search.value))
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const companies = await catalogStore.loadCompanies()
|
||||
const lists = await Promise.all(companies.map((c) => catalogStore.loadCatalogs(c.id)))
|
||||
const [lists, browse] = await Promise.all([
|
||||
Promise.all(companies.map((company) => catalogStore.loadCatalogs(company.id))),
|
||||
crm.loadBrowseProducts().catch(() => [] as CatalogProductCard[]),
|
||||
])
|
||||
catalogs.value = lists.flat()
|
||||
products.value = browse
|
||||
const focusId = String(route.query.id ?? '')
|
||||
if (focusId) {
|
||||
const hit = catalogs.value.find((c) => c.id === focusId)
|
||||
if (hit) {
|
||||
await nextTick()
|
||||
openCatalog(hit)
|
||||
}
|
||||
if (focusId && catalogs.value.some((item) => item.id === focusId)) {
|
||||
router.replace({ name: 'catalog-detail', params: { catalogId: focusId } })
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'بارگذاری کالیتهها ناموفق بود.'
|
||||
} catch {
|
||||
catalogs.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function openCatalog(item: Catalog) {
|
||||
router.push({
|
||||
path: '/products',
|
||||
query: { catalogId: item.id, companyId: item.companyId },
|
||||
})
|
||||
function openCatalog(id: string) {
|
||||
router.push({ name: 'catalog-detail', params: { catalogId: id } })
|
||||
}
|
||||
|
||||
function companyName(companyId: string) {
|
||||
return catalogStore.getCompany(companyId)?.name ?? 'برند'
|
||||
function toggleSearch() {
|
||||
searchOpen.value = !searchOpen.value
|
||||
if (!searchOpen.value) search.value = ''
|
||||
}
|
||||
|
||||
function metaLine(card: (typeof gallery.value)[number]) {
|
||||
const extra =
|
||||
card.season === 'winter'
|
||||
? 'مناسب زمستان'
|
||||
: card.season === 'summer'
|
||||
? 'مناسب تابستان'
|
||||
: 'مناسب چهار فصل'
|
||||
return `${formatNumber(card.patternCount)} طرح • ${extra}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<PageHeader title="کالیتهها" show-back centered @back="router.push('/')" />
|
||||
<div class="page__scroll page__scroll--with-nav catalog-hub">
|
||||
<button type="button" class="hub-card" @click="router.push('/catalog-request')">
|
||||
<strong>درخواست کالیته فیزیکی</strong>
|
||||
<span>ثبت اقلام و پیگیری وضعیت</span>
|
||||
<div class="page calite-hub">
|
||||
<header class="calite-hub__nav">
|
||||
<button type="button" class="icon-btn" aria-label="بازگشت" @click="goBack">
|
||||
<IconChevronBack :size="20" />
|
||||
</button>
|
||||
<button type="button" class="hub-card" @click="router.push('/catalog-requests')">
|
||||
<strong>وضعیت درخواستها</strong>
|
||||
<span>پیگیری کالیتههای ثبتشده</span>
|
||||
</button>
|
||||
<button type="button" class="hub-card" @click="router.push('/categories')">
|
||||
<strong>مشاهده دستهبندیها</strong>
|
||||
<span>انتخاب بر اساس کاربرد و فصل</span>
|
||||
</button>
|
||||
<button type="button" class="hub-card" @click="router.push('/products')">
|
||||
<strong>همه محصولات</strong>
|
||||
<span>مشاهده کاتالوگ و ثبت سفارش</span>
|
||||
</button>
|
||||
|
||||
<PageSkeleton v-if="loading" variant="grid" />
|
||||
<p v-else-if="error">{{ error }}</p>
|
||||
<template v-else>
|
||||
<h2 class="section-title">کالیتههای موجود</h2>
|
||||
<h1>کالیتهها</h1>
|
||||
<div class="calite-hub__nav-end">
|
||||
<AuthThemeToggle variant="inline" class="mobile-only" />
|
||||
<button
|
||||
v-for="item in catalogs"
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
:aria-label="searchOpen ? 'بستن جستجو' : 'جستجو'"
|
||||
:aria-pressed="searchOpen"
|
||||
@click="toggleSearch"
|
||||
>
|
||||
<IconSearch :size="20" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="page__scroll page__scroll--with-nav calite-hub__scroll">
|
||||
<p class="calite-hub__lead">مجموعهای از بهترین پارچهها، در یک نگاه</p>
|
||||
|
||||
<SearchField
|
||||
v-if="searchOpen"
|
||||
:model-value="search"
|
||||
placeholder="جستجوی نام کالیته..."
|
||||
@update:model-value="search = $event"
|
||||
/>
|
||||
|
||||
<div class="calite-hub__chips" role="tablist" aria-label="مرتبسازی کالیتهها">
|
||||
<button
|
||||
v-for="item in filters"
|
||||
:key="item.id"
|
||||
type="button"
|
||||
class="calite-item"
|
||||
:class="{ 'calite-item--focus': String(route.query.id ?? '') === item.id }"
|
||||
@click="openCatalog(item)"
|
||||
class="calite-chip"
|
||||
:class="{ 'is-on': filter === item.id }"
|
||||
@click="filter = item.id"
|
||||
>
|
||||
<div class="calite-item__text">
|
||||
<strong>{{ item.name }}</strong>
|
||||
<span>کد {{ item.code }}</span>
|
||||
<em>{{ companyName(item.companyId) }}</em>
|
||||
</div>
|
||||
<IconChevronLeft :size="18" color="#9aa3a0" />
|
||||
{{ item.label }}
|
||||
</button>
|
||||
<p v-if="!catalogs.length" class="empty-hint">کالیتهای یافت نشد.</p>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<PageSkeleton v-if="loading" variant="cards" :rows="4" />
|
||||
<p v-else-if="!cards.length" class="calite-hub__empty">کالیتهای یافت نشد.</p>
|
||||
<div v-else class="calite-hub__list">
|
||||
<button
|
||||
v-for="card in cards"
|
||||
:key="card.id"
|
||||
type="button"
|
||||
class="calite-card"
|
||||
dir="ltr"
|
||||
@click="openCatalog(card.id)"
|
||||
>
|
||||
<span
|
||||
class="calite-card__photo"
|
||||
:style="card.imageUrl ? { backgroundImage: `url(${card.imageUrl})` } : undefined"
|
||||
/>
|
||||
<span v-if="card.badge" class="calite-card__badge">
|
||||
{{ card.badge === 'new' ? 'جدید' : 'پرفروش' }}
|
||||
</span>
|
||||
<div class="calite-card__copy" dir="rtl">
|
||||
<svg class="calite-card__arrow" width="12" height="16" viewBox="0 0 8 12" fill="none"
|
||||
aria-hidden="true">
|
||||
<path
|
||||
d="M1.5 1.5 6 6l-4.5 4.5"
|
||||
stroke="#fff"
|
||||
stroke-width="1.4"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<div class="mr-4 text-white">
|
||||
<div class="text-lg font-bold">{{ card.title }}</div>
|
||||
<div class="text-md">{{ card.subtitle }}</div>
|
||||
<div class="text-xs mt-4">{{ metaLine(card) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.catalog-hub {
|
||||
padding: 12px var(--page-padding) 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
.calite-hub {
|
||||
background: #f4f0ed;
|
||||
color: #2a2622;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 8px 0 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.hub-card,
|
||||
.calite-item {
|
||||
border: none;
|
||||
text-align: start;
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 14px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.calite-item {
|
||||
flex-direction: row;
|
||||
.calite-hub__nav {
|
||||
display: grid;
|
||||
grid-template-columns: 40px 1fr auto;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 12px 0;
|
||||
background: #f4f0ed;
|
||||
}
|
||||
|
||||
.calite-hub__nav-end {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.calite-hub__nav h1 {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.calite-hub__scroll {
|
||||
padding: 2px 16px 28px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.calite-item--focus {
|
||||
outline: 2px solid var(--color-primary);
|
||||
.calite-hub__lead {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
color: #8a847c;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.calite-item__text {
|
||||
.icon-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.calite-hub__chips {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 2px 0 4px;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.calite-hub__chips::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.calite-chip {
|
||||
flex-shrink: 0;
|
||||
height: 36px;
|
||||
padding: 0 16px;
|
||||
border: 1px solid #eae6e2;
|
||||
border-radius: 999px;
|
||||
background: #eae6e2;
|
||||
color: #615f5c;
|
||||
font: inherit;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.calite-chip.is-on {
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.calite-hub__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.calite-card {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 132px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: none;
|
||||
border-radius: 22px;
|
||||
background: #e7ddd4;
|
||||
cursor: pointer;
|
||||
text-align: start;
|
||||
color: #2c2824;
|
||||
}
|
||||
|
||||
.calite-card__photo {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
background: linear-gradient(160deg, #c4b7a8, #8a7b6c);
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
.calite-card__photo::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(90deg, transparent 28%, rgba(231, 221, 212, 0.42) 58%, rgba(231, 221, 212, 0.88) 100%);
|
||||
}
|
||||
|
||||
.calite-card__badge {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
padding: 5px 12px;
|
||||
border-radius: 999px;
|
||||
background: #e7ddd4;
|
||||
color: #000000;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.calite-card__copy {
|
||||
position: relative;
|
||||
align-items: center;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
min-height: 132px;
|
||||
padding: 16px 16px 16px 42%;
|
||||
}
|
||||
|
||||
.calite-item strong {
|
||||
display: block;
|
||||
font-size: 0.95rem;
|
||||
.calite-card__copy strong {
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.5;
|
||||
color: var(--color-text);
|
||||
line-height: 1.4;
|
||||
color: #2c2824;
|
||||
}
|
||||
|
||||
.hub-card span,
|
||||
.calite-item span,
|
||||
.calite-item em {
|
||||
.calite-card__sub {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 2px;
|
||||
color: #5c5752;
|
||||
}
|
||||
|
||||
.calite-card__arrow {
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.5;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
color: var(--color-text-muted);
|
||||
.calite-card__copy em {
|
||||
font-style: normal;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.45;
|
||||
color: #5c5752;
|
||||
}
|
||||
|
||||
.calite-card__copy small {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.45;
|
||||
color: #7a746c;
|
||||
}
|
||||
|
||||
.calite-hub__empty {
|
||||
margin: 16px 0 0;
|
||||
text-align: center;
|
||||
margin: 12px 0 0;
|
||||
color: #8a847c;
|
||||
}
|
||||
|
||||
html.dark .calite-hub,
|
||||
html.dark .calite-hub__nav {
|
||||
background: #0b0f14;
|
||||
color: #eef2f8;
|
||||
}
|
||||
|
||||
html.dark .calite-hub__lead,
|
||||
html.dark .calite-hub__empty {
|
||||
color: #9aa8bc;
|
||||
}
|
||||
|
||||
html.dark .calite-chip {
|
||||
border-color: #2a3444;
|
||||
background: #1a2230;
|
||||
color: #c5d0de;
|
||||
}
|
||||
|
||||
html.dark .calite-chip.is-on {
|
||||
border-color: #e8e4df;
|
||||
background: #e8e4df;
|
||||
color: #121821;
|
||||
}
|
||||
|
||||
html.dark .calite-card {
|
||||
background: #1a2230;
|
||||
color: #eef2f8;
|
||||
}
|
||||
|
||||
html.dark .calite-card__photo {
|
||||
background: linear-gradient(160deg, #3a4454, #151b24);
|
||||
}
|
||||
|
||||
html.dark .calite-card__photo::after {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 22%,
|
||||
rgba(11, 15, 20, 0.42) 55%,
|
||||
rgba(11, 15, 20, 0.88) 100%
|
||||
);
|
||||
}
|
||||
|
||||
html.dark .calite-card__badge {
|
||||
background: #e8e4df;
|
||||
color: #121821;
|
||||
}
|
||||
|
||||
html.dark .calite-card__copy strong,
|
||||
html.dark .calite-card__sub,
|
||||
html.dark .calite-card__copy em,
|
||||
html.dark .calite-card__copy small {
|
||||
color: #eef2f8;
|
||||
}
|
||||
|
||||
html.dark .calite-card__copy small {
|
||||
color: #c5d0de;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -769,7 +769,7 @@ onMounted(async () => {
|
||||
|
||||
.price-card__image {
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(145deg, #1e3a5f, #111827);
|
||||
background: linear-gradient(145deg, var(--color-primary), var(--color-primary-dark));
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
min-height: 88px;
|
||||
|
||||
Reference in New Issue
Block a user