8/7/405
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useSlotBanners } from '@/composables/useSlotBanners'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
bannerSlot: string
|
||||
/** اگر بنری از API نباشد، چیزی رندر نشود */
|
||||
hideWhenEmpty?: boolean
|
||||
}>(),
|
||||
{ hideWhenEmpty: true },
|
||||
)
|
||||
|
||||
const router = useRouter()
|
||||
const { banners, loading } = useSlotBanners(props.bannerSlot)
|
||||
const index = ref(0)
|
||||
|
||||
const banner = computed(() => banners.value[index.value] ?? null)
|
||||
|
||||
const bannerStyle = computed(() => {
|
||||
const url = banner.value?.imageUrl?.trim()
|
||||
if (!url) return undefined
|
||||
return { backgroundImage: `url(${url})` }
|
||||
})
|
||||
|
||||
function goCta() {
|
||||
const link = banner.value?.ctaLink?.trim()
|
||||
if (!link) return
|
||||
if (/^https?:\/\//i.test(link)) {
|
||||
window.location.assign(link)
|
||||
return
|
||||
}
|
||||
router.push(link)
|
||||
}
|
||||
|
||||
function setSlide(i: number) {
|
||||
if (!banners.value.length) return
|
||||
index.value = ((i % banners.value.length) + banners.value.length) % banners.value.length
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
v-if="!hideWhenEmpty || banner"
|
||||
v-show="!loading && banner"
|
||||
class="slot-banner"
|
||||
:class="{ 'slot-banner--photo': !!banner?.imageUrl }"
|
||||
:style="bannerStyle"
|
||||
:aria-label="banner?.title || 'بنر'"
|
||||
>
|
||||
<template v-if="banner">
|
||||
<div class="slot-banner__content">
|
||||
<h2 v-if="banner.showTitle !== false && banner.title" class="slot-banner__title">
|
||||
{{ banner.title }}
|
||||
</h2>
|
||||
<p
|
||||
v-if="banner.showSubtitle !== false && banner.subtitle"
|
||||
class="slot-banner__tagline"
|
||||
>
|
||||
{{ banner.subtitle }}
|
||||
</p>
|
||||
<button
|
||||
v-if="banner.showCta !== false && banner.ctaLabel"
|
||||
type="button"
|
||||
class="slot-banner__cta"
|
||||
@click="goCta"
|
||||
>
|
||||
{{ banner.ctaLabel }}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="banners.length > 1"
|
||||
class="slot-banner__dots"
|
||||
role="tablist"
|
||||
aria-label="بنرها"
|
||||
>
|
||||
<button
|
||||
v-for="(item, i) in banners"
|
||||
:key="item.id"
|
||||
type="button"
|
||||
class="slot-banner__dot"
|
||||
:class="{ 'slot-banner__dot--active': i === index }"
|
||||
:aria-label="item.title"
|
||||
:aria-selected="i === index"
|
||||
@click="setSlide(i)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.slot-banner {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: flex-start;
|
||||
min-height: 148px;
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
background:
|
||||
linear-gradient(100deg, rgba(var(--color-primary-rgb), 0.75), rgba(var(--color-primary-rgb), 0.35)),
|
||||
#2f3430;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.slot-banner--photo {
|
||||
min-height: 156px;
|
||||
aspect-ratio: 2.35 / 1;
|
||||
max-height: 196px;
|
||||
}
|
||||
|
||||
.slot-banner--photo::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
background: linear-gradient(
|
||||
to top,
|
||||
rgba(0, 0, 0, 0.8) 0%,
|
||||
rgba(0, 0, 0, 0.48) 34%,
|
||||
rgba(0, 0, 0, 0.18) 62%,
|
||||
transparent 88%
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.slot-banner:not(.slot-banner--photo)::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
background: linear-gradient(
|
||||
to left,
|
||||
rgba(0, 0, 0, 0.42) 0%,
|
||||
rgba(0, 0, 0, 0.18) 42%,
|
||||
transparent 72%
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.slot-banner__content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 18px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.slot-banner__title {
|
||||
margin: 0;
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
text-shadow: 0 1px 8px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.slot-banner__tagline {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.55;
|
||||
opacity: 0.96;
|
||||
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.slot-banner__cta {
|
||||
align-self: flex-start;
|
||||
margin-top: 4px;
|
||||
padding: 8px 14px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
color: var(--color-primary);
|
||||
font: inherit;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.slot-banner__dots {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.slot-banner__dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 999px;
|
||||
border: none;
|
||||
padding: 0;
|
||||
background: rgba(255, 255, 255, 0.45);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.slot-banner__dot--active {
|
||||
background: #fff;
|
||||
width: 18px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,24 @@
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { fetchBanners, type AppBannerDto } from '@/services/agentApi'
|
||||
|
||||
export function useSlotBanners(slot: string) {
|
||||
const banners = ref<AppBannerDto[]>([])
|
||||
const loading = ref(true)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
banners.value = await fetchBanners(slot)
|
||||
} catch {
|
||||
banners.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load()
|
||||
})
|
||||
|
||||
return { banners, loading, reload: load }
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
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 'لغو شده'
|
||||
}
|
||||
@@ -233,6 +233,12 @@ 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',
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
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' },
|
||||
)
|
||||
}
|
||||
@@ -46,6 +46,7 @@ function mapProduct(p: {
|
||||
rollMeters?: number
|
||||
imageUrl?: string
|
||||
imageUrls?: string[]
|
||||
stock?: number | null
|
||||
}): CatalogProductCard {
|
||||
return {
|
||||
id: p.id,
|
||||
@@ -73,6 +74,7 @@ function mapProduct(p: {
|
||||
minMeterage: p.minMeterage ?? 30,
|
||||
orderMultiplier: p.orderMultiplier ?? 10,
|
||||
basePrice: p.basePrice ?? undefined,
|
||||
stock: p.stock ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,6 +134,7 @@ export const useCrmStore = defineStore('crm', () => {
|
||||
rollMeters?: number
|
||||
imageUrl?: string
|
||||
imageUrls?: string[]
|
||||
stock?: number | null
|
||||
}>(`/products/${id}`)
|
||||
return mapProduct(p)
|
||||
}
|
||||
|
||||
@@ -112,6 +112,7 @@ export interface CatalogProductCard {
|
||||
minMeterage?: number
|
||||
orderMultiplier?: number
|
||||
basePrice?: number
|
||||
stock?: number | null
|
||||
}
|
||||
|
||||
export interface HomeBanner {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { categoryIconOf, usageCategoryIconKey } from '@/utils/categoryIcons'
|
||||
import { useGoBack } from '@/composables/useGoBack'
|
||||
import AuthThemeToggle from '@/components/ui/AuthThemeToggle.vue'
|
||||
import PageSkeleton from '@/components/ui/PageSkeleton.vue'
|
||||
import AppSlotBanner from '@/components/banners/AppSlotBanner.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const { goBack } = useGoBack('/')
|
||||
@@ -75,6 +76,7 @@ function openSeason(id: string) {
|
||||
<p v-if="error">{{ error }}</p>
|
||||
<PageSkeleton v-else-if="loading" variant="grid" />
|
||||
<template v-else>
|
||||
<AppSlotBanner banner-slot="categories_hero" class="categories-page__hero" />
|
||||
<section>
|
||||
<h2 class="block-title">انتخاب بر اساس کاربرد</h2>
|
||||
<p class="block-sub">دسته موردنظر خود را انتخاب کنید</p>
|
||||
@@ -219,6 +221,10 @@ function openSeason(id: string) {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.categories-page__hero {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.cat-scroll {
|
||||
padding: 16px var(--page-padding) 24px;
|
||||
display: flex;
|
||||
|
||||
@@ -26,6 +26,9 @@ 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'
|
||||
|
||||
const USAGE_CHIP_LABEL: Record<string, string> = {
|
||||
suit: 'پارچه کتشلواری',
|
||||
@@ -64,8 +67,11 @@ 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 p = product.value
|
||||
if (!p) return ''
|
||||
@@ -153,6 +159,7 @@ async function load() {
|
||||
product.value = await crm.loadProductById(productId.value)
|
||||
selectedColor.value = 0
|
||||
rolls.value = minRolls.value
|
||||
await stockNotify.refreshSubscription()
|
||||
} catch (e) {
|
||||
product.value = null
|
||||
error.value = e instanceof Error ? e.message : 'محصول یافت نشد.'
|
||||
@@ -190,7 +197,7 @@ function snapMeterage(meters: number, step: number, min: number) {
|
||||
}
|
||||
|
||||
function openOrderModal() {
|
||||
if (!product.value) return
|
||||
if (!product.value || outOfStock.value) return
|
||||
orderModalError.value = ''
|
||||
orderModalOpen.value = true
|
||||
}
|
||||
@@ -274,6 +281,20 @@ 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>
|
||||
@@ -309,7 +330,10 @@ function onAddressSubmit(address: string) {
|
||||
<header class="desktop-page__head desktop-only pd-desk-head">
|
||||
<div>
|
||||
<h1 class="desktop-page__title">{{ product.name }}</h1>
|
||||
<p class="desktop-page__subtitle">کد محصول: {{ product.code }}</p>
|
||||
<p class="desktop-page__subtitle">
|
||||
کد محصول: {{ product.code }}
|
||||
<span v-if="outOfStock" class="pd-stock-badge">ناموجود</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="pd-desk-head__actions">
|
||||
<button
|
||||
@@ -488,7 +512,26 @@ function onAddressSubmit(address: string) {
|
||||
<span>حداقل سفارش</span>
|
||||
<strong>{{ formatNumber(minRolls) }} طاقه</strong>
|
||||
</div>
|
||||
<div class="cart-summary-panel__btn">
|
||||
<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>
|
||||
<div v-else class="cart-summary-panel__btn">
|
||||
<button type="button" class="btn-primary" @click="openOrderModal">
|
||||
<IconCart :size="18" color="#ffffff" />
|
||||
افزودن به سفارش
|
||||
@@ -509,7 +552,21 @@ function onAddressSubmit(address: string) {
|
||||
</div>
|
||||
|
||||
<footer class="pd-footer mobile-only sticky-footer">
|
||||
<div class="pd-footer__primary">
|
||||
<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>
|
||||
</div>
|
||||
<div v-else class="pd-footer__primary">
|
||||
<button type="button" class="btn-primary" @click="openOrderModal">
|
||||
<IconCart :size="18" color="#ffffff" />
|
||||
افزودن به سفارش
|
||||
@@ -993,6 +1050,47 @@ button.tag--link:hover {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.pd-stock-badge {
|
||||
display: inline-block;
|
||||
margin-inline-start: 8px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
color: #b91c1c;
|
||||
background: rgba(185, 28, 28, 0.12);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.stock-notify-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.stock-notify-panel__lead {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-primary);
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pd-footer__link {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 40px var(--page-padding);
|
||||
text-align: center;
|
||||
|
||||
@@ -16,11 +16,17 @@ import IconDiamond from '@/components/icons/IconDiamond.vue'
|
||||
import { useFavoritesStore } from '@/stores/favorites'
|
||||
import { colorSwatchStyle } from '@/utils/colorSwatch'
|
||||
import { categoryIconOf, usageCategoryIconKey } from '@/utils/categoryIcons'
|
||||
import { useSlotBanners } from '@/composables/useSlotBanners'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const crm = useCrmStore()
|
||||
const favorites = useFavoritesStore()
|
||||
const { banners: productsHeroBanners } = useSlotBanners('products_hero')
|
||||
const productsHero = computed(() => {
|
||||
if (categoryId.value || season.value || catalogId.value) return null
|
||||
return productsHeroBanners.value[0] ?? null
|
||||
})
|
||||
|
||||
const categoryId = computed(() => String(route.query.categoryId ?? ''))
|
||||
const catalogId = computed(() => String(route.query.catalogId ?? ''))
|
||||
@@ -77,7 +83,28 @@ function seasonIconKey(item: SeasonCategory) {
|
||||
return item.icon || (item.id === 'summer' ? 'bolt' : item.id === 'winter' ? 'feather' : 'diamond')
|
||||
}
|
||||
|
||||
function goBannerCta() {
|
||||
const link = bannerCopy.value.ctaLink?.trim()
|
||||
if (!link) return
|
||||
if (/^https?:\/\//i.test(link)) {
|
||||
window.location.assign(link)
|
||||
return
|
||||
}
|
||||
router.push(link)
|
||||
}
|
||||
|
||||
const bannerCopy = computed(() => {
|
||||
const hero = productsHero.value
|
||||
if (hero) {
|
||||
return {
|
||||
lead: undefined,
|
||||
headline: hero.showTitle !== false ? hero.title : undefined,
|
||||
tagline: hero.showSubtitle !== false ? hero.subtitle || undefined : undefined,
|
||||
showIcon: false,
|
||||
ctaLabel: hero.showCta !== false ? hero.ctaLabel || undefined : undefined,
|
||||
ctaLink: hero.ctaLink || undefined,
|
||||
}
|
||||
}
|
||||
if (categoryId.value) {
|
||||
const cat = crm.usageCategories.find((c) => c.id === categoryId.value)
|
||||
const parts = splitCategoryTitle(cat?.title ?? title.value)
|
||||
@@ -115,6 +142,7 @@ const bannerCopy = computed(() => {
|
||||
})
|
||||
|
||||
const bannerIcon = computed(() => {
|
||||
if (productsHero.value) return null
|
||||
if (bannerCopy.value.showIcon === false) return null
|
||||
if (categoryId.value) {
|
||||
const cat = crm.usageCategories.find((c) => c.id === categoryId.value)
|
||||
@@ -128,6 +156,8 @@ const bannerIcon = computed(() => {
|
||||
})
|
||||
|
||||
const bannerImageUrl = computed(() => {
|
||||
const heroUrl = productsHero.value?.imageUrl?.trim()
|
||||
if (heroUrl) return heroUrl
|
||||
if (categoryId.value) {
|
||||
const cat = crm.usageCategories.find((c) => c.id === categoryId.value)
|
||||
if (cat?.imageUrl) return cat.imageUrl
|
||||
@@ -472,6 +502,14 @@ onUnmounted(() => {
|
||||
</div>
|
||||
<h2 v-else-if="bannerCopy.headline" class="cat-banner__title">{{ bannerCopy.headline }}</h2>
|
||||
<p v-if="bannerCopy.tagline" class="cat-banner__tagline">{{ bannerCopy.tagline }}</p>
|
||||
<button
|
||||
v-if="bannerCopy.ctaLabel"
|
||||
type="button"
|
||||
class="cat-banner__cta"
|
||||
@click="goBannerCta"
|
||||
>
|
||||
{{ bannerCopy.ctaLabel }}
|
||||
</button>
|
||||
</div>
|
||||
<span
|
||||
v-if="bannerIcon && !bannerImageUrl"
|
||||
@@ -906,6 +944,20 @@ onUnmounted(() => {
|
||||
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.cat-banner__cta {
|
||||
align-self: flex-start;
|
||||
margin-top: 6px;
|
||||
padding: 8px 14px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
color: var(--color-primary);
|
||||
font: inherit;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cat-banner__icon-ring {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
<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,6 +92,7 @@ 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,6 +66,12 @@ 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