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
+3 -3
View File
@@ -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
View File
@@ -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>
+6 -6
View File
@@ -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 {
+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>
+1 -4
View File
@@ -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) {
+4 -1
View File
@@ -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))
+349 -103
View File
@@ -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;