- Add tailwindcss v4 + @tailwindcss/vite to mall, shop-admin, admin - Add @vmall/shared/theme.css tokens with html[data-accent] presets - Add @vmall/ui kit (VBtn/VBadge/VField/VInput/VCard/VPanel/VTable/VPage, VAccentSwatch, useAccent) as a Nuxt module - Convert all three apps to kit + utilities; delete ui.css/mall.css and every <style scoped>; consoles get accent presets, mall locked to red - Fix VCard boolean prop default (padding) and PDP/store stale useAsyncData keys on param navigation - Archive adopt-tailwind-design-system; new frontend-ui capability spec
125 lines
5.2 KiB
Vue
125 lines
5.2 KiB
Vue
<script setup lang="ts">
|
||
import type { Category, HomeContent, Product } from "@vmall/shared";
|
||
import { t as pick } from "@vmall/shared";
|
||
|
||
interface HomeFloor {
|
||
categoryId: string;
|
||
name: Record<string, string>;
|
||
advImage: string | null;
|
||
advUrl: string;
|
||
products: Product[];
|
||
}
|
||
|
||
const emptyContent: HomeContent = { banners: [], promos: [], quick_links: [], floor_adverts: [] };
|
||
|
||
const { locale, t } = useI18n();
|
||
const { $api } = useNuxtApp();
|
||
|
||
// Shared with the shell's category menu, so this does not refetch the tree.
|
||
const { data: categories } = await useAsyncData("shell-categories", () => $api.listCategories());
|
||
|
||
// Marketing content comes from the content API; an outage leaves these lists
|
||
// empty rather than breaking the page.
|
||
const { data: content } = await useAsyncData("home-content", () => $api.getHomeContent(), {
|
||
default: () => emptyContent,
|
||
});
|
||
|
||
const banners = computed(() =>
|
||
content.value.banners.map((banner) => ({ image: banner.image, url: banner.url })),
|
||
);
|
||
const quickLinks = computed(() => content.value.quick_links);
|
||
const promos = computed(() => content.value.promos);
|
||
const heroQuickLinks = computed(() => quickLinks.value.slice(0, 6));
|
||
const sidePromos = computed(() => promos.value.slice(0, 2));
|
||
const stripPromos = computed(() => (promos.value.length > 2 ? promos.value.slice(2, 5) : promos.value.slice(0, 3)));
|
||
|
||
// Floor structure comes from the category tree and floor products from the
|
||
// catalog API; advert art is an ordered pool assigned to floors in turn.
|
||
const { data: floors } = await useAsyncData("home-floors", async () => {
|
||
const roots: Category[] = categories.value ?? (await $api.listCategories());
|
||
const adverts = content.value.floor_adverts;
|
||
const tops = roots
|
||
.filter((category) => category.parent_id === null)
|
||
.sort((a, b) => a.position - b.position);
|
||
const built = await Promise.all(
|
||
tops.map(async (category, index) => {
|
||
const page = await $api.listProducts({ category_id: category.id, per_page: 8 });
|
||
const floor: HomeFloor = {
|
||
categoryId: category.id,
|
||
name: category.name,
|
||
advImage: adverts.length > 0 ? adverts[index % adverts.length].image : null,
|
||
advUrl: `/search?category=${category.id}`,
|
||
products: page.items,
|
||
};
|
||
return floor;
|
||
}),
|
||
);
|
||
return built.filter((floor) => floor.products.length > 0);
|
||
});
|
||
|
||
const visibleFloors = computed(() => floors.value ?? []);
|
||
</script>
|
||
|
||
<template>
|
||
<div>
|
||
<section class="mx-auto max-w-mall px-4">
|
||
<div class="grid h-hero grid-cols-[240px_minmax(0,1fr)_220px] bg-surface">
|
||
<ShellCategoryMenu pinned />
|
||
<div class="min-w-0">
|
||
<UiCarousel :images="banners" :height="450" />
|
||
</div>
|
||
<aside class="flex h-full flex-col border-l border-border bg-surface">
|
||
<ul class="grid list-none grid-cols-2 gap-px bg-border p-px">
|
||
<li v-for="q in heroQuickLinks" :key="q.id" class="bg-surface text-center">
|
||
<NuxtLink :to="q.url" class="block px-1 py-3 text-[12px] text-muted no-underline transition-colors hover:text-primary">
|
||
<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor" class="mx-auto mb-1.5">
|
||
<path :d="q.glyph" />
|
||
</svg>
|
||
<span>{{ pick(q.label, locale) }}</span>
|
||
</NuxtLink>
|
||
</li>
|
||
</ul>
|
||
<div class="mt-2 flex-1 space-y-2 px-2 pb-2">
|
||
<NuxtLink
|
||
v-for="p in sidePromos"
|
||
:key="p.id"
|
||
:to="p.url"
|
||
class="block overflow-hidden"
|
||
>
|
||
<img :src="p.image" :alt="t('home.hotPromo')" loading="lazy" class="block h-[106px] w-full object-cover" />
|
||
</NuxtLink>
|
||
</div>
|
||
</aside>
|
||
</div>
|
||
</section>
|
||
|
||
<section v-if="stripPromos.length > 0" class="mx-auto mb-5 mt-5 max-w-mall px-4">
|
||
<div class="grid grid-cols-3 gap-3">
|
||
<NuxtLink v-for="p in stripPromos" :key="p.id" :to="p.url" class="block overflow-hidden">
|
||
<img :src="p.image" :alt="t('home.hotPromo')" loading="lazy" class="block h-40 w-full object-cover" />
|
||
</NuxtLink>
|
||
</div>
|
||
</section>
|
||
|
||
<div class="bg-bg py-8">
|
||
<section v-for="floor in visibleFloors" :key="floor.categoryId" class="mx-auto mb-[30px] max-w-mall px-4 last:mb-0">
|
||
<header class="flex items-end justify-between border-b-2 border-primary">
|
||
<h2 class="m-0 py-[10px] text-[18px] leading-none">{{ pick(floor.name, locale) }}</h2>
|
||
<NuxtLink
|
||
:to="`/search?category=${floor.categoryId}`"
|
||
class="pb-2 text-[12px] text-muted no-underline transition-colors hover:text-primary"
|
||
>{{ t("home.viewMore") }} ›</NuxtLink>
|
||
</header>
|
||
<div class="flex bg-surface">
|
||
<NuxtLink v-if="floor.advImage" :to="floor.advUrl" class="shrink-0 border-r border-border">
|
||
<img :src="floor.advImage" :alt="pick(floor.name, locale)" loading="lazy" class="block h-[614px] w-[234px] object-cover" />
|
||
</NuxtLink>
|
||
<div class="grid min-w-0 flex-1 grid-cols-4 gap-px bg-border p-px">
|
||
<UiProductCard v-for="p in floor.products" :key="p.id" :product="p" />
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
</template>
|