feat(banners): make products hero slides addable and fix theme slots

Admins can add/remove any number of product banners, and profile/fund slots keep relative storage paths for refresh-safe previews.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-26 18:31:41 +03:30
co-authored by Cursor
parent 464995a43e
commit 52057805ff
4 changed files with 204 additions and 58 deletions
@@ -20,7 +20,7 @@
:bordered="false"
class="profile-banners__collapse"
>
<a-collapse-panel v-for="group in groups" :key="group.id">
<a-collapse-panel v-for="group in fixedGroups" :key="group.id">
<template #header>
<div class="profile-banners__collapse-header">
<BaseIcon :icon="group.icon" size="18" class="profile-banners__collapse-icon" />
@@ -69,12 +69,93 @@
</article>
</div>
</a-collapse-panel>
<a-collapse-panel key="products">
<template #header>
<div class="profile-banners__collapse-header">
<BaseIcon icon="vuesax-linear:box" size="18" class="profile-banners__collapse-icon" />
<div class="profile-banners__collapse-text">
<span class="profile-banners__collapse-title">محصولات (موبایل)</span>
<span class="profile-banners__collapse-desc">اسلایدر بنرهای بالای صفحهٔ محصولات</span>
</div>
<a-badge
:count="state.productsHero.length"
:number-style="{ backgroundColor: '#d4af37', color: '#fff', fontSize: '11px' }"
/>
</div>
</template>
<div class="profile-banners__panel-actions">
<BaseButton size="small" type="primary" @click="addProductsHero">
<span>افزودن بنر</span>
<BaseIcon icon="vuesax-linear:add" size="17" />
</BaseButton>
</div>
<div v-if="!state.productsHero.length" class="profile-banners__empty">
<p class="profile-banners__empty-title">هنوز بنر محصولی اضافه نشده</p>
<p class="profile-banners__empty-text">اولین اسلاید را بسازید تا در بالای صفحهٔ محصولات دیده شود</p>
<BaseButton type="primary" @click="addProductsHero">
<span>افزودن بنر</span>
<BaseIcon icon="vuesax-linear:add" size="17" />
</BaseButton>
</div>
<div v-else class="profile-banners__list">
<article
v-for="(pair, index) in state.productsHero"
:key="`products-hero-${index}`"
class="profile-banner-card"
>
<header class="profile-banner-card__head profile-banner-card__head--row">
<div>
<div class="profile-banner-card__title">بنر محصولات {{ index + 1 }}</div>
<p class="profile-banner-card__desc">اسلاید {{ index + 1 }} اسلایدر صفحهٔ محصولات</p>
</div>
<button
type="button"
class="profile-banner-card__remove"
title="حذف بنر"
@click="removeProductsHero(index)"
>
<BaseIcon icon="vuesax-linear:trash" size="18" />
</button>
</header>
<div class="profile-banner-card__themes">
<div class="profile-banner-card__theme">
<AdminLabelWithHelp label="تم روشن" :help="settingsProfileBannersThemeHelp" />
<div class="profile-banner-card__upload">
<BannerImageUpload
:image-url="pair.light"
@upload="onProductsHeroUpload(index, 'light', $event)"
@remove="onProductsHeroRemove(index, 'light')"
/>
</div>
</div>
<div class="profile-banner-card__theme">
<AdminLabelWithHelp label="تم تیره" :help="settingsProfileBannersThemeHelp" />
<div class="profile-banner-card__upload profile-banner-card__upload--dark">
<BannerImageUpload
:image-url="pair.dark"
@upload="onProductsHeroUpload(index, 'dark', $event)"
@remove="onProductsHeroRemove(index, 'dark')"
/>
</div>
</div>
</div>
</article>
</div>
</a-collapse-panel>
</a-collapse>
</section>
</template>
<script setup lang="ts">
import type { AppThemeBannerKey } from '@/model/appBanners'
import {
emptyThemePair,
type AppThemeBannerKey,
} from '@/model/appBanners'
import { useAppBannersAdmin } from '@/composables/useAppBannersAdmin'
import AdminFieldHelp from '@/components/AdminFieldHelp.vue'
import AdminLabelWithHelp from '@/components/AdminLabelWithHelp.vue'
@@ -94,7 +175,7 @@ type BannerSlot = {
description: string
}
const groups: Array<{
const fixedGroups: Array<{
id: string
title: string
description: string
@@ -114,39 +195,16 @@ const groups: Array<{
},
],
},
{
id: 'products',
title: 'محصولات (موبایل)',
description: 'اسلایدر بنرهای بالای صفحهٔ محصولات',
icon: 'vuesax-linear:box',
slots: [
{
key: 'productsHero1',
title: 'بنر محصولات ۱',
description: 'اولین اسلاید اسلایدر',
},
{
key: 'productsHero2',
title: 'بنر محصولات ۲',
description: 'دومین اسلاید اسلایدر',
},
{
key: 'productsHero3',
title: 'بنر محصولات ۳',
description: 'سومین اسلاید اسلایدر',
},
],
},
{
id: 'fund',
title: 'قلک‌های من',
description: 'تصویر قلک در صفحهٔ ساخت قلک',
description: 'تصویر قلک در روت قلک (کنار ساخت قلک جدید)',
icon: 'vuesax-linear:wallet-3',
slots: [
{
key: 'piggyCreate',
title: 'تصویر قلک',
description: 'تصویر قلک طلایی کنار دکمهٔ «ساخت قلک جدید»',
description: 'تصویر قلک در صفحهٔ قلک‌ها، کنار دکمهٔ «ساخت قلک جدید» (موبایل و دسکتاپ)',
},
],
},
@@ -188,6 +246,33 @@ function onThemeRemove(key: AppThemeBannerKey, theme: 'light' | 'dark') {
state[key][theme] = null
saveSoon('تصویر بنر حذف شد')
}
function addProductsHero() {
if (!Array.isArray(state.productsHero)) {
state.productsHero = []
}
state.productsHero.push(emptyThemePair())
saveSoon('بنر محصول اضافه شد')
}
function removeProductsHero(index: number) {
state.productsHero.splice(index, 1)
saveSoon('بنر محصول حذف شد')
}
function onProductsHeroUpload(index: number, theme: 'light' | 'dark', url: string) {
const pair = state.productsHero[index]
if (!pair) return
pair[theme] = url
saveSoon('تصویر بنر ذخیره شد')
}
function onProductsHeroRemove(index: number, theme: 'light' | 'dark') {
const pair = state.productsHero[index]
if (!pair) return
pair[theme] = null
saveSoon('تصویر بنر حذف شد')
}
</script>
<style scoped lang="less">
@@ -289,6 +374,36 @@ function onThemeRemove(key: AppThemeBannerKey, theme: 'light' | 'dark') {
color: var(--admin-text-secondary);
}
.profile-banners__panel-actions {
display: flex;
justify-content: flex-start;
margin-bottom: 1rem;
}
.profile-banners__empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 1.5rem 1rem;
border: 1px dashed var(--admin-border);
border-radius: 12px;
text-align: center;
}
.profile-banners__empty-title {
margin: 0;
font-size: 0.9375rem;
font-weight: 600;
color: var(--admin-text);
}
.profile-banners__empty-text {
margin: 0 0 0.5rem;
font-size: 0.8125rem;
color: var(--admin-text-secondary);
}
.profile-banners__list {
display: flex;
flex-direction: column;
@@ -306,6 +421,13 @@ function onThemeRemove(key: AppThemeBannerKey, theme: 'light' | 'dark') {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--admin-border);
background: color-mix(in srgb, var(--admin-bg-muted) 55%, transparent);
&--row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
}
}
.profile-banner-card__title {
@@ -320,6 +442,23 @@ function onThemeRemove(key: AppThemeBannerKey, theme: 'light' | 'dark') {
color: var(--admin-text-secondary);
}
.profile-banner-card__remove {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border: 1px solid var(--admin-border);
border-radius: 8px;
background: transparent;
color: #ff7875;
cursor: pointer;
&:hover {
background: rgba(255, 77, 79, 0.12);
}
}
.profile-banner-card__themes {
display: grid;
grid-template-columns: 1fr;
@@ -339,9 +478,16 @@ function onThemeRemove(key: AppThemeBannerKey, theme: 'light' | 'dark') {
}
.profile-banner-card__upload {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
min-height: 10rem;
:deep(.banner-image-upload) {
position: absolute;
inset: 0;
}
&--dark :deep(.banner-image-upload__filled) {
background: #121212;
}