155 lines
5.6 KiB
Vue
155 lines
5.6 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="max-w-mall mx-auto px-4">
|
||
<div class="h-hero bg-surface grid grid-cols-[240px_minmax(0,1fr)_220px]">
|
||
<ShellCategoryMenu pinned />
|
||
<div class="min-w-0">
|
||
<UiCarousel :images="banners" :height="450" />
|
||
</div>
|
||
<aside class="border-border bg-surface flex h-full flex-col border-l">
|
||
<ul class="bg-border grid list-none grid-cols-2 gap-px p-px">
|
||
<li v-for="q in heroQuickLinks" :key="q.id" class="bg-surface text-center">
|
||
<NuxtLink
|
||
:to="q.url"
|
||
class="text-muted hover:text-primary block px-1 py-3 text-[12px] no-underline transition-colors"
|
||
>
|
||
<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="max-w-mall mx-auto mt-5 mb-5 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="max-w-mall mx-auto mb-[30px] px-4 last:mb-0"
|
||
>
|
||
<header class="border-primary flex items-end justify-between border-b-2">
|
||
<h2 class="m-0 py-[10px] text-[18px] leading-none">{{ pick(floor.name, locale) }}</h2>
|
||
<NuxtLink
|
||
:to="`/search?category=${floor.categoryId}`"
|
||
class="text-muted hover:text-primary pb-2 text-[12px] no-underline transition-colors"
|
||
>{{ t("home.viewMore") }} ›</NuxtLink
|
||
>
|
||
</header>
|
||
<div class="bg-surface flex">
|
||
<NuxtLink
|
||
v-if="floor.advImage"
|
||
:to="floor.advUrl"
|
||
class="border-border shrink-0 border-r"
|
||
>
|
||
<img
|
||
:src="floor.advImage"
|
||
:alt="pick(floor.name, locale)"
|
||
loading="lazy"
|
||
class="block h-[614px] w-[234px] object-cover"
|
||
/>
|
||
</NuxtLink>
|
||
<div class="bg-border grid min-w-0 flex-1 grid-cols-4 gap-px p-px">
|
||
<UiProductCard v-for="p in floor.products" :key="p.id" :product="p" />
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
</template>
|