feat: extract shop from mp/shop — initial libreshop/shop
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:
Michael Czechowski
2026-04-29 17:48:56 +02:00
commit 44107c0734
134 changed files with 19521 additions and 0 deletions

182
pages/notebooks/[cover].vue Normal file
View 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
View 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>