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:
@@ -104,9 +104,7 @@ export async function loadAppBannersConst(): Promise<AppBannersConstState> {
|
||||
Object.assign(merged, {
|
||||
homeAsset: themes.homeAsset,
|
||||
piggyCreate: themes.piggyCreate,
|
||||
productsHero1: themes.productsHero1,
|
||||
productsHero2: themes.productsHero2,
|
||||
productsHero3: themes.productsHero3,
|
||||
productsHero: themes.productsHero?.length ? themes.productsHero : merged.productsHero,
|
||||
referral: themes.referral,
|
||||
share: themes.share,
|
||||
invite: themes.invite,
|
||||
|
||||
@@ -27,9 +27,7 @@ function applyLoadedData(data: AppBannersValue) {
|
||||
Object.assign(next, data)
|
||||
next.homeAsset = absolutizePair(next.homeAsset)
|
||||
next.piggyCreate = absolutizePair(next.piggyCreate)
|
||||
next.productsHero1 = absolutizePair(next.productsHero1)
|
||||
next.productsHero2 = absolutizePair(next.productsHero2)
|
||||
next.productsHero3 = absolutizePair(next.productsHero3)
|
||||
next.productsHero = (next.productsHero ?? []).map(absolutizePair)
|
||||
next.referral = absolutizePair(next.referral)
|
||||
next.share = absolutizePair(next.share)
|
||||
next.invite = absolutizePair(next.invite)
|
||||
@@ -61,9 +59,7 @@ function snapshotForSave(): AppBannersValue {
|
||||
slider: httpsSlider([...state.slider]),
|
||||
homeAsset: httpsPair(state.homeAsset),
|
||||
piggyCreate: httpsPair(state.piggyCreate),
|
||||
productsHero1: httpsPair(state.productsHero1),
|
||||
productsHero2: httpsPair(state.productsHero2),
|
||||
productsHero3: httpsPair(state.productsHero3),
|
||||
productsHero: (state.productsHero ?? []).map(httpsPair),
|
||||
referral: httpsPair(state.referral),
|
||||
share: httpsPair(state.share),
|
||||
invite: httpsPair(state.invite),
|
||||
|
||||
+27
-21
@@ -12,9 +12,6 @@ export const LEGACY_SLIDER_BANNERS_CONST_KEY = 'header_banner'
|
||||
export type AppThemeBannerKey =
|
||||
| 'homeAsset'
|
||||
| 'piggyCreate'
|
||||
| 'productsHero1'
|
||||
| 'productsHero2'
|
||||
| 'productsHero3'
|
||||
| 'referral'
|
||||
| 'share'
|
||||
| 'invite'
|
||||
@@ -31,9 +28,8 @@ export interface AppBannersValue {
|
||||
slider: IBenefits[]
|
||||
homeAsset: ThemeImagePair
|
||||
piggyCreate: ThemeImagePair
|
||||
productsHero1: ThemeImagePair
|
||||
productsHero2: ThemeImagePair
|
||||
productsHero3: ThemeImagePair
|
||||
/** اسلایدر بنر بالای صفحهٔ محصولات — تعداد آزاد */
|
||||
productsHero: ThemeImagePair[]
|
||||
referral: ThemeImagePair
|
||||
share: ThemeImagePair
|
||||
invite: ThemeImagePair
|
||||
@@ -53,9 +49,7 @@ export function emptyAppBanners(): AppBannersValue {
|
||||
slider: [],
|
||||
homeAsset: emptyThemePair(),
|
||||
piggyCreate: emptyThemePair(),
|
||||
productsHero1: emptyThemePair(),
|
||||
productsHero2: emptyThemePair(),
|
||||
productsHero3: emptyThemePair(),
|
||||
productsHero: [],
|
||||
referral: emptyThemePair(),
|
||||
share: emptyThemePair(),
|
||||
invite: emptyThemePair(),
|
||||
@@ -76,6 +70,21 @@ function normalizePair(raw: unknown): ThemeImagePair {
|
||||
}
|
||||
}
|
||||
|
||||
function pairHasImage(pair: ThemeImagePair): boolean {
|
||||
return Boolean(pair.light || pair.dark)
|
||||
}
|
||||
|
||||
/** آرایهٔ جدید یا سه اسلات قدیمی productsHero1/2/3 */
|
||||
export function normalizeProductsHero(parsed: Record<string, unknown>): ThemeImagePair[] {
|
||||
if (Array.isArray(parsed.productsHero)) {
|
||||
return parsed.productsHero.map(normalizePair)
|
||||
}
|
||||
|
||||
return [parsed.productsHero1, parsed.productsHero2, parsed.productsHero3]
|
||||
.map(normalizePair)
|
||||
.filter(pairHasImage)
|
||||
}
|
||||
|
||||
function parseSlider(raw: unknown): IBenefits[] {
|
||||
if (!raw) return []
|
||||
if (Array.isArray(raw)) return raw as IBenefits[]
|
||||
@@ -102,16 +111,15 @@ export function parseAppBanners(value: string | Record<string, unknown> | null |
|
||||
return { ...fallback, slider: parsed as IBenefits[] }
|
||||
}
|
||||
|
||||
const obj = parsed as Record<string, unknown>
|
||||
return {
|
||||
slider: parseSlider(parsed.slider),
|
||||
homeAsset: normalizePair(parsed.homeAsset),
|
||||
piggyCreate: normalizePair(parsed.piggyCreate),
|
||||
productsHero1: normalizePair(parsed.productsHero1),
|
||||
productsHero2: normalizePair(parsed.productsHero2),
|
||||
productsHero3: normalizePair(parsed.productsHero3),
|
||||
referral: normalizePair(parsed.referral),
|
||||
share: normalizePair(parsed.share),
|
||||
invite: normalizePair(parsed.invite),
|
||||
slider: parseSlider(obj.slider),
|
||||
homeAsset: normalizePair(obj.homeAsset),
|
||||
piggyCreate: normalizePair(obj.piggyCreate),
|
||||
productsHero: normalizeProductsHero(obj),
|
||||
referral: normalizePair(obj.referral),
|
||||
share: normalizePair(obj.share),
|
||||
invite: normalizePair(obj.invite),
|
||||
}
|
||||
} catch {
|
||||
return fallback
|
||||
@@ -128,9 +136,7 @@ export function serializeAppBanners(data: AppBannersValue): string {
|
||||
slider: data.slider ?? [],
|
||||
homeAsset: { ...data.homeAsset },
|
||||
piggyCreate: { ...data.piggyCreate },
|
||||
productsHero1: { ...data.productsHero1 },
|
||||
productsHero2: { ...data.productsHero2 },
|
||||
productsHero3: { ...data.productsHero3 },
|
||||
productsHero: (data.productsHero ?? []).map((pair) => ({ ...pair })),
|
||||
referral: { ...data.referral },
|
||||
share: { ...data.share },
|
||||
invite: { ...data.invite },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user