feat: extract shop from mp/shop — initial libreshop/shop
Some checks failed
Build and publish / build (push) Failing after 19s
Some checks failed
Build and publish / build (push) Failing after 19s
Source moved verbatim from mp/shop/ on 2026-04-29; mp was the first concrete adapter consuming the libreshop toolkit. Builds and publishes git.librete.ch/libreshop/shop on every main / v* push via the standard .gitea/workflows/build.yml shared across libreshop components.
This commit is contained in:
182
pages/notebooks/[cover].vue
Normal file
182
pages/notebooks/[cover].vue
Normal file
@@ -0,0 +1,182 @@
|
||||
<template>
|
||||
<main>
|
||||
<!-- Hero Section -->
|
||||
<section class="bg-gray-900 text-white py-16 lg:py-24">
|
||||
<div class="container mx-auto px-6 text-center">
|
||||
<p class="text-sm uppercase tracking-widest opacity-60 mb-4">{{ coverData?.copyText?.format }}</p>
|
||||
<h1 class="text-4xl lg:text-5xl font-bebas mb-4">{{ coverData?.name }}</h1>
|
||||
<p v-if="coverData?.copyText?.details" class="text-lg opacity-80 max-w-2xl mx-auto" v-html="coverData.copyText.details" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Category Navigation -->
|
||||
<section class="py-8 border-b border-gray-200">
|
||||
<div class="container mx-auto px-6">
|
||||
<div class="flex flex-wrap justify-center gap-3">
|
||||
<NuxtLink
|
||||
to="/notebooks"
|
||||
class="px-4 py-2 rounded-full text-sm font-medium transition-colors bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||
>
|
||||
Alle
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
v-for="c in covers"
|
||||
:key="c.id"
|
||||
:to="`/notebooks/${c.slug}`"
|
||||
class="px-4 py-2 rounded-full text-sm font-medium transition-colors"
|
||||
:class="c.slug === cover ? 'bg-gray-900 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'"
|
||||
>
|
||||
{{ c.name }}
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Products Grid -->
|
||||
<section class="py-12 lg:py-16">
|
||||
<div class="container mx-auto px-6">
|
||||
<!-- Section Header -->
|
||||
<div class="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold">{{ pagination?.total ?? 0 }} Notizbücher</h2>
|
||||
<p class="text-sm text-gray-500">Seite {{ currentPage }} von {{ pagination?.pageCount ?? 1 }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="pending" class="text-center py-12">
|
||||
<LoadingSpinner />
|
||||
<p class="mt-4 text-gray-500">Produkte werden geladen...</p>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="error" class="text-center py-12 text-red-600">
|
||||
<p>Fehler beim Laden der Produkte</p>
|
||||
</div>
|
||||
|
||||
<!-- Products Grid -->
|
||||
<div v-else class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6">
|
||||
<ProductCard v-for="(item, i) in products" :key="i" :product="item" />
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div v-if="pagination && pagination.pageCount > 1" class="mt-12 flex justify-center gap-2">
|
||||
<NuxtLink
|
||||
v-for="pageNum in pagination.pageCount"
|
||||
:key="pageNum"
|
||||
:to="pageNum === 1 ? `/notebooks/${cover}` : `/notebooks/${cover}?page=${pageNum}`"
|
||||
class="px-4 py-2 rounded-full text-sm font-medium transition-colors"
|
||||
:class="pageNum === currentPage ? 'bg-gray-900 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'"
|
||||
>
|
||||
{{ pageNum }}
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Footer CTA -->
|
||||
<section class="py-16 bg-gray-50 border-t border-gray-200">
|
||||
<div class="container mx-auto px-6 text-center">
|
||||
<h3 class="text-2xl font-bold mb-3">Fragen zu unseren Produkten?</h3>
|
||||
<p class="text-gray-600 max-w-xl mx-auto mb-6">
|
||||
Jedes Notizbuch wird in unserer Stuttgarter Werkstatt von Hand gebunden.
|
||||
</p>
|
||||
<NuxtLink
|
||||
to="/about"
|
||||
class="inline-block px-6 py-3 rounded-full font-medium bg-gray-900 text-white hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
Mehr über uns
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { slugify } from "~/utils/slugify";
|
||||
import { trackEvent } from "~/utils/trackEvent";
|
||||
|
||||
const route = useRoute();
|
||||
const cover = computed(() => route.params.cover as string);
|
||||
const currentPage = computed(() => parseInt(route.query.page as string) || 1);
|
||||
|
||||
const shopApi = useShopApi();
|
||||
|
||||
// Fetch cover data - key must be dynamic to avoid stale cache on navigation
|
||||
const { data: coverResponse } = await useAsyncData(
|
||||
() => `cover-${cover.value}`,
|
||||
() => shopApi.getProductCoverById(cover.value),
|
||||
{ watch: [cover] }
|
||||
);
|
||||
const coverData = computed(() => coverResponse.value?.data?.attributes);
|
||||
|
||||
// Fetch products
|
||||
const { data, pending, error } = await useAsyncData(
|
||||
() => `products-cover-${cover.value}-page-${currentPage.value}`,
|
||||
() => shopApi.getCheapestProducts(cover.value, currentPage.value, 24),
|
||||
{ watch: [cover, currentPage] }
|
||||
);
|
||||
|
||||
// Fetch all covers for category navigation
|
||||
const { data: coversData } = await useAsyncData("covers-nav", async () => {
|
||||
try {
|
||||
const { data } = await shopApi.getProductCovers();
|
||||
return data
|
||||
.map((c: any) => ({
|
||||
id: c.id,
|
||||
name: c.attributes?.name,
|
||||
slug: slugify(c.attributes?.name ?? ""),
|
||||
sort: c.attributes?.sort ?? 0
|
||||
}))
|
||||
.sort((a: any, b: any) => a.sort - b.sort);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
const covers = computed(() => coversData.value ?? []);
|
||||
const products = computed(() => data.value?.data ?? []);
|
||||
const pagination = computed(() => data.value?.meta?.pagination);
|
||||
|
||||
// Strip HTML tags from CMS rich text for use in meta tags
|
||||
function stripHtml(html: string): string {
|
||||
return html.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
// SEO
|
||||
useSeoMeta({
|
||||
title: () => `${coverData.value?.name ?? "Notizbücher"} | MUELLERPRINTS`,
|
||||
description: () => stripHtml(coverData.value?.copyText?.details ?? "Entdecken Sie unsere handgefertigten Notizbücher aus Stuttgart."),
|
||||
ogTitle: () => `${coverData.value?.name ?? "Notizbücher"} | MUELLERPRINTS`,
|
||||
ogDescription: () => stripHtml(coverData.value?.copyText?.details ?? "Entdecken Sie unsere handgefertigten Notizbücher aus Stuttgart.")
|
||||
});
|
||||
|
||||
// Track category view on mount
|
||||
onMounted(() => {
|
||||
trackEvent("category-viewed", {
|
||||
category: cover.value,
|
||||
page: currentPage.value,
|
||||
totalProducts: pagination.value?.total ?? 0
|
||||
});
|
||||
});
|
||||
|
||||
// Track pagination clicks
|
||||
watch(currentPage, (newPage, oldPage) => {
|
||||
if (newPage !== oldPage) {
|
||||
trackEvent("category-pagination-clicked", {
|
||||
category: cover.value,
|
||||
page: newPage
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Track category filter changes
|
||||
watch(cover, (newCover, oldCover) => {
|
||||
if (newCover !== oldCover) {
|
||||
trackEvent("category-filter-applied", {
|
||||
from: oldCover,
|
||||
to: newCover
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
176
pages/notebooks/index.vue
Normal file
176
pages/notebooks/index.vue
Normal file
@@ -0,0 +1,176 @@
|
||||
<template>
|
||||
<main>
|
||||
<!-- Hero Section -->
|
||||
<section class="bg-gray-900 text-white py-16 lg:py-24">
|
||||
<div class="container mx-auto px-6 text-center">
|
||||
<p class="text-sm uppercase tracking-widest opacity-60 mb-4">Unsere Kollektion</p>
|
||||
<h1 class="text-4xl lg:text-5xl font-bebas mb-4">Alle Notizbücher</h1>
|
||||
<p class="text-lg opacity-80 max-w-2xl mx-auto">
|
||||
Handgefertigte Notizbücher aus Stuttgart – mit 100% Recyclingpapier und traditioneller Fadenheftung.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Category Navigation -->
|
||||
<section class="py-8 border-b border-gray-200">
|
||||
<div class="container mx-auto px-6">
|
||||
<div class="flex flex-wrap justify-center gap-3">
|
||||
<NuxtLink
|
||||
to="/notebooks"
|
||||
class="px-4 py-2 rounded-full text-sm font-medium transition-colors bg-gray-900 text-white"
|
||||
>
|
||||
Alle
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
v-for="cover in covers"
|
||||
:key="cover.id"
|
||||
:to="`/notebooks/${cover.slug}`"
|
||||
class="px-4 py-2 rounded-full text-sm font-medium transition-colors bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||
>
|
||||
{{ cover.name }}
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Products Grid -->
|
||||
<section class="py-12 lg:py-16">
|
||||
<div class="container mx-auto px-6">
|
||||
<!-- Section Header -->
|
||||
<div class="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold">{{ pagination?.total ?? 0 }} Notizbücher</h2>
|
||||
<p class="text-sm text-gray-500">Seite {{ currentPage }} von {{ pagination?.pageCount ?? 1 }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="pending" class="text-center py-12">
|
||||
<LoadingSpinner />
|
||||
<p class="mt-4 text-gray-500">Produkte werden geladen...</p>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="error" class="text-center py-12 text-red-600">
|
||||
<p>Fehler beim Laden der Produkte</p>
|
||||
</div>
|
||||
|
||||
<!-- Products Grid -->
|
||||
<div v-else class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6">
|
||||
<ProductCard v-for="product in products" :key="product.id" :product="normalizeProduct(product)" />
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div v-if="pagination && pagination.pageCount > 1" class="mt-12 flex justify-center gap-2">
|
||||
<NuxtLink
|
||||
v-for="pageNum in pagination.pageCount"
|
||||
:key="pageNum"
|
||||
:to="pageNum === 1 ? '/notebooks' : `/notebooks?page=${pageNum}`"
|
||||
class="px-4 py-2 rounded-full text-sm font-medium transition-colors"
|
||||
:class="pageNum === currentPage ? 'bg-gray-900 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'"
|
||||
>
|
||||
{{ pageNum }}
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Footer CTA -->
|
||||
<section class="py-16 bg-gray-50 border-t border-gray-200">
|
||||
<div class="container mx-auto px-6 text-center">
|
||||
<h3 class="text-2xl font-bold mb-3">Fragen zu unseren Produkten?</h3>
|
||||
<p class="text-gray-600 max-w-xl mx-auto mb-6">
|
||||
Jedes Notizbuch wird in unserer Stuttgarter Werkstatt von Hand gebunden.
|
||||
</p>
|
||||
<NuxtLink
|
||||
to="/about"
|
||||
class="inline-block px-6 py-3 rounded-full font-medium bg-gray-900 text-white hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
Mehr über uns
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Product } from "~/types";
|
||||
import { slugify } from "~/utils/slugify";
|
||||
import { trackEvent } from "~/utils/trackEvent";
|
||||
|
||||
const route = useRoute();
|
||||
const currentPage = computed(() => parseInt(route.query.page as string) || 1);
|
||||
|
||||
const shopApi = useShopApi();
|
||||
|
||||
// Fetch products (cheapest variant per cover+pattern)
|
||||
const { data, pending, error } = await useAsyncData(
|
||||
() => `products-all-page-${currentPage.value}`,
|
||||
() => shopApi.getCheapestProducts(undefined, currentPage.value, 20),
|
||||
{ watch: [currentPage] }
|
||||
);
|
||||
|
||||
// Fetch covers for category navigation
|
||||
const { data: coversData } = await useAsyncData("covers-nav", async () => {
|
||||
try {
|
||||
const { data } = await shopApi.getProductCovers();
|
||||
return data
|
||||
.map((cover: any) => ({
|
||||
id: cover.id,
|
||||
name: cover.attributes?.name,
|
||||
slug: slugify(cover.attributes?.name ?? ""),
|
||||
sort: cover.attributes?.sort ?? 0
|
||||
}))
|
||||
.sort((a: any, b: any) => a.sort - b.sort);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
const covers = computed(() => coversData.value ?? []);
|
||||
const products = computed(() => data.value?.data ?? []);
|
||||
const pagination = computed(() => data.value?.meta?.pagination);
|
||||
|
||||
// Normalize Strapi response to Product type
|
||||
function normalizeProduct(item: any): Product {
|
||||
const attrs = item.attributes ?? item;
|
||||
return {
|
||||
id: item.id,
|
||||
name: attrs.name,
|
||||
slug: attrs.slug,
|
||||
totalProductPrice: attrs.totalProductPrice ?? attrs.price,
|
||||
pattern: attrs.pattern?.data ? { id: attrs.pattern.data.id, name: attrs.pattern.data.attributes?.name } : attrs.pattern,
|
||||
cover: attrs.cover?.data ? { id: attrs.cover.data.id, name: attrs.cover.data.attributes?.name } : attrs.cover,
|
||||
ruling: attrs.ruling?.data ? { id: attrs.ruling.data.id, name: attrs.ruling.data.attributes?.name } : attrs.ruling,
|
||||
pages: attrs.pages?.data ? { id: attrs.pages.data.id, name: attrs.pages.data.attributes?.name } : attrs.pages,
|
||||
images: attrs.images
|
||||
};
|
||||
}
|
||||
|
||||
// SEO
|
||||
useSeoMeta({
|
||||
title: "Alle Notizbücher | MUELLERPRINTS",
|
||||
description: "Entdecken Sie unsere handgefertigten Notizbücher aus Stuttgart mit 100% Recyclingpapier.",
|
||||
ogTitle: "Alle Notizbücher | MUELLERPRINTS",
|
||||
ogDescription: "Entdecken Sie unsere handgefertigten Notizbücher aus Stuttgart mit 100% Recyclingpapier."
|
||||
});
|
||||
|
||||
// Track category view on mount
|
||||
onMounted(() => {
|
||||
trackEvent("category-viewed", {
|
||||
category: "all",
|
||||
page: currentPage.value,
|
||||
totalProducts: pagination.value?.total ?? 0
|
||||
});
|
||||
});
|
||||
|
||||
// Track pagination clicks
|
||||
watch(currentPage, (newPage, oldPage) => {
|
||||
if (newPage !== oldPage) {
|
||||
trackEvent("category-pagination-clicked", {
|
||||
category: "all",
|
||||
page: newPage
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
Reference in New Issue
Block a user