This commit is contained in:
2026-09-13 15:36:48 +03:30
parent d6e424137d
commit d02c27fef4
98 changed files with 1886 additions and 234 deletions
+830
View File
@@ -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>