Compare commits
7
Commits
v0.1.0
...
61a74e649b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61a74e649b
|
||
|
|
e9cd3d9c20 | ||
|
|
cfe37519bd
|
||
|
|
60ab9dcc23
|
||
|
|
4fe5b9043d
|
||
|
|
f8d4c6a47d | ||
|
|
ed6771e03a |
@@ -1,4 +1,4 @@
|
||||
name: Build and publish
|
||||
name: build
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -7,49 +7,18 @@ on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
# Required secrets:
|
||||
# REGISTRY git.librete.ch
|
||||
# REGISTRY_USER libretech-bot
|
||||
# REGISTRY_PASS bot PAT (write:package; bot is in libreshop Owners team)
|
||||
# Required variable:
|
||||
# PUBLISH_ENABLED "true" to actually push (off = build-only on PRs)
|
||||
#
|
||||
# Image: git.librete.ch/libreshop/shop
|
||||
# main pushes → :main + :sha-<short>
|
||||
# tag pushes → :<tag> + :latest
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: git.librete.ch/libretech/runner-image:v1
|
||||
image: git.librete.ch/public/runner-image:v0.2.0@sha256:f60c587d3c0b0aac04a572db5349e27672bf76baec2ce547a3dcc28cebcf1b7e
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login (only when publishing)
|
||||
if: ${{ vars.PUBLISH_ENABLED == 'true' }}
|
||||
uses: docker/login-action@v3
|
||||
- uses: https://git.librete.ch/public/actions/.gitea/actions/docker-build@main
|
||||
with:
|
||||
registry: ${{ secrets.REGISTRY }}
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PASS }}
|
||||
|
||||
- id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ secrets.REGISTRY }}/libreshop/shop
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=sha,format=short
|
||||
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/') }}
|
||||
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: ${{ vars.PUBLISH_ENABLED == 'true' && github.event_name == 'push' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
registry_user: ${{ secrets.REGISTRY_USER }}
|
||||
registry_pass: ${{ secrets.REGISTRY_PASS }}
|
||||
publish: ${{ github.event_name == 'push' && vars.PUBLISH_ENABLED == 'true' }}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
Datenschutzerklärung
|
||||
</Button>
|
||||
<Button @click="acceptCookies" data-cy="cookie-banner-accept" classes="bg-gray-800 py-1.5 px-4 !text-xs whitespace-nowrap">
|
||||
Akzeptieren
|
||||
Schließen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -45,12 +45,6 @@
|
||||
:path="`/notebooks/${item.slug}`"
|
||||
@click="trackEvent('header-item-clicked', { label: item.label })"
|
||||
/>
|
||||
<HeaderNavLink
|
||||
label="Über uns"
|
||||
description="Über MUELLERPRINTS"
|
||||
path="/about"
|
||||
@click="trackEvent('header-item-clicked', { label: 'Über uns' })"
|
||||
/>
|
||||
</div>
|
||||
<NuxtLink
|
||||
to="/cart"
|
||||
|
||||
@@ -64,11 +64,9 @@ const props = withDefaults(defineProps<{
|
||||
});
|
||||
|
||||
const shadowColors = computed(() => {
|
||||
return (
|
||||
props.product?.pattern && [
|
||||
randomTailwindColor(props.product.pattern.id, "group-hover:shadow", 200, "/60")
|
||||
]
|
||||
);
|
||||
return props.product?.pattern
|
||||
? [randomTailwindColor(props.product.pattern.id, "group-hover:shadow", 200, "/60")]
|
||||
: [];
|
||||
});
|
||||
|
||||
const hasProductOptions = computed(() => {
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
<template>
|
||||
<NuxtLink
|
||||
:to="path"
|
||||
<component
|
||||
:is="locked ? 'div' : NuxtLink"
|
||||
:to="locked ? undefined : path"
|
||||
:title="ariaLabel"
|
||||
:aria-label="ariaLabel"
|
||||
:aria-checked="isActive"
|
||||
:aria-checked="isActive || locked"
|
||||
:class="{
|
||||
'border-black bg-white': isActive,
|
||||
'border-transparent cursor-pointer bg-gray-100 opacity-80 hover:opacity-100 hover:border-gray-400 pointer': !isActive,
|
||||
'border-black bg-white': isActive || locked,
|
||||
'cursor-default': locked,
|
||||
'border-transparent cursor-pointer bg-gray-100 opacity-80 hover:opacity-100 hover:border-gray-400 pointer': !isActive && !locked,
|
||||
'opacity-30 cursor-not-allowed pointer-events-none': ariaDisabled
|
||||
}"
|
||||
class="flex flex-col items-center gap-y-4 rounded-md border-2 border-spacing-2 pt-2 pb-2 px-2 transition-all"
|
||||
@@ -17,20 +19,24 @@
|
||||
<div
|
||||
class="text-sm text-gray-800 pointer-events-none"
|
||||
:class="{
|
||||
'font-semibold': isActive
|
||||
'font-semibold': isActive || locked
|
||||
}"
|
||||
>
|
||||
{{ label }}
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { NuxtLink } from "#components";
|
||||
|
||||
defineProps<{
|
||||
label: string;
|
||||
path: string;
|
||||
isActive?: boolean;
|
||||
ariaLabel?: string;
|
||||
ariaDisabled?: boolean;
|
||||
// Pre-selected, non-interactive (single-option / fixed products)
|
||||
locked?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Product content keyed by cover type
|
||||
// This content is displayed on product detail pages as storytelling modules
|
||||
|
||||
export type CoverType = "Hardcover" | "Softcover" | "Heft" | "Spiralbindung";
|
||||
export type CoverType = "Hardcover" | "Softcover" | "Heft" | "Spiralbindung" | "Promo";
|
||||
|
||||
export interface FeatureModule {
|
||||
eyebrow?: string;
|
||||
@@ -199,6 +199,44 @@ export const FEATURE_MODULES: Record<CoverType, FeatureModule[]> = {
|
||||
imageRight: true,
|
||||
},
|
||||
],
|
||||
|
||||
Promo: [
|
||||
{
|
||||
eyebrow: "12 Wochen · 3 Monate",
|
||||
headline: "Struktur für deinen Alltag",
|
||||
subtitle: "Planung, Intention & Reflexion",
|
||||
body: `Dieses Journaling Booklet im Format A5 sorgt für mehr Produktivität im Alltag.
|
||||
Es beinhaltet Monats-, Wochen- und Tagesplanungen sowie inspirierenden Zitaten
|
||||
für insgesamt 12 Wochen bzw. 3 Monate. Jeder Tag bietet Platz für eine
|
||||
Tagesintention, den täglichen Aufgaben, dem Notieren von Mahlzeiten und der
|
||||
Fitnessroutine. Darüber hinaus bietet es die Möglichkeit der eigene
|
||||
Tagesreflexion. Als tägliche Begleiter soll es helfen, deine Zeit effektiv zu
|
||||
managen, um mit mehr Klarheit und Struktur deine Ziele anzugehen.`,
|
||||
imageRight: true,
|
||||
},
|
||||
{
|
||||
eyebrow: "Gestaltet von mk rietzl",
|
||||
headline: "VIVUS 89 Recyclingpapier",
|
||||
subtitle: "Matt, 120g/qm, fliederfarbener Umschlag",
|
||||
body: `Das Journaling Booklet wurde konzipiert und gestaltet von Michèle Rietzl
|
||||
(mk rietzl). Es ist auf VIVUS 89 Recyclingpapier gedruckt. Die matte,
|
||||
ungestrichene Oberfläche mit feiner Textur verhindert Durchschlagen – selbst bei
|
||||
Füllfederhaltern und Aquarellfarben. Mit 120g/qm und 1,2-fachem Volumen fühlt
|
||||
sich jede Seite substanziell an, ohne starr zu wirken. Der fliederfarbene
|
||||
Umschlag sorgt für schlichte Farbakzente.`,
|
||||
imageRight: false,
|
||||
},
|
||||
{
|
||||
eyebrow: "Handgefertigt in Stuttgart",
|
||||
headline: "Fadenbindung mit offenem Buchrücken",
|
||||
subtitle: "Vollständiges Aufklappen zum Schreiben",
|
||||
body: `Bei der Bindung handelt es sich um eine klassische Fadenbindung mit offenem
|
||||
Buchrücken. Dies ermöglicht ein vollständiges Aufklappen, was sich ideal zum
|
||||
Schreiben eignet. Die Booklets werden mit größter Sorgfalt in Stuttgart
|
||||
produziert. Jedes Exemplar ist handgefertigt.`,
|
||||
imageRight: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
@@ -244,6 +282,15 @@ export const TECHNICAL_SPECS: Record<CoverType, Record<string, string>> = {
|
||||
Zertifizierung: "FSC® C018175, Blauer Engel, EU Ecolabel",
|
||||
Herstellung: "Stuttgart, Deutschland",
|
||||
},
|
||||
Promo: {
|
||||
Format: "150 × 210 mm (A5)",
|
||||
Umfang: "12 Wochen / 3 Monate",
|
||||
Layout: "Vorgedruckt (Tages-, Wochen- & Monatsplanung)",
|
||||
Papier: "VIVUS 89, 120g/qm, 1,2-faches Volumen, ungestrichen matt",
|
||||
Einband: "Fliederfarbener Softcover",
|
||||
Bindung: "Fadenbindung mit offenem Buchrücken",
|
||||
Herstellung: "Handgefertigt in Stuttgart",
|
||||
},
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
@@ -284,6 +331,8 @@ export function getCoverType(coverName?: string): CoverType {
|
||||
if (name.includes("hardcover")) return "Hardcover";
|
||||
if (name.includes("softcover")) return "Softcover";
|
||||
if (name.includes("spiral") || name.includes("wire")) return "Spiralbindung";
|
||||
// Standalone promo bucket (cover named "Promo").
|
||||
if (name.includes("promo")) return "Promo";
|
||||
return "Hardcover"; // fallback
|
||||
}
|
||||
|
||||
@@ -314,10 +363,10 @@ export function useProductContent(
|
||||
derivedSpecs["Format"] = prod.cover.copyText.format;
|
||||
}
|
||||
|
||||
// Seitenanzahl from pages
|
||||
if (prod.pages?.name) {
|
||||
// Seitenanzahl from pages (skip for Promo/standalone — page count not relevant)
|
||||
if (prod.pages?.name && coverType.value !== "Promo") {
|
||||
derivedSpecs["Seitenanzahl"] = prod.pages.name;
|
||||
} else if (prod.pages?.count) {
|
||||
} else if (prod.pages?.count && coverType.value !== "Promo") {
|
||||
const sheets = Math.floor(prod.pages.count / 2);
|
||||
derivedSpecs["Seitenanzahl"] = `${prod.pages.count} Seiten (${sheets} Blatt)`;
|
||||
}
|
||||
|
||||
+4
-1
@@ -45,7 +45,10 @@ export default defineNuxtConfig({
|
||||
routeRules: {
|
||||
// Cart & Checkout: Client-only (no SSR)
|
||||
"/cart": { ssr: false },
|
||||
"/checkout/**": { ssr: false }
|
||||
"/checkout/**": { ssr: false },
|
||||
// Local dev: proxy CMS media. In prod nginx routes /uploads → cms;
|
||||
// without it the browser would hit the Nuxt origin and 404.
|
||||
"/uploads/**": { proxy: "http://cms:5555/uploads/**" }
|
||||
// All other pages use default SSR
|
||||
},
|
||||
|
||||
|
||||
+41
-31
@@ -77,48 +77,40 @@
|
||||
{{ product?.cover?.copyText?.paper }}
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col-reverse lg:flex-row-reverse gap-4 xl:gap-12">
|
||||
<div class="flex flex-col-reverse lg:flex-row-reverse lg:items-center gap-4 xl:gap-12">
|
||||
<div class="lg:w-1/2 xl:w-2/5 xl:pt-8">
|
||||
<div class="rounded-lg bg-white bg-opacity-100 shadow-md mb-16 relative">
|
||||
<!-- Pages Variant Selection -->
|
||||
<section
|
||||
v-if="productVariantsPages?.length"
|
||||
class="relative overflow-hidden flex flex-col xl:flex-row-reverse gap-4 xl:gap-12 py-6 px-6 xl:px-8 xl:pt-20"
|
||||
>
|
||||
<h2 class="hidden lg:block pointer-events-none absolute left-8 top-4 text-right xl:text-left pt-3 tracking-tight opacity-70 text-xl font-semibold">
|
||||
Seiten
|
||||
</h2>
|
||||
<div class="flex gap-4 z-10">
|
||||
<!-- Pages Variant Selection (locked chip when only one option) -->
|
||||
<section v-if="productVariantsPages?.length" class="flex flex-col gap-3 py-6 px-6 xl:px-8">
|
||||
<h2 class="tracking-tight opacity-70 text-lg font-semibold">Seiten</h2>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<SelectionBox
|
||||
v-for="pages in productVariantsPages"
|
||||
:key="pages.id"
|
||||
:label="pages.name"
|
||||
:path="pages.productSlug ? `/details/${pages.productSlug}` : ''"
|
||||
:is-active="pages.active"
|
||||
:locked="productVariantsPages.length === 1"
|
||||
:aria-disabled="isAddingToCart || isChangingVariant"
|
||||
@click="handleVariantClick('pages', pages)"
|
||||
@click="productVariantsPages.length > 1 && handleVariantClick('pages', pages)"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<hr v-if="productVariantsPages?.length" class="border-t-[1px] border-black opacity-40" />
|
||||
|
||||
<!-- Ruling Variant Selection -->
|
||||
<section
|
||||
v-if="productVariantsRuling?.length"
|
||||
class="relative overflow-hidden flex flex-col xl:flex-row-reverse gap-4 xl:gap-12 py-6 px-6 xl:px-8 xl:pt-20"
|
||||
>
|
||||
<h2 class="hidden lg:block pointer-events-none absolute left-8 top-4 text-right xl:text-left pt-3 tracking-tight opacity-70 text-xl font-semibold">
|
||||
Layout
|
||||
</h2>
|
||||
<div class="flex gap-4 z-10">
|
||||
<section v-if="productVariantsRuling?.length" class="flex flex-col gap-3 py-6 px-6 xl:px-8">
|
||||
<h2 class="tracking-tight opacity-70 text-lg font-semibold">Layout</h2>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<SelectionBox
|
||||
v-for="ruling in productVariantsRuling"
|
||||
:key="ruling.id"
|
||||
:label="ruling.name"
|
||||
:path="ruling.productSlug ? `/details/${ruling.productSlug}` : ''"
|
||||
:is-active="ruling.active"
|
||||
:locked="productVariantsRuling.length === 1"
|
||||
:aria-disabled="isAddingToCart || isChangingVariant"
|
||||
@click="handleVariantClick('ruling', ruling)"
|
||||
@click="productVariantsRuling.length > 1 && handleVariantClick('ruling', ruling)"
|
||||
>
|
||||
<img v-if="ruling.iconUrl" :alt="ruling.name" :src="ruling.iconUrl" />
|
||||
</SelectionBox>
|
||||
@@ -126,15 +118,10 @@
|
||||
</section>
|
||||
<hr v-if="productVariantsRuling?.length" class="border-t-[1px] border-black opacity-40" />
|
||||
|
||||
<!-- Cover Variant Selection -->
|
||||
<section
|
||||
v-if="productVariantsCover?.length"
|
||||
class="flex flex-col relative xl:flex-row-reverse gap-4 xl:gap-12 py-6 px-6 xl:px-8 xl:pt-20 overflow-hidden"
|
||||
>
|
||||
<h2 class="hidden lg:block pointer-events-none absolute left-8 top-4 text-right xl:text-left pt-3 tracking-tight opacity-70 text-xl font-semibold">
|
||||
Einband
|
||||
</h2>
|
||||
<div class="flex gap-4 z-10">
|
||||
<!-- Cover Variant Selection (only when a real choice exists) -->
|
||||
<section v-if="productVariantsCover?.length > 1" class="flex flex-col gap-3 py-6 px-6 xl:px-8">
|
||||
<h2 class="tracking-tight opacity-70 text-lg font-semibold">Einband</h2>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<SelectionBox
|
||||
v-for="cover in productVariantsCover"
|
||||
:key="cover.id"
|
||||
@@ -148,7 +135,18 @@
|
||||
</SelectionBox>
|
||||
</div>
|
||||
</section>
|
||||
<hr v-if="productVariantsCover?.length" class="border-t-[1px] border-black opacity-40" />
|
||||
<hr v-if="productVariantsCover?.length > 1" class="border-t-[1px] border-black opacity-40" />
|
||||
|
||||
<!-- Fixed product facts (e.g. Journaling Booklet) as pre-selected locked chips -->
|
||||
<template v-for="fact in buyBoxFacts" :key="fact.label">
|
||||
<section class="flex flex-col gap-3 py-6 px-6 xl:px-8">
|
||||
<h2 class="tracking-tight opacity-70 text-lg font-semibold">{{ fact.label }}</h2>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<SelectionBox :label="fact.value" :path="''" :locked="true" :aria-label="`${fact.label}: ${fact.value}`" />
|
||||
</div>
|
||||
</section>
|
||||
<hr class="border-t-[1px] border-black opacity-40" />
|
||||
</template>
|
||||
|
||||
<!-- Price and Add to Cart -->
|
||||
<section v-if="product?.totalProductPrice" class="flex flex-col lg:flex-row gap-6 py-6 px-6 lg:px-8 items-center">
|
||||
@@ -186,7 +184,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-center lg:w-1/2 xl:w-3/5 xl:pt-2 mx-auto">
|
||||
<div class="flex flex-col items-center lg:w-1/2 xl:w-3/5 lg:py-12 xl:py-16 mx-auto">
|
||||
<a v-if="productImage" :href="productImage.url" class="flex items-center justify-center mx-8" target="_blank" rel="noopener noreferrer">
|
||||
<img :src="productImage.formats?.medium?.url" :alt="`Overhead product shot of MUELLERPRINTS. Paperwork ${product?.name}`" class="object-contain" />
|
||||
</a>
|
||||
@@ -421,6 +419,18 @@ const productVariantsPatterns = computed(() => {
|
||||
}));
|
||||
});
|
||||
|
||||
// Fixed/standalone products (no selectable pages & ruling, e.g. Journaling Booklet)
|
||||
// surface their key specs as pre-selected locked chips in the buy box.
|
||||
const buyBoxFacts = computed(() => {
|
||||
if (productVariantsPages.value.length || productVariantsRuling.value.length) return [];
|
||||
const s = (technicalSpecs.value ?? {}) as Record<string, string>;
|
||||
const facts: { label: string; value: string }[] = [];
|
||||
if (s.Umfang) facts.push({ label: "Umfang", value: s.Umfang });
|
||||
if (s.Layout) facts.push({ label: "Layout", value: s.Layout });
|
||||
if (s.Einband) facts.push({ label: "Einband", value: s.Bindung ? `${s.Einband} · ${s.Bindung}` : s.Einband });
|
||||
return facts;
|
||||
});
|
||||
|
||||
// Testimonials data
|
||||
const testimonials: Testimonial[] = [
|
||||
{
|
||||
|
||||
+56
-14
@@ -49,23 +49,32 @@
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<!-- CTA Section -->
|
||||
<section class="py-16 bg-gray-900 text-white">
|
||||
<div class="container mx-auto px-6 text-center">
|
||||
<h3 class="text-3xl lg:text-4xl font-bebas mb-4">Premium Notizbücher für kreative Köpfe</h3>
|
||||
<p class="text-lg opacity-80 max-w-2xl mx-auto mb-8">
|
||||
Erfahre wie unsere handgefertigten Notizbücher aus recyceltem Papier hergestellt werden
|
||||
</p>
|
||||
<NuxtLink
|
||||
to="/notebooks"
|
||||
class="inline-block px-8 py-3 rounded-full font-medium bg-white text-gray-900 hover:bg-gray-200 transition-all duration-200"
|
||||
@click="trackEvent('start-page-cta-clicked')"
|
||||
>
|
||||
Jetzt entdecken
|
||||
<!-- Promo bands for standalone products (e.g. Journaling Booklet) -->
|
||||
<section
|
||||
v-for="promo in promoCovers"
|
||||
:key="promo.id"
|
||||
class="py-16 lg:py-24 bg-gradient-to-br from-violet-50 via-white to-rose-50"
|
||||
>
|
||||
<div class="container mx-auto px-6 flex flex-col-reverse lg:flex-row items-center gap-10 lg:gap-16">
|
||||
<div class="lg:w-1/2">
|
||||
<p class="text-sm uppercase tracking-widest opacity-60 mb-3">Neu im Sortiment</p>
|
||||
<Heading :level="2" html-tag="h2">{{ promo.name }}</Heading>
|
||||
<p v-if="promo.details" class="mt-4 text-lg leading-relaxed opacity-80 xl:w-5/6" v-html="promo.details" />
|
||||
<NuxtLink
|
||||
:to="`/details/${promo.productSlug}`"
|
||||
class="mt-8 inline-block px-8 py-3 rounded-full font-medium bg-gray-900 text-white hover:bg-gray-700 transition-all duration-200"
|
||||
@click="trackEvent('promo-cta-clicked', { product: promo.productSlug })"
|
||||
>
|
||||
{{ promo.name }} entdecken
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<NuxtLink :to="`/details/${promo.productSlug}`" class="lg:w-1/2 block">
|
||||
<img v-if="promo.image" :src="promo.image" :alt="promo.name" loading="lazy" class="w-full rounded-lg shadow-xl" />
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
<!-- Testimonials (hidden for now)
|
||||
<section v-if="testimonials?.length > 0" class="px-6 lg:container mx-auto mt-20 mb-32">
|
||||
<div class="text-center mb-10">
|
||||
@@ -99,8 +108,11 @@ const shopApi = useShopApi();
|
||||
const { data: coversData } = await useAsyncData("landing-covers", async () => {
|
||||
try {
|
||||
const { data } = await shopApi.getProductCovers();
|
||||
// Standalone covers (e.g. Journaling Booklet) are excluded from the landing
|
||||
// grid — they get a dedicated promo placement / direct link, not a row here.
|
||||
const sellable = data.filter((cover: any) => !cover.attributes?.standalone);
|
||||
const covers = await Promise.all(
|
||||
data.map(async (cover: any) => {
|
||||
sellable.map(async (cover: any) => {
|
||||
const productsResponse = await shopApi.getCheapestProducts(cover.id, 1, 10);
|
||||
return {
|
||||
id: cover.id,
|
||||
@@ -122,6 +134,36 @@ const { data: coversData } = await useAsyncData("landing-covers", async () => {
|
||||
|
||||
const covers = computed(() => coversData.value ?? []);
|
||||
|
||||
// Standalone covers (e.g. Journaling Booklet) → dedicated promo band with a
|
||||
// direct link to the first product of that category. Link resolves by SLUG
|
||||
// (never hardcoded ids — prod ids differ from dev).
|
||||
const { data: promoData } = await useAsyncData("landing-promos", async () => {
|
||||
try {
|
||||
const { data } = await shopApi.getProductCovers();
|
||||
const standalone = data.filter((c: any) => c.attributes?.standalone);
|
||||
const promos = await Promise.all(
|
||||
standalone.map(async (cover: any) => {
|
||||
const productsResponse = await shopApi.getCheapestProducts(cover.id, 1, 1);
|
||||
const first = productsResponse?.data?.[0] as any;
|
||||
const img = first?.images?.images?.[0];
|
||||
return {
|
||||
id: cover.id,
|
||||
name: first?.name ?? cover.attributes?.name,
|
||||
details: cover.attributes?.copyText?.details,
|
||||
sort: cover.attributes?.sort ?? 0,
|
||||
productSlug: first?.slug,
|
||||
image: img?.formats?.medium?.url ?? img?.formats?.small?.url ?? img?.url
|
||||
};
|
||||
})
|
||||
);
|
||||
return promos.filter((p) => p.productSlug).sort((a, b) => a.sort - b.sort);
|
||||
} catch (error) {
|
||||
console.error("Error fetching promos:", error);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
const promoCovers = computed(() => promoData.value ?? []);
|
||||
|
||||
// Testimonials
|
||||
const testimonials: Testimonial[] = [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user