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

View File

@@ -0,0 +1,172 @@
import { fetchCms } from "~/server/utils/cmsApi";
function slugify(text: string): string {
return text
.toLowerCase()
.replace(/\s+/g, "-")
.replace(/ü/g, "ue")
.replace(/ä/g, "ae")
.replace(/ö/g, "oe")
.replace(/ß/g, "ss");
}
interface ProductData {
id: number;
slug?: string;
updatedAt?: string;
cover?: { id: number } | { data: { id: number } };
attributes?: {
slug: string;
updatedAt: string;
cover?: { data: { id: number } };
};
}
interface CoverData {
id: number;
name?: string;
sort?: number;
updatedAt?: string;
attributes?: {
name: string;
sort?: number;
updatedAt: string;
};
}
interface ApiResponse<T> {
data: T[];
meta?: {
pagination?: {
total: number;
pageSize: number;
pageCount: number;
};
};
}
export default defineSitemapEventHandler(async (event) => {
const config = useRuntimeConfig(event);
const siteUrl = config.siteUrl || "https://muellerprints-paperwork.com";
const urls: { loc: string; lastmod?: string; priority?: number }[] = [];
const allPageSize = 20; // must match notebooks/index.vue pageSize
const coverPageSize = 24; // must match notebooks/[cover].vue pageSize
try {
// Fetch all products for detail page URLs
const products = await fetchCms<ApiResponse<ProductData>>("/products", {
query: {
"pagination[pageSize]": "1000",
"fields[0]": "slug",
"fields[1]": "updatedAt",
"populate[cover][fields][0]": "id"
}
});
// Product detail pages
for (const product of products.data || []) {
const slug = product.slug || product.attributes?.slug;
const updatedAt = product.updatedAt || product.attributes?.updatedAt;
if (slug) {
urls.push({
loc: `${siteUrl}/details/${slug}`,
lastmod: updatedAt,
priority: 0.8
});
}
}
// Fetch promo products count for pagination (matches what the page actually shows)
const promoProducts = await fetchCms<ApiResponse<ProductData>>("/promo-products", {
query: {
"pagination[pageSize]": "1",
"pagination[page]": "1"
}
});
const totalPromoProducts = promoProducts.meta?.pagination?.total || 0;
const totalNotebooksPages = Math.ceil(totalPromoProducts / allPageSize);
// Add /notebooks pagination pages
for (let page = 2; page <= totalNotebooksPages; page++) {
urls.push({
loc: `${siteUrl}/notebooks?page=${page}`,
priority: 0.7
});
}
// Fetch all covers
const covers = await fetchCms<ApiResponse<CoverData>>("/product-covers", {
query: {
"fields[0]": "name",
"fields[1]": "sort",
"fields[2]": "updatedAt"
}
});
// Sort covers by sort field
const sortedCovers = (covers.data || []).sort((a, b) => {
const sortA = a.sort ?? a.attributes?.sort ?? 0;
const sortB = b.sort ?? b.attributes?.sort ?? 0;
return sortA - sortB;
});
// Cover category pages with slugs and pagination
for (const cover of sortedCovers) {
const name = cover.name || cover.attributes?.name;
const updatedAt = cover.updatedAt || cover.attributes?.updatedAt;
if (name) {
const slug = slugify(name);
// Main category page
urls.push({
loc: `${siteUrl}/notebooks/${slug}`,
lastmod: updatedAt,
priority: 0.7
});
// Fetch promo product count for this cover (matches page display)
const coverId = cover.id;
const coverPromo = await fetchCms<ApiResponse<ProductData>>("/promo-products", {
query: {
"filters[cover]": String(coverId),
"pagination[pageSize]": "1",
"pagination[page]": "1"
}
});
const coverTotal = coverPromo.meta?.pagination?.total || 0;
const coverPageCount = Math.ceil(coverTotal / coverPageSize);
// Add pagination pages for this category
for (let page = 2; page <= coverPageCount; page++) {
urls.push({
loc: `${siteUrl}/notebooks/${slug}?page=${page}`,
priority: 0.6
});
}
}
}
} catch (error) {
console.error("Error fetching sitemap data:", error);
}
// Static pages
urls.push(
{ loc: `${siteUrl}/`, priority: 1.0 },
{ loc: `${siteUrl}/notebooks`, priority: 0.9 },
// Info pages
{ loc: `${siteUrl}/about`, priority: 0.6 },
{ loc: `${siteUrl}/kontakt`, priority: 0.5 },
{ loc: `${siteUrl}/anfahrt`, priority: 0.4 },
{ loc: `${siteUrl}/oeffnungszeiten`, priority: 0.4 },
// Legal pages
{ loc: `${siteUrl}/impressum`, priority: 0.2 },
{ loc: `${siteUrl}/datenschutz`, priority: 0.2 },
{ loc: `${siteUrl}/agb`, priority: 0.2 },
{ loc: `${siteUrl}/versand`, priority: 0.3 },
{ loc: `${siteUrl}/zahlung`, priority: 0.3 }
);
return urls;
});

View File

@@ -0,0 +1,55 @@
export default defineEventHandler(async (event) => {
const body = await readBody(event);
const { name, email, subject, message } = body;
if (!name || !email || !message) {
throw createError({
statusCode: 400,
message: "Name, E-Mail und Nachricht sind erforderlich",
});
}
// Validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
throw createError({
statusCode: 400,
message: "Ungültige E-Mail-Adresse",
});
}
try {
// Send email via Mail service
const config = useRuntimeConfig();
const mailApiUrl = config.mailApiUrl || "http://mail:2222";
await $fetch(`${mailApiUrl}/send`, {
method: "POST",
body: {
to: "paperwork@muellerprints.de",
subject: `Kontaktanfrage: ${subject || "Anfrage über Website"}`,
body: `
Name: ${name}
E-Mail: ${email}
Betreff: ${subject || "Kontaktanfrage über Website"}
Nachricht:
${message}
---
Diese Nachricht wurde über das Kontaktformular auf muellerprints.de gesendet.
`.trim(),
replyTo: email,
},
});
return { success: true };
} catch (error) {
console.error("Failed to send contact email:", error);
throw createError({
statusCode: 500,
message: "E-Mail konnte nicht gesendet werden",
});
}
});

View File

@@ -0,0 +1,5 @@
import { fetchCms } from "~/server/utils/cmsApi";
export default defineEventHandler(async () => {
return await fetchCms(`/content`);
});

View File

@@ -0,0 +1,5 @@
import { fetchCms } from "~/server/utils/cmsApi";
export default defineEventHandler(async () => {
return await fetchCms(`/deliveries`);
});

26
server/api/health.get.ts Normal file
View File

@@ -0,0 +1,26 @@
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig();
const healthStatus: Record<string, unknown> = { status: "ok" };
// Check CMS connectivity (CMS returns 204 No Content for /_health)
try {
const cmsUrl = config.cmsInternalUrl || "http://cms:5555";
await $fetch(`${cmsUrl}/_health`, {
timeout: 5000,
ignoreResponseError: false
});
// If we get here without error, CMS is responding (even with 204)
healthStatus.cms_responding = true;
} catch (error) {
healthStatus.cms_responding = false;
healthStatus.status = "degraded";
}
// Check if API token is configured
healthStatus.api_token_configured = !!config.shopApiToken;
const statusCode = healthStatus.status === "ok" ? 200 : 503;
setResponseStatus(event, statusCode);
return healthStatus;
});

5
server/api/legal.get.ts Normal file
View File

@@ -0,0 +1,5 @@
import { fetchCms } from "~/server/utils/cmsApi";
export default defineEventHandler(async () => {
return await fetchCms(`/legal`);
});

View File

@@ -0,0 +1,10 @@
import { fetchCms } from "~/server/utils/cmsApi";
export default defineEventHandler(async (event) => {
const uuid = getRouterParam(event, "uuid");
if (!uuid) {
throw createError({ statusCode: 400, statusMessage: "Missing order UUID" });
}
return await fetchCms(`/orders/${uuid}/cart`);
});

View File

@@ -0,0 +1,15 @@
import { fetchCms } from "~/server/utils/cmsApi";
export default defineEventHandler(async (event) => {
const uuid = getRouterParam(event, "uuid");
if (!uuid) {
throw createError({ statusCode: 400, statusMessage: "Missing order UUID" });
}
const body = await readBody(event);
return await fetchCms(`/orders/${uuid}/cart`, {
method: "PUT",
body
});
});

View File

@@ -0,0 +1,20 @@
import { fetchCms } from "~/server/utils/cmsApi";
export default defineEventHandler(async (event) => {
const uuid = getRouterParam(event, "uuid");
const productId = getRouterParam(event, "productId");
const query = getQuery(event);
if (!uuid) {
throw createError({ statusCode: 400, statusMessage: "Missing order UUID" });
}
if (!productId) {
throw createError({ statusCode: 400, statusMessage: "Missing product ID" });
}
const count = query.count || 1;
return await fetchCms(`/orders/${uuid}/add-product/${productId}?count=${count}`, {
method: "PUT"
});
});

View File

@@ -0,0 +1,17 @@
import { fetchCms } from "~/server/utils/cmsApi";
export default defineEventHandler(async (event) => {
const uuid = getRouterParam(event, "uuid");
const paypalOrderId = getRouterParam(event, "paypalOrderId");
if (!uuid) {
throw createError({ statusCode: 400, statusMessage: "Missing order UUID" });
}
if (!paypalOrderId) {
throw createError({ statusCode: 400, statusMessage: "Missing PayPal order ID" });
}
return await fetchCms(`/orders/${uuid}/capture/${paypalOrderId}`, {
method: "POST"
});
});

View File

@@ -0,0 +1,16 @@
import { fetchCms } from "~/server/utils/cmsApi";
export default defineEventHandler(async (event) => {
const uuid = getRouterParam(event, "uuid");
const query = getQuery(event);
if (!uuid) {
throw createError({ statusCode: 400, statusMessage: "Missing order UUID" });
}
const returnUrl = query.returnUrl || "";
return await fetchCms(`/orders/${uuid}/checkout?returnUrl=${encodeURIComponent(String(returnUrl))}`, {
method: "POST"
});
});

View File

@@ -0,0 +1,20 @@
import { fetchCms } from "~/server/utils/cmsApi";
export default defineEventHandler(async (event) => {
const uuid = getRouterParam(event, "uuid");
const productId = getRouterParam(event, "productId");
const query = getQuery(event);
if (!uuid) {
throw createError({ statusCode: 400, statusMessage: "Missing order UUID" });
}
if (!productId) {
throw createError({ statusCode: 400, statusMessage: "Missing product ID" });
}
const count = query.count || 1;
return await fetchCms(`/orders/${uuid}/remove-product/${productId}?count=${count}`, {
method: "PUT"
});
});

View File

@@ -0,0 +1,5 @@
import { fetchCms } from "~/server/utils/cmsApi";
export default defineEventHandler(async () => {
return await fetchCms(`/orders/`, { method: "POST" });
});

View File

@@ -0,0 +1,5 @@
import { fetchCms } from "~/server/utils/cmsApi";
export default defineEventHandler(async () => {
return await fetchCms(`/payments`);
});

View File

@@ -0,0 +1,18 @@
import { fetchCms } from "~/server/utils/cmsApi";
import { resolveCoverId } from "~/server/utils/coverSlug";
export default defineEventHandler(async (event) => {
const idOrSlug = getRouterParam(event, "id");
if (!idOrSlug) {
throw createError({ statusCode: 400, statusMessage: "Missing cover ID" });
}
// Resolve slug to ID
const coverId = await resolveCoverId(idOrSlug);
if (!coverId) {
throw createError({ statusCode: 404, statusMessage: "Cover not found" });
}
return await fetchCms(`/product-covers/${coverId}?populate[copyText]=true`);
});

View File

@@ -0,0 +1,5 @@
import { fetchCms } from "~/server/utils/cmsApi";
export default defineEventHandler(async () => {
return await fetchCms(`/product-covers?populate[copyText]=true`);
});

View File

@@ -0,0 +1,5 @@
import { fetchCms } from "~/server/utils/cmsApi";
export default defineEventHandler(async () => {
return await fetchCms(`/product-pages`);
});

View File

@@ -0,0 +1,5 @@
import { fetchCms } from "~/server/utils/cmsApi";
export default defineEventHandler(async () => {
return await fetchCms(`/product-patterns`);
});

View File

@@ -0,0 +1,5 @@
import { fetchCms } from "~/server/utils/cmsApi";
export default defineEventHandler(async () => {
return await fetchCms(`/product-ruling`);
});

View File

@@ -0,0 +1,15 @@
import { fetchCms } from "~/server/utils/cmsApi";
/**
* Get product by slug - optimized for SSR.
* This route fetches directly from CMS and returns the first matching product.
*/
export default defineEventHandler(async (event) => {
const slug = getRouterParam(event, "slug");
if (!slug) {
throw createError({ statusCode: 400, statusMessage: "Missing slug parameter" });
}
const response = await fetchCms<{ data: unknown[] }>(`/products?filters[slug]=${slug}`);
return response.data?.[0] ?? null;
});

View File

@@ -0,0 +1,10 @@
import { fetchCms } from "~/server/utils/cmsApi";
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, "id");
if (!id) {
throw createError({ statusCode: 400, statusMessage: "Missing product ID" });
}
return await fetchCms(`/products/${id}/variants`);
});

View File

@@ -0,0 +1,10 @@
import { fetchCms } from "~/server/utils/cmsApi";
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, "id");
if (!id) {
throw createError({ statusCode: 400, statusMessage: "Missing product ID" });
}
return await fetchCms(`/products/${id}/variants/pattern`);
});

View File

@@ -0,0 +1,12 @@
import { fetchCms } from "~/server/utils/cmsApi";
export default defineEventHandler(async (event) => {
const query = getQuery(event);
const params = new URLSearchParams();
if (query.cover) params.append("filters[cover]", String(query.cover));
params.append("pagination[page]", String(query.page || 1));
params.append("pagination[pageSize]", String(query.pageSize || 24));
return await fetchCms(`/products?${params}`);
});

View File

@@ -0,0 +1,21 @@
import { fetchCms } from "~/server/utils/cmsApi";
import { resolveCoverId } from "~/server/utils/coverSlug";
export default defineEventHandler(async (event) => {
const query = getQuery(event);
const params = new URLSearchParams();
// Resolve cover slug to ID if provided
if (query.cover) {
const coverId = await resolveCoverId(String(query.cover));
if (coverId) {
params.append("filters[cover]", coverId);
}
}
params.append("pagination[page]", String(query.page || 1));
params.append("pagination[pageSize]", String(query.pageSize || 24));
return await fetchCms(`/promo-products?${params}`);
});

View File

@@ -0,0 +1,5 @@
import { fetchCms } from "~/server/utils/cmsApi";
export default defineEventHandler(async () => {
return await fetchCms(`/website?populate=*`);
});

View File

@@ -0,0 +1,29 @@
// 301 Redirects for old URLs to new semantic URLs
// These redirects ensure SEO continuity and prevent broken links
const REDIRECTS: Record<string, string> = {
// Website pages (old ID-based URLs)
"/website/19": "/about",
"/website/20": "/oeffnungszeiten",
"/website/21": "/anfahrt",
// Legal pages (old /legal/[content] URLs)
"/legal/imprint": "/impressum",
"/legal/contact": "/kontakt",
"/legal/terms": "/agb",
"/legal/privacy": "/datenschutz",
"/legal/delivery": "/versand",
"/legal/payment": "/zahlung",
};
export default defineEventHandler((event) => {
const path = event.path;
// Check if the current path matches any redirect
const redirectTo = REDIRECTS[path];
if (redirectTo) {
// 301 Permanent Redirect
return sendRedirect(event, redirectTo, 301);
}
});

17
server/utils/cmsApi.ts Normal file
View File

@@ -0,0 +1,17 @@
/**
* Server-side CMS API utility.
* Use this for direct CMS access during SSR.
*/
export async function fetchCms<T>(endpoint: string, options: Parameters<typeof $fetch>[1] = {}): Promise<T> {
const config = useRuntimeConfig();
const url = `${config.cmsInternalUrl}/api${endpoint}`;
return await $fetch<T>(url, {
...options,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${config.shopApiToken}`,
...((options.headers as Record<string, string>) || {})
}
});
}

37
server/utils/coverSlug.ts Normal file
View File

@@ -0,0 +1,37 @@
import { fetchCms } from "./cmsApi";
export function slugify(text: string): string {
return text
.toLowerCase()
.replace(/\s+/g, "-")
.replace(/ü/g, "ue")
.replace(/ä/g, "ae")
.replace(/ö/g, "oe")
.replace(/ß/g, "ss");
}
/**
* Resolves a cover slug or ID to the actual cover ID.
* If the input is already a numeric ID, returns it as-is.
* If it's a slug, looks up the cover by name and returns the ID.
*/
export async function resolveCoverId(idOrSlug: string): Promise<string | null> {
// If it's already a numeric ID, return it
if (/^\d+$/.test(idOrSlug)) {
return idOrSlug;
}
// Otherwise, fetch all covers and find by slug
try {
const allCovers = await fetchCms<{ data: any[] }>(`/product-covers`);
const cover = allCovers.data?.find((c: any) => {
const name = c.attributes?.name || c.name;
const slug = slugify(name || "");
return slug === idOrSlug;
});
return cover?.id?.toString() || null;
} catch {
return null;
}
}