Files
nademitex_user/src/views/catalogs/CatalogDetailView.vue
T

941 lines
23 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, ref, 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 { colorSwatchStyle } from '@/utils/colorSwatch'
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="colorSwatchStyle(color, pattern.colorImages[idx])"
/>
</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 calite-detail__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: var(--color-bg);
color: var(--color-text);
}
.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: var(--color-border);
}
.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: var(--color-tab-inactive-bg);
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: var(--color-surface);
text-align: start;
cursor: pointer;
color: #1c1c1c;
box-shadow: none;
border: 1px solid var(--color-border);
}
.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);
background-size: cover;
background-position: center;
}
.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: var(--color-primary);
color: #0b0f14;
}
html.dark .calite-detail__footer.sticky-footer {
background: #0b0f14;
}
@media (min-width: 768px) {
.calite-detail {
padding: 0;
max-width: none;
margin: 0;
overflow: visible;
min-height: auto;
}
.calite-detail__scroll {
padding-bottom: 0;
}
.calite-hero {
min-height: 300px;
height: 340px;
}
.calite-hero__nav {
padding-top: 10px;
}
.calite-hero__copy {
inset-inline: 36px 36px;
bottom: 28px;
gap: 4px;
}
.calite-hero__copy h2 {
font-size: 2rem;
}
.calite-hero__usage {
font-size: 0.9375rem;
}
.calite-hero__latin {
font-size: 2.125rem;
}
.calite-hero__tagline {
font-size: 0.875rem;
}
.calite-detail__body {
padding: 18px 28px 28px;
max-width: 1120px;
margin-inline: auto;
width: 100%;
gap: 18px;
}
.attr-row {
max-width: 720px;
margin-inline: auto;
padding: 8px 0 4px;
}
.attr {
font-size: 0.875rem;
padding: 12px 12px;
}
.attr__icon {
width: 28px;
height: 28px;
}
.physical-cta {
max-width: 720px;
margin-inline: auto;
min-height: 56px;
}
.pattern-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
}
.calite-detail__footer.sticky-footer {
position: sticky;
bottom: 0;
inset-inline: auto;
width: 100%;
max-width: none;
padding: 12px 28px 24px;
background: var(--color-bg);
}
.inquiry-bar {
max-width: 1120px;
margin-inline: auto;
border-radius: 999px;
}
.toast {
bottom: 32px;
}
}
@media (min-width: 1200px) {
.calite-hero {
min-height: 360px;
height: 400px;
}
.calite-detail__body {
padding: 24px 40px 36px;
}
.pattern-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
}
.calite-detail__footer.sticky-footer {
padding-inline: 40px;
}
}
</style>