change nademitex2
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 'لغو شده'
|
||||
}
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -97,6 +97,7 @@ export interface CatalogProductCard {
|
||||
weaveType?: string
|
||||
colors: string[]
|
||||
colorImages?: Array<string | null>
|
||||
colorVariantImages?: string[][]
|
||||
imageUrls?: string[]
|
||||
tags: string[]
|
||||
isNew: boolean
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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] || ''
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' },
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user