This commit is contained in:
2026-09-30 09:54:04 +03:30
parent 38d6b0d978
commit 9cf546bf8e
75 changed files with 872 additions and 56 deletions
+213
View File
@@ -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>
+24
View File
@@ -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 }
}
+80
View File
@@ -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 'لغو شده'
}
+6
View File
@@ -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',
+32
View File
@@ -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' },
)
}
+3
View File
@@ -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)
}
+1
View File
@@ -112,6 +112,7 @@ export interface CatalogProductCard {
minMeterage?: number
orderMultiplier?: number
basePrice?: number
stock?: number | null
}
export interface HomeBanner {
+6
View File
@@ -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;
+102 -4
View File
@@ -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;
+52
View File
@@ -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;
+291
View File
@@ -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' },
]