change nademitex2

This commit is contained in:
2026-09-30 22:10:12 +03:30
parent 9cf546bf8e
commit adb0ce886f
112 changed files with 433 additions and 694 deletions
+39
View File
@@ -2,6 +2,7 @@
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useSlotBanners } from '@/composables/useSlotBanners'
import { useTouchSwipe } from '@/composables/useTouchSwipe'
const props = withDefaults(
defineProps<{
@@ -38,6 +39,13 @@ function setSlide(i: number) {
if (!banners.value.length) return
index.value = ((i % banners.value.length) + banners.value.length) % banners.value.length
}
const { onTouchStart, onTouchEnd } = useTouchSwipe({
enabled: () => banners.value.length > 1,
onSwipe(dir) {
setSlide(index.value + dir)
},
})
</script>
<template>
@@ -48,6 +56,8 @@ function setSlide(i: number) {
:class="{ 'slot-banner--photo': !!banner?.imageUrl }"
:style="bannerStyle"
:aria-label="banner?.title || 'بنر'"
@touchstart.passive="onTouchStart"
@touchend.passive="onTouchEnd"
>
<template v-if="banner">
<div class="slot-banner__content">
@@ -93,6 +103,7 @@ function setSlide(i: number) {
<style scoped>
.slot-banner {
position: relative;
touch-action: pan-y;
isolation: isolate;
flex-shrink: 0;
display: flex;
@@ -210,4 +221,32 @@ function setSlide(i: number) {
background: #fff;
width: 18px;
}
/* دسکتاپ: همان نسبت و اندازه بنر صفحهٔ خانه */
@media (min-width: 768px) {
.slot-banner--photo {
max-height: none;
min-height: 0;
aspect-ratio: 2.1 / 1;
border-radius: 20px;
}
.slot-banner__content {
padding: 24px 28px 24px 36px;
}
.slot-banner__title {
font-size: 1.15rem;
}
}
@media (min-width: 1200px) {
.slot-banner--photo {
aspect-ratio: 2.2 / 1;
}
.slot-banner__title {
font-size: 1.25rem;
}
}
</style>
+27 -12
View File
@@ -2,7 +2,7 @@
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import type { CatalogProductCard } from '@/types/crm'
import { formatNumber } from '@/utils/format'
import { colorSwatchStyle } from '@/utils/colorSwatch'
import { colorSwatchStyle, colorVariantThumb } from '@/utils/colorSwatch'
import BaseButton from '@/components/ui/BaseButton.vue'
const props = defineProps<{
@@ -94,7 +94,7 @@ function submit() {
<div class="product-order-modal__table-head" aria-hidden="true">
<span>رنگ</span>
<span>تعداد</span>
<span>طاقه</span>
</div>
<ul class="product-order-modal__rows">
@@ -102,7 +102,12 @@ function submit() {
<div class="product-order-row__color">
<span
class="product-order-row__swatch"
:style="colorSwatchStyle(color, product.colorImages?.[idx] || product.imageUrls?.[idx])"
:style="
colorSwatchStyle(
color,
colorVariantThumb(product.colorImages, product.colorVariantImages, idx),
)
"
:title="color"
/>
<span v-if="color" class="product-order-row__label">{{ color }}</span>
@@ -134,15 +139,19 @@ function submit() {
<p v-if="error" class="product-order-modal__error">{{ error }}</p>
<footer class="product-order-modal__footer">
<div class="product-order-modal__price">
<span>قیمت</span>
<strong v-if="estimatedPrice != null">
{{ formatNumber(estimatedPrice) }} تومان
</strong>
<strong v-else-if="totalRolls > 0">
{{ formatNumber(totalMeters) }} متر
</strong>
<strong v-else>—</strong>
<div class="product-order-modal__prices">
<div v-if="basePrice && basePrice > 0" class="product-order-modal__price">
<span>قیمت هر متر</span>
<strong>{{ formatNumber(basePrice) }} تومان</strong>
</div>
<div class="product-order-modal__price">
<span>جمع تقریبی</span>
<strong v-if="estimatedPrice != null">{{ formatNumber(estimatedPrice) }} تومان</strong>
<strong v-else-if="totalRolls > 0 && (!basePrice || basePrice <= 0)">
{{ formatNumber(totalMeters) }} متر
</strong>
<strong v-else>—</strong>
</div>
</div>
<p v-if="minRolls > 0" class="product-order-modal__hint">
حداقل سفارش: {{ formatNumber(minRolls) }} طاقه
@@ -288,6 +297,12 @@ function submit() {
gap: 8px;
}
.product-order-modal__prices {
display: flex;
flex-direction: column;
gap: 6px;
}
.product-order-modal__price {
display: flex;
align-items: baseline;
-80
View File
@@ -1,80 +0,0 @@
import { ref } from 'vue'
import {
cancelStockAlert,
fetchMyStockAlerts,
subscribeStockAlert,
type StockAlertRequest,
} from '@/services/stockAlerts'
import { ApiError } from '@/services/http'
export function isProductOutOfStock(stock: number | null | undefined): boolean {
return stock != null && stock <= 0
}
export function useStockNotify(productId: () => string) {
const subscribed = ref(false)
const loading = ref(false)
const error = ref('')
async function refreshSubscription() {
const id = productId()
if (!id) {
subscribed.value = false
return
}
try {
const rows = await fetchMyStockAlerts()
subscribed.value = rows.some((r) => r.productId === id && r.active)
} catch {
subscribed.value = false
}
}
async function subscribe() {
const id = productId()
if (!id || loading.value) return
loading.value = true
error.value = ''
try {
await subscribeStockAlert(id)
subscribed.value = true
return true
} catch (e) {
error.value =
e instanceof ApiError || e instanceof Error
? e.message
: 'ثبت درخواست خبر موجودی ناموفق بود.'
return false
} finally {
loading.value = false
}
}
async function unsubscribe() {
const id = productId()
if (!id || loading.value) return
loading.value = true
error.value = ''
try {
await cancelStockAlert(id)
subscribed.value = false
return true
} catch (e) {
error.value =
e instanceof ApiError || e instanceof Error
? e.message
: 'لغو درخواست ناموفق بود.'
return false
} finally {
loading.value = false
}
}
return { subscribed, loading, error, refreshSubscription, subscribe, unsubscribe }
}
export function stockAlertStatusLabel(row: StockAlertRequest): string {
if (row.active) return 'در انتظار موجودی'
if (row.notifiedAt) return 'اطلاع داده شد'
return 'لغو شده'
}
+33
View File
@@ -0,0 +1,33 @@
/**
* اسلاید با سوایپ افقی (موبایل). سوایپ عمودی نادیده گرفته می‌شود تا اسکرول صفحه مختل نشود.
*/
export function useTouchSwipe(options: {
enabled: () => boolean
onSwipe: (direction: 1 | -1) => void
thresholdPx?: number
}) {
let startX = 0
let startY = 0
function onTouchStart(event: TouchEvent) {
if (!options.enabled()) return
const touch = event.touches[0]
if (!touch) return
startX = touch.clientX
startY = touch.clientY
}
function onTouchEnd(event: TouchEvent) {
if (!options.enabled()) return
const touch = event.changedTouches[0]
if (!touch) return
const dx = touch.clientX - startX
const dy = touch.clientY - startY
const threshold = options.thresholdPx ?? 40
if (Math.abs(dx) < threshold) return
if (Math.abs(dy) > Math.abs(dx)) return
options.onSwipe(dx < 0 ? 1 : -1)
}
return { onTouchStart, onTouchEnd }
}
+10 -20
View File
@@ -59,15 +59,14 @@ const router = createRouter({
},
{
path: '/catalogs',
name: 'catalogs',
component: () => import('@/views/catalogs/CatalogsHubView.vue'),
meta: { requiresAuth: true },
redirect: '/products',
},
{
path: '/catalogs/:catalogId',
name: 'catalog-detail',
component: () => import('@/views/catalogs/CatalogProductRedirectView.vue'),
meta: { requiresAuth: true },
redirect: (to) => ({
path: '/product',
query: { id: String(to.params.catalogId ?? '') },
}),
},
{
path: '/catalog-requests',
@@ -131,19 +130,16 @@ const router = createRouter({
},
{
path: '/companies/:companyId/catalogs',
name: 'company-catalogs',
component: () => import('@/views/catalogs/CatalogListView.vue'),
meta: { requiresAuth: true },
redirect: (to) => ({
path: '/products',
query: { companyId: String(to.params.companyId ?? '') },
}),
},
{
path: '/companies/:companyId/catalogs/:catalogId/products',
name: 'company-products',
redirect: (to) => ({
path: '/products',
query: {
catalogId: String(to.params.catalogId ?? ''),
companyId: String(to.params.companyId ?? ''),
},
query: { companyId: String(to.params.companyId ?? '') },
}),
},
{
@@ -233,12 +229,6 @@ const router = createRouter({
component: () => import('@/views/profile/AddressesView.vue'),
meta: { requiresAuth: true },
},
{
path: '/profile/stock-alerts',
name: 'profile-stock-alerts',
component: () => import('@/views/profile/StockAlertsView.vue'),
meta: { requiresAuth: true },
},
{
path: '/profile/security',
name: 'profile-security',
-2
View File
@@ -122,7 +122,6 @@ export async function fetchCatalogCategories() {
export async function fetchBrowseProducts(query?: {
categoryId?: string
season?: string
catalogId?: string
companyId?: string
}) {
return apiRequest<BrowseProductDto[]>('/products', {
@@ -130,7 +129,6 @@ export async function fetchBrowseProducts(query?: {
activeOnly: true,
categoryId: query?.categoryId,
season: query?.season,
catalogId: query?.catalogId,
companyId: query?.companyId,
},
})
-32
View File
@@ -1,32 +0,0 @@
import { apiRequest } from './http'
export type StockAlertRequest = {
id: string
productId: string
productName: string
productCode: string
catalogCode: string
color: string
stock: number | null
imageUrl: string
active: boolean
createdAt: string
notifiedAt: string | null
}
export async function fetchMyStockAlerts() {
return apiRequest<StockAlertRequest[]>('/stock-alerts')
}
export async function subscribeStockAlert(productId: string) {
return apiRequest<StockAlertRequest>('/stock-alerts', {
body: { productId },
})
}
export async function cancelStockAlert(productId: string) {
return apiRequest<{ productId: string; active: boolean }>(
`/stock-alerts/${productId}/cancel`,
{ method: 'POST' },
)
}
+5 -1
View File
@@ -40,6 +40,7 @@ function mapProduct(p: {
weaveType?: string
colors?: string[]
colorImages?: Array<string | null>
colorVariantImages?: string[][]
tags?: string[]
isNew?: boolean
subtitle?: string
@@ -60,9 +61,11 @@ function mapProduct(p: {
weaveType: p.weaveType,
colors: p.colors?.length ? p.colors : ['#6b7280'],
colorImages: p.colorImages,
colorVariantImages: p.colorVariantImages,
tags: p.tags ?? [],
isNew: !!p.isNew,
imageUrl: p.imageUrl || p.colorImages?.find(Boolean) || p.imageUrls?.[0],
imageUrl:
p.imageUrls?.[0] || p.imageUrl || p.colorImages?.find(Boolean) || undefined,
imageUrls: p.imageUrls,
subtitle: p.subtitle,
rollMeters: p.rollMeters ?? 50,
@@ -128,6 +131,7 @@ export const useCrmStore = defineStore('crm', () => {
weaveType?: string
colors?: string[]
colorImages?: Array<string | null>
colorVariantImages?: string[][]
tags?: string[]
isNew?: boolean
subtitle?: string
+1
View File
@@ -97,6 +97,7 @@ export interface CatalogProductCard {
weaveType?: string
colors: string[]
colorImages?: Array<string | null>
colorVariantImages?: string[][]
imageUrls?: string[]
tags: string[]
isNew: boolean
+9 -57
View File
@@ -1,62 +1,14 @@
import type { Router } from 'vue-router'
import type { CatalogProductCard } from '@/types/crm'
import { demoProductsFor, findMockCalite } from '@/data/caliteCollections'
import { groupCatalogPatterns } from '@/utils/catalogGallery'
import { useCatalogStore } from '@/stores/catalog'
import { useCrmStore } from '@/stores/crm'
function pickPrimaryProductId(products: CatalogProductCard[]): string | null {
if (!products.length) return null
const patterns = groupCatalogPatterns(products)
const first = patterns[0]?.items[0] ?? products[0]
return first?.id ?? null
}
export async function resolveCatalogPrimaryProductId(
catalogId: string,
knownProducts?: CatalogProductCard[],
): Promise<string | null> {
const fromKnown = knownProducts?.filter((item) => item.catalogId === catalogId) ?? []
if (fromKnown.length) {
return pickPrimaryProductId(fromKnown)
}
const catalogStore = useCatalogStore()
const crm = useCrmStore()
let catalog = catalogStore.getCatalog(catalogId)
if (!catalog) {
const companies = await catalogStore.loadCompanies()
await Promise.all(companies.map((company) => catalogStore.loadCatalogs(company.id)))
catalog = catalogStore.getCatalog(catalogId)
}
if (catalog) {
const products = await crm.loadBrowseProducts({
catalogId: catalog.id,
companyId: catalog.companyId,
})
const productId = pickPrimaryProductId(products)
if (productId) return productId
}
const mock = findMockCalite(catalogId)
if (mock) {
return pickPrimaryProductId(demoProductsFor(mock))
}
return null
}
export async function navigateToCatalogProduct(
router: Router,
catalogId: string,
knownProducts?: CatalogProductCard[],
) {
const productId = await resolveCatalogPrimaryProductId(catalogId, knownProducts)
if (productId) {
await router.replace({ path: '/product', query: { id: productId } })
/** @deprecated legacy catalog URLs — treat id as product id */
export async function navigateToCatalogProduct(router: Router, productOrLegacyId: string) {
if (!productOrLegacyId) {
await router.replace('/products')
return
}
await router.replace({ name: 'catalogs' })
await router.replace({ path: '/product', query: { id: productOrLegacyId } })
}
export async function resolveCatalogPrimaryProductId(productId: string): Promise<string | null> {
return productId || null
}
+14 -5
View File
@@ -23,12 +23,21 @@ export function colorSwatchStyle(color: string, imageUrl?: string | null) {
return { background: '#c5cdc9' }
}
export function colorImageAt(
colors: string[],
colorImages: Array<string | null | undefined> | undefined,
/** تصویر کارت / لیست: اول گالری عمومی، بعد نمونهٔ رنگ */
export function productListThumb(
imageUrls: string[] | undefined,
index: number,
colorImages: Array<string | null | undefined> | undefined,
fallback?: string,
) {
return colorImages?.[index] || imageUrls?.[index] || fallback || ''
return imageUrls?.[0] || colorImages?.find(Boolean) || fallback || ''
}
export function colorVariantThumb(
colorImages: Array<string | null | undefined> | undefined,
colorVariantImages: string[][] | undefined,
index: number,
) {
const list = colorVariantImages?.[index]
if (list?.length) return list[0]
return colorImages?.[index] || ''
}
+1 -1
View File
@@ -13,7 +13,7 @@ onMounted(() => store.hydrate())
<template>
<div class="page catalog-requests-page">
<PageHeader class="mobile-only" title="وضعیت کالیته‌ها" show-back centered @back="router.push('/catalogs')" />
<PageHeader class="mobile-only" title="وضعیت کالیته‌ها" show-back centered @back="router.push('/products')" />
<header class="desktop-page__head desktop-only catalog-requests-desk-head">
<div>
+2 -2
View File
@@ -57,7 +57,7 @@ onMounted(async () => {
products.value = browse
const focusId = String(route.query.id ?? '')
if (focusId && catalogs.value.some((item) => item.id === focusId)) {
await navigateToCatalogProduct(router, focusId, products.value)
await navigateToCatalogProduct(router, focusId)
}
} catch {
catalogs.value = []
@@ -67,7 +67,7 @@ onMounted(async () => {
})
function openCatalog(id: string) {
void navigateToCatalogProduct(router, id, products.value)
void navigateToCatalogProduct(router, id)
}
function focusSearch() {
+16 -1
View File
@@ -23,6 +23,7 @@ import AuthThemeToggle from '@/components/ui/AuthThemeToggle.vue'
import PageSkeleton from '@/components/ui/PageSkeleton.vue'
import UserAvatar from '@/components/ui/UserAvatar.vue'
import HomeSuggestionsSection from '@/components/home/HomeSuggestionsSection.vue'
import { useTouchSwipe } from '@/composables/useTouchSwipe'
const router = useRouter()
const { goBack } = useGoBack('/')
@@ -145,6 +146,14 @@ function setBanner(index: number) {
startBannerRotation()
}
const { onTouchStart: onHeroTouchStart, onTouchEnd: onHeroTouchEnd } = useTouchSwipe({
enabled: () => banners.value.length > 1,
onSwipe(dir) {
moveBanner(bannerIndex.value + dir, dir)
startBannerRotation()
},
})
const displayFirst = computed(() => auth.user?.firstName || 'کاربر')
const displayLast = computed(() => auth.user?.lastName || '')
const homeLevel = computed(() => {
@@ -268,7 +277,12 @@ async function toggleFavorite(productId: string) {
<p v-if="error" class="home-error">{{ error }}</p>
<PageSkeleton v-else-if="loading" variant="home" />
<template v-else>
<section class="hero-banner" :aria-label="banner.title">
<section
class="hero-banner"
:aria-label="banner.title"
@touchstart.passive="onHeroTouchStart"
@touchend.passive="onHeroTouchEnd"
>
<TransitionGroup :name="heroAnim" tag="div" class="hero-banner__stage">
<article
:key="banner.id"
@@ -512,6 +526,7 @@ async function toggleFavorite(productId: string) {
color: #fff;
overflow: hidden;
isolation: isolate;
touch-action: pan-y;
}
.hero-banner__stage {
+8 -3
View File
@@ -7,7 +7,7 @@ import { useFavoritesStore } from '@/stores/favorites'
import { useCrmStore } from '@/stores/crm'
import type { CatalogProductCard } from '@/types/crm'
import { formatNumber } from '@/utils/format'
import { colorImageAt, colorSwatchStyle } from '@/utils/colorSwatch'
import { colorSwatchStyle, colorVariantThumb, productListThumb } from '@/utils/colorSwatch'
import IconBookmark from '@/components/icons/IconBookmark.vue'
import IconChevronLeft from '@/components/icons/IconChevronLeft.vue'
@@ -43,7 +43,7 @@ function seasonLabel(season: string) {
}
function productImage(item: CatalogProductCard) {
return colorImageAt(item.colors, item.colorImages, item.imageUrls, 0, item.imageUrl)
return productListThumb(item.imageUrls, item.colorImages, item.imageUrl)
}
function openProduct(id: string) {
@@ -108,7 +108,12 @@ async function remove(id: string) {
v-for="(color, idx) in item.colors.slice(0, 6)"
:key="idx"
class="swatch"
:style="colorSwatchStyle(color, item.colorImages?.[idx] || item.imageUrls?.[idx])"
:style="
colorSwatchStyle(
color,
colorVariantThumb(item.colorImages, item.colorVariantImages, idx),
)
"
/>
</div>
<button type="button" class="details-link" @click.stop="openProduct(item.id)">
+103 -80
View File
@@ -8,7 +8,7 @@ import { useCatalogStore } from '@/stores/catalog'
import type { CatalogProductCard } from '@/types/crm'
import type { Product } from '@/types'
import { formatNumber } from '@/utils/format'
import { colorImageAt, colorSwatchStyle } from '@/utils/colorSwatch'
import { colorSwatchStyle, colorVariantThumb } from '@/utils/colorSwatch'
import IconChevronBack from '@/components/icons/IconChevronBack.vue'
import IconCart from '@/components/icons/IconCart.vue'
import quickCatalogRequestIcon from '@/assets/images/quick-catalog-request.webp'
@@ -26,9 +26,7 @@ import {
import { useCatalogRequest } from '@/composables/useCatalogRequest'
import AddressCompletionModal from '@/components/catalogs/AddressCompletionModal.vue'
import ProductOrderModal from '@/components/products/ProductOrderModal.vue'
import IconBell from '@/components/icons/IconBell.vue'
import { isProductOutOfStock, useStockNotify } from '@/composables/useStockNotify'
import { useAppToast } from '@/composables/useAppToast'
import { useTouchSwipe } from '@/composables/useTouchSwipe'
const USAGE_CHIP_LABEL: Record<string, string> = {
suit: 'پارچه کت‌شلواری',
@@ -54,6 +52,7 @@ const product = ref<CatalogProductCard | null>(null)
const loading = ref(true)
const error = ref<string | null>(null)
const selectedColor = ref(0)
const selectedGalleryIndex = ref(0)
const rolls = ref(1)
const adding = ref(false)
const showAllSpecs = ref(false)
@@ -67,15 +66,39 @@ const {
submitAddressAndRequest,
closeAddressModal,
} = useCatalogRequest()
const toast = useAppToast()
const stockNotify = useStockNotify(() => productId.value)
const rollMeters = computed(() => product.value?.rollMeters ?? 50)
const outOfStock = computed(() => isProductOutOfStock(product.value?.stock))
const currentGalleryImage = computed(() => {
const outOfStock = computed(() => {
const stock = product.value?.stock
return stock != null && stock <= 0
})
const galleryImages = computed(() => {
const urls = product.value?.imageUrls ?? []
return urls.filter((url) => typeof url === 'string' && url.trim().length > 0)
})
const currentGalleryImage = computed(
() => galleryImages.value[selectedGalleryIndex.value] ?? '',
)
function setGalleryIndex(index: number) {
const len = galleryImages.value.length
if (!len) return
selectedGalleryIndex.value = ((index % len) + len) % len
}
const { onTouchStart: onGalleryTouchStart, onTouchEnd: onGalleryTouchEnd } = useTouchSwipe({
enabled: () => galleryImages.value.length > 1,
onSwipe(dir) {
setGalleryIndex(selectedGalleryIndex.value + dir)
},
})
const selectedVariantImages = computed(() => {
const p = product.value
if (!p) return ''
return colorImageAt(p.colors, p.colorImages, p.imageUrls, selectedColor.value, p.imageUrl)
if (!p) return []
const idx = selectedColor.value
const fromList = p.colorVariantImages?.[idx]
if (fromList?.length) return fromList
const one = p.colorImages?.[idx]
return one ? [one] : []
})
const currentColorName = computed(() => product.value?.colors[selectedColor.value] ?? '')
const orderMeters = computed(() => rolls.value * rollMeters.value)
@@ -158,8 +181,8 @@ async function load() {
await Promise.all([favorites.hydrate(), catalogStore.loadCompanies()])
product.value = await crm.loadProductById(productId.value)
selectedColor.value = 0
selectedGalleryIndex.value = 0
rolls.value = minRolls.value
await stockNotify.refreshSubscription()
} catch (e) {
product.value = null
error.value = e instanceof Error ? e.message : 'محصول یافت نشد.'
@@ -281,20 +304,6 @@ function onAddressSubmit(address: string) {
void submitAddressAndRequest(address)
}
async function toggleStockNotify() {
if (stockNotify.subscribed.value) {
const ok = await stockNotify.unsubscribe()
if (ok) toast.show('درخواست خبر موجودی لغو شد.', { tone: 'success' })
return
}
const ok = await stockNotify.subscribe()
if (ok) {
toast.show('درخواست ثبت شد. با موجود شدن محصول اعلان می‌گیرید.', { tone: 'success' })
} else if (stockNotify.error.value) {
toast.show(stockNotify.error.value, { tone: 'error' })
}
}
</script>
<template>
@@ -363,7 +372,11 @@ async function toggleStockNotify() {
<div class="product-page__layout">
<div class="product-page__main">
<div class="gallery">
<div class="gallery__stage">
<div
class="gallery__stage"
@touchstart.passive="onGalleryTouchStart"
@touchend.passive="onGalleryTouchEnd"
>
<div
class="gallery__main"
:style="
@@ -373,37 +386,44 @@ async function toggleStockNotify() {
backgroundSize: 'cover',
backgroundPosition: 'center',
}
: colorSwatchStyle(currentColorName)
: colorSwatchStyle(product.color || currentColorName)
"
/>
<div
v-if="product.colors.length > 1"
v-if="galleryImages.length > 1"
class="gallery__dots"
role="tablist"
aria-label="تصاویر محصول"
aria-label="تصاویر عمومی محصول"
>
<button
v-for="(color, idx) in product.colors"
v-for="(_, idx) in galleryImages"
:key="idx"
type="button"
class="gallery__dot"
:class="{ 'gallery__dot--active': selectedColor === idx }"
:aria-label="color || `تصویر ${idx + 1}`"
:aria-selected="selectedColor === idx"
@click="selectedColor = idx"
:class="{ 'gallery__dot--active': selectedGalleryIndex === idx }"
:aria-label="`تصویر ${idx + 1}`"
:aria-selected="selectedGalleryIndex === idx"
@click="selectedGalleryIndex = idx"
/>
</div>
</div>
<div class="gallery__thumbs">
<div v-if="galleryImages.length > 1" class="gallery__thumbs">
<button
v-for="(color, idx) in product.colors"
:key="idx"
v-for="(url, idx) in galleryImages"
:key="url + idx"
type="button"
class="thumb"
:class="{ 'thumb--active': selectedColor === idx }"
@click="selectedColor = idx"
:class="{ 'thumb--active': selectedGalleryIndex === idx }"
@click="selectedGalleryIndex = idx"
>
<span class="thumb__swatch" :style="colorSwatchStyle(color, product.colorImages?.[idx] || product.imageUrls?.[idx])" />
<span
class="thumb__swatch"
:style="{
backgroundImage: `url(${url})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}"
/>
</button>
</div>
</div>
@@ -471,10 +491,28 @@ async function toggleStockNotify() {
type="button"
class="color-dot"
:class="{ 'color-dot--active': selectedColor === idx }"
:style="colorSwatchStyle(color, product.colorImages?.[idx] || product.imageUrls?.[idx])"
:style="
colorSwatchStyle(
color,
colorVariantThumb(product.colorImages, product.colorVariantImages, idx),
)
"
@click="selectedColor = idx"
/>
</div>
<div v-if="selectedVariantImages.length" class="variant-images" role="list">
<div
v-for="(url, vIdx) in selectedVariantImages"
:key="`${selectedColor}-${vIdx}-${url}`"
class="variant-images__item"
role="listitem"
:style="{
backgroundImage: `url(${url})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}"
/>
</div>
</section>
<section class="pd-section">
@@ -512,25 +550,9 @@ async function toggleStockNotify() {
<span>حداقل سفارش</span>
<strong>{{ formatNumber(minRolls) }} طاقه</strong>
</div>
<div v-if="outOfStock" class="stock-notify-panel">
<p class="stock-notify-panel__lead">این محصول فعلاً موجود نیست.</p>
<button
type="button"
class="btn-primary"
:disabled="stockNotify.loading.value"
@click="toggleStockNotify"
>
<IconBell :size="18" />
{{ stockNotify.subscribed.value ? 'لغو خبر موجودی' : 'خبرم کن وقتی موجود شد' }}
</button>
<button
type="button"
class="btn-link"
@click="router.push('/profile/stock-alerts')"
>
مشاهده درخواست‌های من
</button>
</div>
<p v-if="outOfStock" class="cart-summary-panel__unavailable">
این محصول فعلاً موجود نیست.
</p>
<div v-else class="cart-summary-panel__btn">
<button type="button" class="btn-primary" @click="openOrderModal">
<IconCart :size="18" color="#ffffff" />
@@ -553,18 +575,7 @@ async function toggleStockNotify() {
<footer class="pd-footer mobile-only sticky-footer">
<div v-if="outOfStock" class="pd-footer__primary">
<button
type="button"
class="btn-primary"
:disabled="stockNotify.loading.value"
@click="toggleStockNotify"
>
<IconBell :size="18" color="#ffffff" />
{{ stockNotify.subscribed.value ? 'لغو خبر موجودی' : 'خبرم کن وقتی موجود شد' }}
</button>
<button type="button" class="btn-link pd-footer__link" @click="router.push('/profile/stock-alerts')">
درخواست‌های خبر موجودی
</button>
<p class="pd-footer__unavailable">این محصول فعلاً موجود نیست.</p>
</div>
<div v-else class="pd-footer__primary">
<button type="button" class="btn-primary" @click="openOrderModal">
@@ -658,6 +669,7 @@ async function toggleStockNotify() {
.gallery__stage {
position: relative;
touch-action: pan-y;
}
.gallery__main {
@@ -909,6 +921,21 @@ async function toggleStockNotify() {
padding-inline-start: 20px;
}
.variant-images {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 4px;
}
.variant-images__item {
width: 72px;
height: 72px;
border-radius: 12px;
border: 1px solid var(--color-border);
background: #eef1f0 center / cover no-repeat;
}
.color-dot {
appearance: none;
-webkit-appearance: none;
@@ -1062,17 +1089,13 @@ button.tag--link:hover {
vertical-align: middle;
}
.stock-notify-panel {
display: flex;
flex-direction: column;
gap: 10px;
}
.stock-notify-panel__lead {
.cart-summary-panel__unavailable,
.pd-footer__unavailable {
margin: 0;
font-size: 0.85rem;
color: var(--color-text-muted);
line-height: 1.5;
text-align: center;
}
.btn-link {
+102 -28
View File
@@ -14,36 +14,59 @@ import IconHeart from '@/components/icons/IconHeart.vue'
import IconCatalog from '@/components/icons/IconCatalog.vue'
import IconDiamond from '@/components/icons/IconDiamond.vue'
import { useFavoritesStore } from '@/stores/favorites'
import { colorSwatchStyle } from '@/utils/colorSwatch'
import { colorSwatchStyle, colorVariantThumb } from '@/utils/colorSwatch'
import { categoryIconOf, usageCategoryIconKey } from '@/utils/categoryIcons'
import { useSlotBanners } from '@/composables/useSlotBanners'
import { useTouchSwipe } from '@/composables/useTouchSwipe'
const route = useRoute()
const router = useRouter()
const crm = useCrmStore()
const favorites = useFavoritesStore()
const { banners: productsHeroBanners } = useSlotBanners('products_hero')
const productsHeroIndex = ref(0)
watch(productsHeroBanners, () => {
productsHeroIndex.value = 0
})
const productsHeroSlideCount = computed(() =>
categoryId.value || season.value ? 0 : productsHeroBanners.value.length,
)
const productsHero = computed(() => {
if (categoryId.value || season.value || catalogId.value) return null
return productsHeroBanners.value[0] ?? null
if (categoryId.value || season.value) return null
const list = productsHeroBanners.value
if (!list.length) return null
const i =
((productsHeroIndex.value % list.length) + list.length) % list.length
return list[i] ?? null
})
function setProductsHeroSlide(i: number) {
const len = productsHeroBanners.value.length
if (!len) return
productsHeroIndex.value = ((i % len) + len) % len
}
const { onTouchStart: onBannerTouchStart, onTouchEnd: onBannerTouchEnd } = useTouchSwipe({
enabled: () => productsHeroSlideCount.value > 1,
onSwipe(dir) {
setProductsHeroSlide(productsHeroIndex.value + dir)
},
})
const categoryId = computed(() => String(route.query.categoryId ?? ''))
const catalogId = computed(() => String(route.query.catalogId ?? ''))
const companyId = computed(() => String(route.query.companyId ?? ''))
const season = computed(() => String(route.query.season ?? '') as SeasonKind | '')
const filterAxis = computed(() => String(route.query.filterAxis ?? ''))
const q = computed(() => String(route.query.q ?? ''))
const searchDraft = ref(String(route.query.q ?? ''))
const products = ref<CatalogProductCard[]>([])
const catalogTitle = ref('')
const loading = ref(true)
const error = ref<string | null>(null)
const sortMode = ref<'newest' | 'name'>('newest')
const filterOpen = ref(false)
const filterDraftCategory = ref('')
const filterDraftSeason = ref('')
const showFilter = computed(() => !catalogId.value)
const showFilter = computed(() => true)
const categoryFilterOptions = computed(() => [
{ value: '', label: 'همه دسته‌ها' },
@@ -58,7 +81,6 @@ const seasonFilterOptions = computed(() => [
const hasActiveFilters = computed(() => !!categoryId.value || !!season.value)
const title = computed(() => {
if (catalogId.value && catalogTitle.value) return catalogTitle.value
if (categoryId.value) {
const cat = crm.usageCategories.find((c) => c.id === categoryId.value)
return cat?.title ?? 'محصولات'
@@ -125,14 +147,6 @@ const bannerCopy = computed(() => {
showIcon: item?.showIcon !== false,
}
}
if (catalogId.value) {
return {
lead: 'کالیته',
headline: catalogTitle.value || title.value,
tagline: 'انتخاب محصول از کالیته',
showIcon: true,
}
}
return {
lead: undefined,
headline: title.value,
@@ -166,9 +180,6 @@ const bannerImageUrl = computed(() => {
const item = crm.seasonCategories.find((s) => s.id === season.value)
if (item?.imageUrl) return item.imageUrl
}
if (catalogId.value) {
return products.value.find((item) => item.imageUrl)?.imageUrl
}
return undefined
})
@@ -176,7 +187,7 @@ const bannerStyle = computed(() =>
bannerImageUrl.value ? { backgroundImage: `url(${bannerImageUrl.value})` } : undefined,
)
const showSearch = computed(() => !categoryId.value && !catalogId.value)
const showSearch = computed(() => !categoryId.value)
/** Cross-filter chips: usage entry → season chips; season entry → usage chips. */
const crossChipKind = computed<'season' | 'usage'>(() => {
@@ -221,7 +232,6 @@ function applySearch(value: string) {
path: '/products',
query: {
...(categoryId.value ? { categoryId: categoryId.value } : {}),
...(catalogId.value ? { catalogId: catalogId.value } : {}),
...(companyId.value ? { companyId: companyId.value } : {}),
...(season.value ? { season: season.value } : {}),
...(axis ? { filterAxis: axis } : {}),
@@ -238,11 +248,8 @@ async function load() {
products.value = await crm.loadBrowseProducts({
categoryId: categoryId.value || undefined,
season: season.value || undefined,
catalogId: catalogId.value || undefined,
companyId: companyId.value || undefined,
})
const fromCatalog = products.value[0]
catalogTitle.value = fromCatalog?.catalogName || fromCatalog?.catalogCode || ''
} catch (e) {
error.value = e instanceof Error ? e.message : 'بارگذاری محصولات ناموفق بود.'
products.value = []
@@ -255,7 +262,7 @@ onMounted(async () => {
await favorites.hydrate()
await load()
})
watch([categoryId, season, catalogId, companyId], load)
watch([categoryId, season, companyId], load)
watch(q, (value) => {
searchDraft.value = value
})
@@ -267,7 +274,6 @@ function setSeason(value: string) {
path: '/products',
query: {
...(nextCategory ? { categoryId: nextCategory } : {}),
...(catalogId.value ? { catalogId: catalogId.value } : {}),
...(companyId.value ? { companyId: companyId.value } : {}),
...(value ? { season: value } : {}),
...(axis ? { filterAxis: axis } : {}),
@@ -283,7 +289,6 @@ function setCategory(value: string) {
path: '/products',
query: {
...(value ? { categoryId: value } : {}),
...(catalogId.value ? { catalogId: catalogId.value } : {}),
...(companyId.value ? { companyId: companyId.value } : {}),
...(nextSeason ? { season: nextSeason } : {}),
...(axis ? { filterAxis: axis } : {}),
@@ -491,6 +496,8 @@ onUnmounted(() => {
class="cat-banner"
:class="{ 'cat-banner--photo': !!bannerImageUrl }"
:style="bannerStyle"
@touchstart.passive="onBannerTouchStart"
@touchend.passive="onBannerTouchEnd"
>
<div class="cat-banner__content">
<p v-if="bannerCopy.lead" class="cat-banner__lead">{{ bannerCopy.lead }}</p>
@@ -518,6 +525,23 @@ onUnmounted(() => {
>
<component :is="bannerIcon" :size="26" color="#ffffff" />
</span>
<div
v-if="productsHeroSlideCount > 1"
class="cat-banner__dots"
role="tablist"
aria-label="بنرها"
>
<button
v-for="(item, i) in productsHeroBanners"
:key="item.id"
type="button"
class="cat-banner__dot"
:class="{ 'cat-banner__dot--active': i === productsHeroIndex }"
:aria-label="item.title"
:aria-selected="i === productsHeroIndex"
@click="setProductsHeroSlide(i)"
/>
</div>
</section>
<div v-if="showFilter" class="chips-row scroll-x">
@@ -643,7 +667,12 @@ onUnmounted(() => {
v-for="(color, idx) in item.colors.slice(0, 6)"
:key="idx"
class="swatch"
:style="colorSwatchStyle(color, item.colorImages?.[idx] || item.imageUrls?.[idx])"
:style="
colorSwatchStyle(
color,
colorVariantThumb(item.colorImages, item.colorVariantImages, idx),
)
"
/>
</div>
<button type="button" class="details-link" @click.stop="openProduct(item.id)">
@@ -836,6 +865,7 @@ onUnmounted(() => {
.cat-banner {
position: relative;
touch-action: pan-y;
isolation: isolate;
flex-shrink: 0;
display: flex;
@@ -980,6 +1010,31 @@ onUnmounted(() => {
flex-shrink: 0;
}
.cat-banner__dots {
position: absolute;
inset-inline: 0;
bottom: 10px;
display: flex;
justify-content: center;
gap: 6px;
z-index: 2;
}
.cat-banner__dot {
width: 6px;
height: 6px;
border-radius: 999px;
border: none;
padding: 0;
background: rgba(255, 255, 255, 0.45);
cursor: pointer;
}
.cat-banner__dot--active {
background: #fff;
width: 18px;
}
.chips-row {
flex-shrink: 0;
margin-inline: calc(-1 * var(--page-padding));
@@ -1257,7 +1312,18 @@ onUnmounted(() => {
}
.cat-banner--photo {
max-height: 220px;
max-height: none;
min-height: 0;
aspect-ratio: 2.1 / 1;
border-radius: 20px;
}
.cat-banner--photo .cat-banner__content {
padding: 24px 28px 24px 36px;
}
.cat-banner__title {
font-size: 1.15rem;
}
}
@@ -1265,5 +1331,13 @@ onUnmounted(() => {
.products-list {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.cat-banner--photo {
aspect-ratio: 2.2 / 1;
}
.cat-banner__title {
font-size: 1.25rem;
}
}
</style>
-291
View File
@@ -1,291 +0,0 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import ProfileSubpageLayout from '@/views/profile/components/ProfileSubpageLayout.vue'
import PageSkeleton from '@/components/ui/PageSkeleton.vue'
import BaseButton from '@/components/ui/BaseButton.vue'
import {
cancelStockAlert,
fetchMyStockAlerts,
type StockAlertRequest,
} from '@/services/stockAlerts'
import { stockAlertStatusLabel, isProductOutOfStock } from '@/composables/useStockNotify'
import { useAppToast } from '@/composables/useAppToast'
import { ApiError } from '@/services/http'
import { formatDateTime, formatNumber } from '@/utils/format'
const router = useRouter()
const toast = useAppToast()
const rows = ref<StockAlertRequest[]>([])
const loading = ref(true)
const error = ref('')
const cancellingId = ref<string | null>(null)
const activeRows = computed(() => rows.value.filter((r) => r.active))
const historyRows = computed(() => rows.value.filter((r) => !r.active))
async function load() {
loading.value = true
error.value = ''
try {
rows.value = await fetchMyStockAlerts()
} catch (e) {
error.value =
e instanceof ApiError || e instanceof Error ? e.message : 'بارگذاری درخواست‌ها ناموفق بود.'
} finally {
loading.value = false
}
}
onMounted(load)
function openProduct(row: StockAlertRequest) {
router.push({ path: '/product', query: { id: row.productId } })
}
async function cancel(row: StockAlertRequest) {
if (!row.active || cancellingId.value) return
cancellingId.value = row.id
try {
await cancelStockAlert(row.productId)
toast.show('درخواست خبر موجودی لغو شد.', { tone: 'success' })
await load()
} catch (e) {
toast.show(
e instanceof ApiError || e instanceof Error ? e.message : 'لغو درخواست ناموفق بود.',
{ tone: 'error' },
)
} finally {
cancellingId.value = null
}
}
</script>
<template>
<ProfileSubpageLayout
title="خبر موجودی"
subtitle="وقتی محصول ناموجود دوباره موجود شود، در اعلان‌ها مطلع می‌شوید"
>
<p v-if="error" class="stock-alerts-error">{{ error }}</p>
<PageSkeleton v-if="loading" variant="list" />
<template v-else>
<div v-if="!rows.length" class="stock-alerts-empty">
<p>هنوز درخواستی ثبت نکرده‌اید.</p>
<p class="stock-alerts-empty__hint">
در صفحهٔ محصول ناموجود، دکمه «خبرم کن» را بزنید.
</p>
<BaseButton size="sm" variant="outline" @click="router.push('/products')">
مشاهده محصولات
</BaseButton>
</div>
<template v-else>
<section v-if="activeRows.length" class="stock-alerts-section">
<h2 class="stock-alerts-section__title">فعال ({{ formatNumber(activeRows.length) }})</h2>
<ul class="stock-alerts-list">
<li v-for="row in activeRows" :key="row.id" class="stock-alert-card">
<button type="button" class="stock-alert-card__main" @click="openProduct(row)">
<span
class="stock-alert-card__thumb"
:class="{ 'stock-alert-card__thumb--empty': !row.imageUrl }"
:style="row.imageUrl ? { backgroundImage: `url(${row.imageUrl})` } : undefined"
/>
<div class="stock-alert-card__body">
<span class="stock-alert-card__badge stock-alert-card__badge--active">
{{ stockAlertStatusLabel(row) }}
</span>
<strong class="stock-alert-card__name">{{ row.productName }}</strong>
<p class="stock-alert-card__meta">
کالیته {{ row.catalogCode }} · {{ row.color }}
<span dir="ltr">({{ row.productCode }})</span>
</p>
<p class="stock-alert-card__meta">
ثبت: {{ formatDateTime(row.createdAt) }}
<span v-if="isProductOutOfStock(row.stock)"> · موجودی: ۰</span>
</p>
</div>
</button>
<button
type="button"
class="stock-alert-card__cancel"
:disabled="cancellingId === row.id"
@click="cancel(row)"
>
لغو
</button>
</li>
</ul>
</section>
<section v-if="historyRows.length" class="stock-alerts-section">
<h2 class="stock-alerts-section__title">تاریخچه</h2>
<ul class="stock-alerts-list">
<li
v-for="row in historyRows"
:key="row.id"
class="stock-alert-card stock-alert-card--muted"
>
<button type="button" class="stock-alert-card__main" @click="openProduct(row)">
<span
class="stock-alert-card__thumb"
:class="{ 'stock-alert-card__thumb--empty': !row.imageUrl }"
:style="row.imageUrl ? { backgroundImage: `url(${row.imageUrl})` } : undefined"
/>
<div class="stock-alert-card__body">
<span
class="stock-alert-card__badge"
:class="{ 'stock-alert-card__badge--done': !!row.notifiedAt }"
>
{{ stockAlertStatusLabel(row) }}
</span>
<strong class="stock-alert-card__name">{{ row.productName }}</strong>
<p class="stock-alert-card__meta">
{{ row.notifiedAt ? `اطلاع: ${formatDateTime(row.notifiedAt)}` : formatDateTime(row.createdAt) }}
</p>
</div>
</button>
</li>
</ul>
</section>
</template>
</template>
</ProfileSubpageLayout>
</template>
<style scoped>
.stock-alerts-error {
margin: 0 0 12px;
color: var(--color-danger);
font-size: 0.8125rem;
}
.stock-alerts-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 32px 16px;
text-align: center;
color: var(--color-text-muted);
}
.stock-alerts-empty__hint {
margin: 0;
font-size: 0.8125rem;
line-height: 1.6;
max-width: 280px;
}
.stock-alerts-section {
margin-bottom: 20px;
}
.stock-alerts-section__title {
margin: 0 0 10px;
font-size: 0.875rem;
font-weight: 700;
color: var(--color-text-muted);
}
.stock-alerts-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.stock-alert-card {
display: flex;
align-items: stretch;
gap: 8px;
padding: 10px;
border-radius: 14px;
border: 1px solid var(--color-border);
background: var(--color-elevated);
}
.stock-alert-card--muted {
opacity: 0.88;
}
.stock-alert-card__main {
flex: 1;
display: flex;
gap: 12px;
min-width: 0;
border: none;
background: transparent;
padding: 0;
text-align: start;
cursor: pointer;
color: inherit;
font: inherit;
}
.stock-alert-card__thumb {
width: 56px;
height: 56px;
flex-shrink: 0;
border-radius: 10px;
background: var(--color-bg) center / cover no-repeat;
border: 1px solid var(--color-border);
}
.stock-alert-card__thumb--empty {
background: var(--color-primary-soft);
}
.stock-alert-card__body {
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.stock-alert-card__badge {
align-self: flex-start;
padding: 2px 8px;
border-radius: 999px;
font-size: 0.6875rem;
font-weight: 700;
background: var(--color-bg);
color: var(--color-text-muted);
}
.stock-alert-card__badge--active {
background: var(--color-primary-soft);
color: var(--color-primary);
}
.stock-alert-card__badge--done {
background: rgba(34, 197, 94, 0.15);
color: var(--color-success-dark, #15803d);
}
.stock-alert-card__name {
font-size: 0.9375rem;
line-height: 1.35;
}
.stock-alert-card__meta {
margin: 0;
font-size: 0.75rem;
color: var(--color-text-muted);
line-height: 1.5;
}
.stock-alert-card__cancel {
flex-shrink: 0;
align-self: center;
border: none;
background: transparent;
color: var(--color-danger);
font: inherit;
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
padding: 8px;
}
</style>
@@ -92,7 +92,6 @@ const companyRows = computed(() => [
const quickAccess = [
{ label: 'حساب‌های بانکی', icon: IconCashSettlement },
{ label: 'آدرس‌ها', icon: IconMapPin, path: '/profile/addresses' },
{ label: 'خبر موجودی', icon: IconBell, path: '/profile/stock-alerts' },
{ label: 'حمایت و پشتیبانی', icon: IconHeadset, href: 'tel:02112345678' },
{ label: 'فاکتورهای من', icon: IconOrders, path: '/orders' },
]
@@ -66,12 +66,6 @@ const customerQuickActions: QuickAction[] = [
{ id: 'favorites', label: 'علاقه‌مندی‌ها', to: '/favorites', useHeart: true },
{ id: 'order-status', label: 'وضعیت سفارش', to: '/orders/latest-status', icon: 'vuesax-linear:timer-1' },
{ id: 'addresses', label: 'آدرس‌ها', to: '/profile/addresses', icon: 'vuesax-linear:location' },
{
id: 'stock-alerts',
label: 'خبر موجودی',
to: '/profile/stock-alerts',
icon: 'vuesax-linear:notification',
},
{ id: 'catalog', label: 'درخواست کالیته', to: '/categories', icon: 'vuesax-linear:box-1' },
]