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

84
components/Background.vue Normal file
View File

@@ -0,0 +1,84 @@
<template>
<article :class="bgClassList" :style="styles">
<slot></slot>
</article>
</template>
<script setup lang="ts">
import { randomTailwindColor } from "~/utils/randomTailwindColor";
const props = withDefaults(
defineProps<{
coverId?: number | string | null;
patternId?: number | string | null;
/** @deprecated Use coverId and patternId instead */
colorId?: number | string | null;
shade?: 50 | 100 | 150 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900;
opacity?: number;
gradient?: boolean;
gradientDirection?: "to-t" | "to-tr" | "to-r" | "to-br" | "to-b" | "to-bl" | "to-l" | "to-tl";
customStyles?: Record<string, string>;
}>(),
{
coverId: null,
patternId: null,
colorId: null,
shade: 50,
opacity: 100,
gradient: false,
gradientDirection: "to-br",
customStyles: () => ({})
}
);
const bgClassList = computed(() => {
// Use new system if coverId/patternId provided, fallback to legacy colorId
const hasCoverPattern = props.coverId !== null || props.patternId !== null;
const legacyColorId = props.colorId;
if (!hasCoverPattern && !legacyColorId) return ["bg-black"];
const classList: string[] = [];
const opacityValue = props.opacity > 0 ? Math.floor(props.opacity) : 0;
const opacitySuffix = opacityValue < 100 ? `/${opacityValue}` : undefined;
if (props.gradient) {
classList.push(`bg-gradient-${props.gradientDirection}`);
if (hasCoverPattern) {
// New system: cover determines one color, pattern determines another
// This creates visual consistency within categories while varying by pattern
const coverNum = Number(props.coverId ?? 0);
const patternNum = Number(props.patternId ?? 0);
// from (top-left): cover color - lighter shade
classList.push(randomTailwindColor(coverNum, "from", 100, opacitySuffix));
// via (middle): blend of both - mix the IDs for variety
classList.push(randomTailwindColor(coverNum + patternNum, "via", 100, opacitySuffix));
// to (bottom-right): pattern color - slightly darker shade
classList.push(randomTailwindColor(patternNum, "to", 100, opacitySuffix));
} else {
// Legacy system for backward compatibility
const colorNum = Number(legacyColorId);
classList.push(randomTailwindColor(colorNum + colorNum, "from", 100, opacitySuffix));
classList.push(randomTailwindColor(colorNum, "to", 100, opacitySuffix));
classList.push(randomTailwindColor(colorNum + colorNum, "via", 100, opacitySuffix));
}
} else {
// Non-gradient: use pattern color if available, else cover, else legacy
const colorNum = Number(props.patternId ?? props.coverId ?? legacyColorId ?? 0);
const colorClass = randomTailwindColor(colorNum, "bg", props.shade);
if (props.opacity < 100) {
classList.push(`${colorClass}/${props.opacity}`);
} else {
classList.push(colorClass);
}
}
return classList;
});
const styles = computed(() => props.customStyles);
</script>

78
components/Button.vue Normal file
View File

@@ -0,0 +1,78 @@
<template>
<button
:type="type"
:disabled="isInPendingState"
:id="id"
@click="handleClick()"
class="inline-flex items-center justify-center px-8 py-3 rounded-full font-medium transition-all duration-200"
:class="[
variantClasses,
classes,
{ 'opacity-70 cursor-wait': isInPendingState }
]"
>
<svg
v-if="isInPendingState"
aria-hidden="true"
role="status"
class="inline w-4 h-4 me-2 animate-spin"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
class="opacity-20"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentColor"
/>
</svg>
<slot />
</button>
</template>
<script setup lang="ts">
const props = defineProps<{
type?: "button" | "submit" | "reset";
variant?: "primary" | "secondary" | "outline";
classes?: string;
href?: string;
isPending?: boolean;
id?: string;
}>();
const variantClasses = computed(() => {
switch (props.variant) {
case "secondary":
return "bg-white text-gray-900 hover:bg-gray-100";
case "outline":
return "bg-transparent text-gray-900 border-2 border-gray-900 hover:bg-gray-900 hover:text-white";
default:
return "bg-gray-900 text-white hover:bg-gray-700";
}
});
const isInPendingState = ref(props.isPending ?? false);
watch(
() => props.isPending,
(newValue) => {
if (!newValue) {
setTimeout(() => {
isInPendingState.value = false;
}, 300);
} else {
isInPendingState.value = true;
}
}
);
function handleClick() {
if (props.href) {
navigateTo(props.href);
}
}
</script>

57
components/Carousel.vue Normal file
View File

@@ -0,0 +1,57 @@
<template>
<div class="carousel__wrapper">
<Carousel v-bind="carouselSettings">
<slot />
<template #addons>
<Navigation />
</template>
</Carousel>
</div>
</template>
<script setup>
import "vue3-carousel/dist/carousel.css";
import { Carousel, Navigation } from "vue3-carousel";
const carouselSettings = {
itemsToShow: 1,
wrapAround: false,
transition: 360,
autoplay: false,
gap: 20,
mouseDrag: false,
touchDrag: true,
breakpoints: {
990: {
mouseDrag: false,
touchDrag: true,
itemsToShow: 4,
snapAlign: "start",
gap: 40,
pauseAutoplayOnHover: true
}
}
};
</script>
<style>
.carousel__prev,
.carousel__next {
@apply w-24 h-24 absolute right-auto bottom-0 top-auto rounded-full hover:shadow-sm hover:bg-gray-100 focus:bg-gray-100 focus:ring-4 ring-gray-500 focus:ring-offset-2 bg-gray-200 bg-opacity-70 text-gray-400 text-sm transition-colors scale-75 font-bold;
left: min(7rem, calc(100% - 6rem));
}
.carousel__prev {
@apply left-0;
}
.carousel__viewport {
@apply overflow-hidden;
}
.carousel__wrapper {
.carousel__viewport {
@apply pb-28;
}
}
</style>

View File

@@ -0,0 +1,45 @@
<template>
<div class="bg-red-50 border border-red-200 rounded-xl p-6 text-center max-w-md mx-auto">
<div class="inline-flex items-center justify-center w-12 h-12 rounded-full bg-red-100 mb-4">
<svg class="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<h3 class="text-lg font-semibold text-red-800 mb-2">{{ title }}</h3>
<p class="text-red-600 mb-6">{{ message }}</p>
<div class="flex flex-col sm:flex-row gap-3 justify-center">
<button
v-if="showRetry"
@click="$emit('retry')"
class="px-6 py-2.5 bg-red-600 text-white rounded-full font-medium hover:bg-red-700 transition-colors"
>
Erneut versuchen
</button>
<NuxtLink
to="/"
class="px-6 py-2.5 bg-white border border-red-200 text-red-700 rounded-full font-medium hover:bg-red-50 transition-colors"
>
Zur Startseite
</NuxtLink>
</div>
</div>
</template>
<script setup lang="ts">
withDefaults(
defineProps<{
title?: string;
message?: string;
showRetry?: boolean;
}>(),
{
title: "Ein Fehler ist aufgetreten",
message: "Dein Warenkorb konnte nicht geladen werden. Bitte versuche es erneut.",
showRetry: true
}
);
defineEmits<{
retry: [];
}>();
</script>

View File

@@ -0,0 +1,66 @@
<template>
<div class="animate-pulse">
<!-- Form skeleton -->
<div v-if="variant === 'form'" class="space-y-6">
<div v-for="i in fields" :key="i" class="space-y-2">
<div class="h-4 w-24 bg-gray-200 rounded"></div>
<div class="h-12 bg-gray-200 rounded-lg"></div>
</div>
</div>
<!-- Delivery methods skeleton -->
<div v-else-if="variant === 'delivery'" class="space-y-4">
<div class="h-5 w-48 bg-gray-200 rounded mb-4"></div>
<div v-for="i in 3" :key="i" class="p-5 border-2 border-gray-200 rounded-lg">
<div class="flex justify-between">
<div class="space-y-2">
<div class="h-5 w-32 bg-gray-200 rounded"></div>
<div class="h-4 w-48 bg-gray-200 rounded"></div>
<div class="h-4 w-20 bg-gray-200 rounded mt-2"></div>
</div>
<div class="h-5 w-5 bg-gray-200 rounded-full"></div>
</div>
</div>
</div>
<!-- Order summary skeleton -->
<div v-else-if="variant === 'summary'" class="space-y-4">
<div v-for="i in 2" :key="i" class="flex gap-4">
<div class="h-20 w-20 bg-gray-200 rounded"></div>
<div class="flex-1 space-y-2">
<div class="h-4 w-3/4 bg-gray-200 rounded"></div>
<div class="h-4 w-1/2 bg-gray-200 rounded"></div>
<div class="h-4 w-20 bg-gray-200 rounded"></div>
</div>
</div>
<hr class="my-4" />
<div class="space-y-2">
<div class="flex justify-between">
<div class="h-4 w-24 bg-gray-200 rounded"></div>
<div class="h-4 w-16 bg-gray-200 rounded"></div>
</div>
<div class="flex justify-between">
<div class="h-4 w-20 bg-gray-200 rounded"></div>
<div class="h-4 w-16 bg-gray-200 rounded"></div>
</div>
<div class="flex justify-between pt-2">
<div class="h-5 w-16 bg-gray-200 rounded"></div>
<div class="h-5 w-20 bg-gray-200 rounded"></div>
</div>
</div>
</div>
<!-- Button skeleton -->
<div v-if="showButton" class="mt-8">
<div class="h-12 bg-gray-200 rounded-full w-full"></div>
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
variant: "form" | "delivery" | "summary";
fields?: number;
showButton?: boolean;
}>();
</script>

8
components/CodeBlock.vue Normal file
View File

@@ -0,0 +1,8 @@
<template>
<details class="mt-36">
<summary class="cursor-pointer outline-none focus:outline-none">
<span class="py-6">Data</span>
</summary>
<pre class="text-xs bg-gray-100 overflow-x-auto p-4"><slot></slot></pre>
</details>
</template>

112
components/ContactForm.vue Normal file
View File

@@ -0,0 +1,112 @@
<template>
<form @submit.prevent="handleSubmit" class="space-y-6">
<div>
<label for="name" class="block text-sm font-medium text-gray-700 mb-1">Name *</label>
<input
id="name"
v-model="form.name"
type="text"
required
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-900 focus:border-transparent"
placeholder="Ihr Name"
/>
</div>
<div>
<label for="email" class="block text-sm font-medium text-gray-700 mb-1">E-Mail *</label>
<input
id="email"
v-model="form.email"
type="email"
required
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-900 focus:border-transparent"
placeholder="ihre@email.de"
/>
</div>
<div>
<label for="subject" class="block text-sm font-medium text-gray-700 mb-1">Betreff</label>
<input
id="subject"
v-model="form.subject"
type="text"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-900 focus:border-transparent"
placeholder="Betreff Ihrer Nachricht"
/>
</div>
<div>
<label for="message" class="block text-sm font-medium text-gray-700 mb-1">Nachricht *</label>
<textarea
id="message"
v-model="form.message"
required
rows="5"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-900 focus:border-transparent resize-none"
placeholder="Ihre Nachricht an uns..."
></textarea>
</div>
<!-- Honeypot field for spam protection -->
<div class="hidden" aria-hidden="true">
<input v-model="form.honeypot" type="text" name="website" tabindex="-1" autocomplete="off" />
</div>
<div v-if="status === 'success'" class="p-4 bg-green-50 text-green-800 rounded-lg">Vielen Dank für Ihre Nachricht! Wir werden uns so schnell wie möglich bei Ihnen melden.</div>
<div v-if="status === 'error'" class="p-4 bg-red-50 text-red-800 rounded-lg">Es ist ein Fehler aufgetreten. Bitte versuchen Sie es später erneut oder kontaktieren Sie uns direkt per E-Mail.</div>
<button type="submit" :disabled="isSubmitting" class="w-full sm:w-auto px-8 py-3 bg-gray-900 text-white font-semibold rounded-full hover:bg-gray-800 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
<span v-if="isSubmitting">Wird gesendet...</span>
<span v-else>Nachricht senden</span>
</button>
</form>
</template>
<script setup lang="ts">
const form = reactive({
name: "",
email: "",
subject: "",
message: "",
honeypot: "", // spam protection
});
const isSubmitting = ref(false);
const status = ref<"idle" | "success" | "error">("idle");
async function handleSubmit() {
// Check honeypot
if (form.honeypot) {
status.value = "success"; // Fake success for bots
return;
}
isSubmitting.value = true;
status.value = "idle";
try {
await $fetch("/api/contact", {
method: "POST",
body: {
name: form.name,
email: form.email,
subject: form.subject || "Kontaktanfrage über Website",
message: form.message,
},
});
status.value = "success";
// Reset form
form.name = "";
form.email = "";
form.subject = "";
form.message = "";
} catch (error) {
console.error("Contact form error:", error);
status.value = "error";
} finally {
isSubmitting.value = false;
}
}
</script>

View File

@@ -0,0 +1,55 @@
<template>
<div class="space-y-4">
<div class="flex items-start gap-3">
<IconMapPin class="w-5 h-5 mt-1 text-gray-600 flex-shrink-0" />
<div>
<p class="font-semibold">{{ contact.name }}</p>
<p>{{ contact.street }}</p>
<p>{{ contact.postalCode }} {{ contact.city }}</p>
</div>
</div>
<div class="flex items-center gap-3">
<svg class="w-5 h-5 text-gray-600 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"
/>
</svg>
<a :href="`tel:${contact.phone.replace(/\s/g, '')}`" class="hover:underline">{{ contact.phone }}</a>
</div>
<div v-if="contact.fax" class="flex items-center gap-3">
<svg class="w-5 h-5 text-gray-600 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z" />
</svg>
<span>{{ contact.fax }}</span>
</div>
<div class="flex items-center gap-3">
<svg class="w-5 h-5 text-gray-600 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
<a :href="`mailto:${contact.email}`" class="hover:underline">{{ contact.email }}</a>
</div>
<div v-if="showVatId" class="flex items-center gap-3 text-sm text-gray-600">
<svg class="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<span>USt-IdNr.: {{ contact.vatId }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import type { ContactInfo } from "~/composables/usePageContent";
import IconMapPin from "~/components/icons/IconMapPin.vue";
defineProps<{
contact: ContactInfo;
showVatId?: boolean;
}>();
</script>

View File

@@ -0,0 +1,55 @@
<template>
<div
v-if="consentGiven === false"
class="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 shadow-lg px-4 py-3 text-gray-800 z-50 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-4"
>
<p class="text-xs text-gray-600 sm:flex-1">
{{ consentText }}
</p>
<div class="flex items-center gap-2 shrink-0">
<Button
href="/datenschutz"
data-cy="cookie-banner-privacy"
classes="bg-transparent border-transparent py-1.5 px-3 !text-xs !text-black hover:!bg-gray-100 shadow-none"
>
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
</Button>
</div>
</div>
</template>
<script setup lang="ts">
const COOKIE_CONSENT_KEY = "shop:cookie-consent";
const props = withDefaults(
defineProps<{
consentText?: string;
}>(),
{
consentText:
"Wir verwenden notwendige Cookies für die sichere Zahlungsabwicklung. Weitere Informationen finden Sie in unserer Datenschutzerklärung:"
}
);
const emit = defineEmits<{
"consent-given": [];
}>();
// Start with null to prevent SSR flash - banner only renders after client check
const consentGiven = ref<boolean | null>(null);
// Check localStorage only on client
onMounted(() => {
consentGiven.value = !!localStorage.getItem(COOKIE_CONSENT_KEY);
});
function acceptCookies() {
localStorage.setItem(COOKIE_CONSENT_KEY, new Date().toISOString());
consentGiven.value = true;
emit("consent-given");
}
</script>

View File

@@ -0,0 +1,62 @@
<template>
<section class="feature-module py-16 lg:py-24">
<div class="xl:container mx-auto px-6">
<div class="grid lg:grid-cols-2 gap-8 lg:gap-16 items-center">
<!-- Image -->
<div :class="imageRight ? 'lg:order-2' : ''">
<img
v-if="image"
:src="image"
:alt="headline"
class="rounded-xl shadow-md w-full object-cover aspect-[4/3]"
loading="lazy"
/>
<div v-else class="rounded-xl bg-gray-200 w-full aspect-[4/3] flex items-center justify-center">
<span class="text-gray-400 text-sm">Bild folgt</span>
</div>
</div>
<!-- Content -->
<div class="lg:w-4/5">
<p v-if="eyebrow" class="text-sm uppercase tracking-widest opacity-60 mb-2">
{{ eyebrow }}
</p>
<h2 class="font-display text-3xl lg:text-4xl font-bold mb-4">
{{ headline }}
</h2>
<p v-if="subtitle" class="text-xl lg:text-2xl opacity-80 mb-6">
{{ subtitle }}
</p>
<div class="prose prose-lg max-w-none text-gray-700 leading-relaxed" v-html="formattedBody"></div>
</div>
</div>
</div>
</section>
</template>
<script setup lang="ts">
const props = defineProps<{
eyebrow?: string;
headline: string;
subtitle?: string;
body: string;
image?: string;
imageRight?: boolean;
}>();
// Clean up multi-line template strings (remove extra whitespace from indentation)
const formattedBody = computed(() => {
return props.body
.split("\n")
.map((line) => line.trim())
.join(" ")
.replace(/\s+/g, " ")
.trim();
});
</script>
<style scoped>
.feature-module :deep(strong) {
@apply font-semibold text-gray-900;
}
</style>

104
components/Footer.vue Normal file
View File

@@ -0,0 +1,104 @@
<template>
<footer class="bg-gray-900 text-white">
<!-- Trust Bar -->
<TrustBar variant="light" />
<div class="mx-auto xl:container p-6 pt-8 lg:pt-20 lg:py-7">
<div class="md:flex md:justify-between">
<div class="mb-12">
<NuxtLink to="/" class="flex items-center" @click="trackEvent('footer-logo-clicked')">
<span class="tracking-wide text-3xl font-bebas leading-none"> MUELLERPRINTS.<br />Paperwork </span>
</NuxtLink>
<p class="mt-4 text-white">
Mo - Do 9.00 - 16.00 Uhr<br />
Fr 9.00 - 14.00 Uhr<br />
und nach Vereinbarung
</p>
</div>
<div class="grid grid-cols-1 gap-8 lg:gap-6 lg:grid-cols-4">
<div>
<!-- Intentionally left empty -->
</div>
<div>
<h2 class="mb-4 text-sm font-semibold uppercase">Kollektion</h2>
<ul class="text-gray-400 font-medium mb-4">
<li v-for="item in sortedCovers" :key="item.id" class="mb-2 lg:mb-4">
<NuxtLink
:title="item.description ?? item.label"
:to="`/notebooks/${item.slug}`"
class="hover:text-white"
aria-label="Navigation"
@click="trackEvent('footer-link-clicked', { section: 'kollektion', label: item.label })"
>
{{ item.label }}
</NuxtLink>
</li>
</ul>
</div>
<div>
<h2 class="mb-4 text-sm font-semibold uppercase">Über uns</h2>
<ul class="text-gray-400 font-medium mb-4">
<li class="mb-2 lg:mb-4">
<NuxtLink to="/about" class="hover:text-white" @click="trackEvent('footer-link-clicked', { section: 'about', label: 'About' })">About</NuxtLink>
</li>
<li class="mb-2 lg:mb-4">
<NuxtLink to="/oeffnungszeiten" class="hover:text-white" @click="trackEvent('footer-link-clicked', { section: 'about', label: 'Öffnungszeiten' })">Öffnungszeiten</NuxtLink>
</li>
<li class="mb-2 lg:mb-4">
<NuxtLink to="/anfahrt" class="hover:text-white" @click="trackEvent('footer-link-clicked', { section: 'about', label: 'Anfahrt' })">Anfahrt</NuxtLink>
</li>
<li class="mb-2 lg:mb-4">
<NuxtLink to="/kontakt" class="hover:text-white" @click="trackEvent('footer-link-clicked', { section: 'about', label: 'Kontakt' })">Kontakt</NuxtLink>
</li>
<li class="mb-2 lg:mb-4">
<a title="Sitemap" href="/sitemap.xml" target="_blank" class="hover:text-white" @click="trackEvent('footer-link-clicked', { section: 'about', label: 'Sitemap' })">Sitemap</a>
</li>
</ul>
</div>
<div>
<h2 class="mb-4 text-sm font-semibold uppercase">Rechtliches</h2>
<ul class="text-gray-400 font-medium mb-4">
<li class="mb-2 lg:mb-4">
<NuxtLink to="/impressum" class="hover:text-white" @click="trackEvent('footer-link-clicked', { section: 'legal', label: 'Impressum' })">Impressum</NuxtLink>
</li>
<li class="mb-2 lg:mb-4">
<NuxtLink to="/datenschutz" class="hover:text-white" @click="trackEvent('footer-link-clicked', { section: 'legal', label: 'Datenschutz' })">Datenschutzerklärung</NuxtLink>
</li>
<li class="mb-2 lg:mb-4">
<NuxtLink to="/agb" class="hover:text-white" @click="trackEvent('footer-link-clicked', { section: 'legal', label: 'AGB' })">Allgemeine Geschäftsbedingungen (AGB)</NuxtLink>
</li>
<li class="mb-2 lg:mb-4">
<NuxtLink to="/zahlung" class="hover:text-white" @click="trackEvent('footer-link-clicked', { section: 'legal', label: 'Zahlung' })">Zahlungsarten</NuxtLink>
</li>
<li>
<NuxtLink to="/versand" class="hover:text-white" @click="trackEvent('footer-link-clicked', { section: 'legal', label: 'Versand' })">Versandarten</NuxtLink>
</li>
</ul>
</div>
</div>
</div>
<hr class="my-6 sm:mx-auto border-gray-700 lg:my-8" />
<div class="flex flex-col-reverse lg:flex-row items-center justify-between gap-8">
<span class="text-gray-400">
© {{ currentYear }} <a href="/" class="hover:text-white">MUELLERPRINTS. PAPERWORK</a> Alle Rechte vorbehalten
</span>
<div class="flex flex-col lg:flex-row mt-4 lg:mt-0 items-start lg:items-center text-gray-400 gap-8">
<span>Unterstützte Zahlungsanbieter: BAR, VISA, Mastercard und PayPal</span>
<img class="h-12" src="~/assets/visa-mastercard-paypal.svg" alt="VISA Mastercard PayPal Logos" />
</div>
</div>
</div>
</footer>
</template>
<script setup lang="ts">
import type { CoverNavItem } from "~/types";
const props = defineProps<{
covers: CoverNavItem[];
}>();
const currentYear = new Date().getFullYear();
const sortedCovers = computed(() => [...props.covers].sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0)));
</script>

104
components/Header.vue Normal file
View File

@@ -0,0 +1,104 @@
<template>
<header class="text-gray-900 z-50" :class="{ 'bg-white bg-opacity-95 absolute shadow-xl top-0 left-0 right-0 z-[99999]': isDarkMode }">
<div class="xl:container mx-auto lg:text-lg">
<div class="flex flex-col lg:flex-row lg:gap-12 relative">
<div class="flex lg:w-1/4 justify-between">
<NuxtLink to="/" class="max-sm:sticky max-sm:top-0 p-6 py-6 lg:py-7 hover:text-red-600" @click="trackEvent('header-logo-clicked')">
<span class="tracking-wide text-3xl font-bebas leading-none block">
<span class="!text-black">MUELLERPRINTS</span>.<br />Paperwork
</span>
</NuxtLink>
<button @click="toggleMobileMenu()" class="p-6 py-6 lg:py-7 lg:hidden" :class="{ 'bg-white': !isDarkMode }" aria-label="Menü öffnen" :aria-expanded="isMobileMenuOpen">
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16m-7 6h7"></path>
</svg>
</button>
</div>
<div
v-if="route.name"
role="navigation"
class="grow lg:w-3/4 p-6 py-6 lg:py-7 hidden lg:block bg-transparent"
:class="{ '!block bg-white': isMobileMenuOpen, 'text-gray-900': isMobileMenuOpen && isDarkMode }"
>
<nav class="flex justify-end" v-if="isCheckoutRoute" aria-label="Navigation">
<HeaderNavLink v-if="!isPaymentRoute" label="Zurück zum Warenkorb" path="/cart" @click="trackEvent('header-back-to-cart-clicked')" />
</nav>
<nav
class="flex flex-col lg:flex-row justify-between gap-4 lg:gap-28 lg:h-full"
v-else
aria-label="Navigation"
@click="closeMobileMenu()"
>
<div class="flex flex-col lg:flex-row gap-4 lg:gap-2">
<HeaderNavLink
label="Shop"
description="Alle Notizbücher"
path="/notebooks"
@click="trackEvent('header-item-clicked', { label: 'Shop' })"
/>
<HeaderNavLink
v-for="item in sortedCovers"
:key="item.id"
:label="item.label"
:description="item.description"
: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"
class="px-4 py-0.5 rounded-full font-medium text-lg text-black transition-all duration-200 flex items-center gap-2 hover:bg-gray-900 hover:text-white"
active-class="!bg-gray-900 !text-white"
title="Warenkorb"
@click="trackEvent('header-cart-clicked')"
>
Warenkorb
<span
v-if="productsCount > 0"
class="inline-flex items-center justify-center w-5 h-5 text-xs font-semibold rounded-full bg-gray-900 text-white group-hover:bg-white group-hover:text-gray-900"
:class="{ '!bg-white !text-gray-900': $route.path === '/cart' }"
>
{{ productsCount }}
</span>
</NuxtLink>
</nav>
</div>
</div>
</div>
</header>
</template>
<script setup lang="ts">
import type { CoverNavItem } from "~/types";
const props = defineProps<{
covers: CoverNavItem[];
isDarkMode?: boolean;
}>();
const route = useRoute();
const { productsCount } = useCart();
const isMobileMenuOpen = ref(false);
const sortedCovers = computed(() => [...props.covers].sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0)));
const isCheckoutRoute = computed(() => String(route.name).startsWith("checkout-"));
const isPaymentRoute = computed(() => route.name === "checkout-3");
function toggleMobileMenu() {
isMobileMenuOpen.value = !isMobileMenuOpen.value;
}
function closeMobileMenu() {
isMobileMenuOpen.value = false;
}
</script>

View File

@@ -0,0 +1,21 @@
<template>
<NuxtLink
:to="path"
class="px-4 py-0.5 rounded-full font-medium text-lg text-black transition-all duration-200 flex flex-col justify-center hover:bg-gray-900 hover:text-white"
active-class="!bg-gray-900 !text-white"
aria-label="Navigation"
:title="description ?? label"
>
{{ label }}
</NuxtLink>
</template>
<script setup lang="ts">
defineProps<{
path?: string;
to?: string;
label: string;
description?: string;
isDarkMode?: boolean;
}>();
</script>

36
components/Heading.vue Normal file
View File

@@ -0,0 +1,36 @@
<script setup lang="ts">
type Level = 1 | 2 | 3 | 4 | 5;
type HtmlTag = "h1" | "h2" | "h3" | "h4" | "h5" | "p";
const levelClasses: Record<Level, string> = {
1: "text-3xl lg:text-3xl font-bold mb-5 mt-6",
2: "text-2xl font-bold mb-4 mt-5",
3: "text-xl mt-2 font-semibold mb-1",
4: "text-md mt-2 mb-1 font-semibold",
5: "text-sm uppercase mb-1 font-semibold mt-2 text-gray-500"
};
const props = withDefaults(
defineProps<{
level?: Level;
htmlTag?: HtmlTag;
classes?: string;
}>(),
{
level: 1,
htmlTag: "p",
classes: ""
}
);
const computedClass = computed(() => levelClasses[props.level] + " " + props.classes);
</script>
<template>
<h1 v-if="htmlTag === 'h1'" :class="computedClass"><slot /></h1>
<h2 v-else-if="htmlTag === 'h2'" :class="computedClass"><slot /></h2>
<h3 v-else-if="htmlTag === 'h3'" :class="computedClass"><slot /></h3>
<h4 v-else-if="htmlTag === 'h4'" :class="computedClass"><slot /></h4>
<h5 v-else-if="htmlTag === 'h5'" :class="computedClass"><slot /></h5>
<p v-else :class="computedClass"><slot /></p>
</template>

119
components/Hero.vue Normal file
View File

@@ -0,0 +1,119 @@
<template>
<section class="w-full bg-gray-950 text-white overflow-hidden pt-0 h-min-[22rem] h-[48vh] lg:h-[60vh] relative">
<div class="h-min-[22rem] h-[48vh] lg:h-[60vh] bg-cover bg-center shadow-xl" :style="`background-image: url('${slide04Url}')`">
<div class="absolute inset-0 bg-gradient-to-b from-black/0 to-black/60"></div>
<div class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/100 to-black/0 bg-opacity-60 py-6 pb-4 lg:py-8">
<div class="container mx-auto px-6 lg:px-12 text-center relative">
<div class="hero-carousel">
<div class="hero-slide">
<div class="w-full px-6 text-center">
<h3 class="font-bebas text-2xl lg:text-3xl leading-none mb-1">100% Recyceltes Altpapier</h3>
<hr class="w-20 h-1 border-red-600 border-t-[3px] mx-auto mb-2" />
<p class="block lg:w-2/3 mx-auto text-sm lg:text-md leading-tight tracking-wide">
Umweltfreundliche Materialien für eine nachhaltige Zukunft
</p>
</div>
</div>
<div class="hero-slide">
<div class="w-full px-6 text-center">
<h3 class="font-bebas text-2xl lg:text-3xl leading-none mb-1">Hergestellt in Deutschland</h3>
<hr class="w-20 h-1 border-red-600 border-t-[3px] mx-auto mb-2" />
<p class="block lg:w-2/3 mx-auto text-sm lg:text-md leading-tight tracking-wide">
Qualität und Tradition aus lokaler Produktion
</p>
</div>
</div>
<div class="hero-slide">
<div class="w-full px-6 text-center">
<h3 class="font-bebas text-2xl lg:text-3xl leading-none mb-1">Höchste Qualität</h3>
<hr class="w-20 h-1 border-red-600 border-t-[3px] mx-auto mb-2" />
<p class="block lg:w-2/3 mx-auto text-sm lg:text-md leading-tight tracking-wide">
Premium-Produkte für anspruchsvolle Anwendungen
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
</template>
<script setup>
import slide04Url from "~/assets/landingpage/04.jpg";
</script>
<style scoped>
.hero-carousel {
position: relative;
min-height: 4.5rem;
}
.hero-slide {
position: absolute;
inset: 0;
opacity: 0;
animation: hero-fade 9.6s infinite;
}
.hero-slide:nth-child(1) {
animation-delay: 0s;
}
.hero-slide:nth-child(2) {
animation-delay: 3.2s;
}
.hero-slide:nth-child(3) {
animation-delay: 6.4s;
}
@keyframes hero-fade {
0% {
opacity: 0;
}
5% {
opacity: 1;
}
28% {
opacity: 1;
}
33.33% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@media (min-width: 990px) {
.hero-carousel {
display: flex;
gap: 2.5rem;
min-height: auto;
}
.hero-slide {
position: static;
opacity: 1;
animation: none;
flex: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.hero-carousel {
display: flex;
flex-direction: column;
gap: 1.5rem;
min-height: auto;
}
.hero-slide {
position: static;
opacity: 1;
animation: none;
}
}
</style>

67
components/Input.vue Normal file
View File

@@ -0,0 +1,67 @@
<template>
<label
class="focus-within:ring-4 focus-within:ring-offset-2 ring-gray-500 relative w-full border border-gray-500 rounded-md cursor-text h-12 overflow-hidden"
>
<span :class="spanClasses" class="absolute left-2 top-0 transition-all select-none">{{ label }}</span>
<input
:value="modelValue"
@input="update(($event.target as HTMLInputElement).value)"
:required="required"
class="outline-none w-full h-full rounded-sm pt-4 p-2 border-transparent"
placeholder=""
:autocomplete="autocomplete"
:aria-label="label"
@focus="inputFocused = true"
@blur="inputFocused = false"
/>
</label>
</template>
<script setup lang="ts">
type Autocomplete =
| "given-name"
| "family-name"
| "email"
| "tel"
| "street-address"
| "postal-code"
| "country-name"
| "off"
| "on"
| "name"
| "address-level2"
| "country";
const props = withDefaults(
defineProps<{
modelValue?: string;
label?: string;
required?: boolean;
labelClass?: string;
inputClass?: string;
autocomplete?: Autocomplete;
}>(),
{
autocomplete: "off"
}
);
const emit = defineEmits<{
"update:modelValue": [value: string];
}>();
const inputFocused = ref(false);
const spanClasses = computed(() => ({
"text-md pt-3": !inputFocused.value && !props.modelValue,
"text-xs pt-[3px]": inputFocused.value || props.modelValue,
"text-gray-500": !inputFocused.value && !props.modelValue,
"text-gray-400": inputFocused.value || props.modelValue
}));
function update(newValue?: string) {
if (newValue !== undefined) {
emit("update:modelValue", newValue);
}
}
</script>

View File

@@ -0,0 +1,9 @@
<template>
<div class="flex justify-center items-center">
<div class="w-20 h-20">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 50" class="animate-spin">
<circle cx="25" cy="25" r="20" fill="none" stroke="currentColor" stroke-width="4" stroke-dasharray="60 100" />
</svg>
</div>
</div>
</template>

View File

@@ -0,0 +1,59 @@
<template>
<div class="space-y-6">
<!-- Address -->
<div v-if="showAddress" class="flex items-start gap-3">
<IconMapPin class="w-6 h-6 mt-1 text-gray-600 flex-shrink-0" />
<div>
<p class="font-semibold text-lg">{{ address.name }}</p>
<p>{{ address.street }}</p>
<p>{{ address.postalCode }} {{ address.city }}</p>
</div>
</div>
<!-- OpenStreetMap Embed -->
<div class="relative w-full aspect-video rounded-xl overflow-hidden shadow-lg">
<iframe
:src="mapUrl"
width="100%"
height="100%"
style="border: 0"
allowfullscreen=""
loading="lazy"
class="absolute inset-0"
></iframe>
</div>
<!-- Directions Link -->
<a :href="directionsUrl" target="_blank" rel="noopener noreferrer" class="inline-flex items-center gap-2 text-gray-900 font-medium hover:underline">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Route planen
</a>
</div>
</template>
<script setup lang="ts">
import IconMapPin from "~/components/icons/IconMapPin.vue";
const props = defineProps<{
address: {
name: string;
street: string;
postalCode: string;
city: string;
};
showAddress?: boolean;
}>();
// OpenStreetMap embed — hardcoded coordinates for Rotenbergstraße 39, 70190 Stuttgart
const lat = 48.7862;
const lon = 9.2;
const bbox = `${lon - 0.005},${lat - 0.0025},${lon + 0.005},${lat + 0.0025}`;
const mapUrl = `https://www.openstreetmap.org/export/embed.html?bbox=${bbox}&layer=mapnik&marker=${lat},${lon}`;
const encodedAddress = computed(() => encodeURIComponent(`${props.address.street}, ${props.address.postalCode} ${props.address.city}`));
const directionsUrl = computed(() => `https://www.openstreetmap.org/directions?to=${lat},${lon}#map=16/${lat}/${lon}`);
</script>

View File

@@ -0,0 +1,16 @@
<template>
<div class="space-y-2">
<div v-for="day in hours" :key="day.day" class="flex justify-between py-2 border-b border-gray-100 last:border-0">
<span class="font-medium">{{ day.day }}</span>
<span :class="day.hours === 'Geschlossen' ? 'text-gray-400' : 'text-gray-900'">{{ day.hours }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import type { OpeningHoursDay } from "~/composables/usePageContent";
defineProps<{
hours: OpeningHoursDay[];
}>();
</script>

19
components/PageHero.vue Normal file
View File

@@ -0,0 +1,19 @@
<template>
<section class="relative bg-gray-900 text-white py-20 lg:py-32">
<div v-if="image" class="absolute inset-0 opacity-30">
<img :src="image" :alt="title" class="w-full h-full object-cover" />
</div>
<div class="relative xl:container mx-auto px-6">
<h1 class="text-4xl lg:text-5xl font-bold mb-4">{{ title }}</h1>
<p v-if="subtitle" class="text-xl lg:text-2xl text-gray-300 max-w-2xl">{{ subtitle }}</p>
</div>
</section>
</template>
<script setup lang="ts">
defineProps<{
title: string;
subtitle?: string;
image?: string;
}>();
</script>

View File

@@ -0,0 +1,51 @@
<template>
<section class="py-12 lg:py-16" :class="bgClass">
<div class="xl:container mx-auto px-6">
<h2 v-if="title" class="text-2xl lg:text-3xl font-bold mb-6">{{ title }}</h2>
<div class="prose prose-lg max-w-none" v-html="renderedContent"></div>
</div>
</section>
</template>
<script setup lang="ts">
import { marked } from "marked";
const props = defineProps<{
title?: string;
content: string;
variant?: "white" | "gray";
}>();
const renderedContent = computed(() => marked(props.content));
const bgClass = computed(() => (props.variant === "gray" ? "bg-gray-50" : "bg-white"));
</script>
<style scoped>
.prose :deep(p) {
@apply mb-4;
}
.prose :deep(strong) {
@apply font-semibold;
}
.prose :deep(a) {
@apply text-gray-900 underline underline-offset-2 hover:text-gray-600;
}
.prose :deep(ul) {
@apply list-disc pl-6 mb-4;
}
.prose :deep(ol) {
@apply list-decimal pl-6 mb-4;
}
.prose :deep(h2) {
@apply text-2xl font-bold mt-8 mb-4;
}
.prose :deep(h3) {
@apply text-xl font-semibold mt-6 mb-3;
}
</style>

View File

@@ -0,0 +1,98 @@
<template>
<a
:href="product.slug ? `/details/${product.slug}` : undefined"
class="cursor-pointer w-full gap-2 flex flex-col items-stretch group hover:scale-105 transition-all z-100"
@click="handleProductClick"
>
<div
v-if="variant === 'neutral'"
class="p-12 flex items-center justify-center rounded-lg relative"
>
<div class="absolute bottom-5 drop-shadow-sm text-gray-800 text-md">{{ product.pattern?.name }}</div>
<img
v-if="product.images?.images"
:src="product.images?.images[0].formats.small.url"
:alt="product.name"
class="lg:w-full object-cover max-h-64 lg:max-h-fit" loading="lazy"
/>
<div v-else class="bg-black opacity-5 shadow-sm lg:w-full object-cover min-h-48 lg:max-h-fit"></div>
</div>
<Background
v-else
:coverId="product.cover?.id"
:patternId="product.pattern?.id"
:shade="200"
:gradient="true"
:class="[...shadowColors]"
class="p-12 flex items-center justify-center rounded-lg shadow-none group-hover:shadow-2xl transition-all duration-300 relative"
>
<div class="absolute bottom-5 drop-shadow-sm text-gray-800 text-md">{{ product.pattern?.name }}</div>
<img
v-if="product.images?.images"
:src="product.images?.images[0].formats.small.url"
:alt="product.name"
class="lg:w-full object-cover max-h-64 lg:max-h-fit" loading="lazy"
/>
<div v-else class="bg-black opacity-5 shadow-sm lg:w-full object-cover min-h-48 lg:max-h-fit"></div>
</Background>
<div class="flex flex-col justify-between">
<!-- Product Options Pills -->
<div v-if="hasProductOptions" class="pt-4 pb-1 flex flex-wrap gap-1">
<span v-if="product.cover?.name" class="text-xs px-2 py-1 bg-gray-100 rounded-full text-gray-600">{{ product.cover.name }}</span>
<span v-if="product.ruling?.name" class="text-xs px-2 py-1 bg-gray-100 rounded-full text-gray-600">{{ product.ruling.name }}</span>
<span v-if="product.pages?.name" class="text-xs px-2 py-1 bg-gray-100 rounded-full text-gray-600">{{ product.pages.name }}</span>
</div>
<!-- Price -->
<div v-if="product.totalProductPrice" class="px-2 py-2">
<p class="text-gray-600 text-xs text-left">ab {{ numberFormatter(product.totalProductPrice, "€") }}</p>
</div>
</div>
</a>
</template>
<script setup lang="ts">
import { randomTailwindColor } from "~/utils/randomTailwindColor";
import { numberFormatter } from "~/utils/numberFormatter";
import { trackEvent } from "~/utils/trackEvent";
import type { Product } from "~/types";
const props = withDefaults(defineProps<{
product: Product;
variant?: "default" | "neutral";
}>(), {
variant: "default",
});
const shadowColors = computed(() => {
return (
props.product?.pattern && [
randomTailwindColor(props.product.pattern.id, "group-hover:shadow", 200, "/60")
]
);
});
const hasProductOptions = computed(() => {
return props.product?.cover || props.product?.ruling || props.product?.pages;
});
function handlePersonalizedProductClick() {
const subject = `Anfrage für personalisiertes Produkt: ${props.product.cover?.name}`;
const body = `Hallo,\n\nich interessiere mich für ein personalisiertes Produkt: ${props.product.cover?.name}\n\nBitte kontaktieren Sie mich für weitere Details.\n\nVielen Dank!`;
window.location.href = `mailto:paperwork@muellerprints.de?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
}
function handleProductClick() {
trackEvent("product-card-clicked", {
productId: props.product.id,
productSlug: props.product.slug,
productName: props.product.name,
price: props.product.totalProductPrice
});
if (props.product.slug) {
navigateTo(`/details/${props.product.slug}`);
} else {
handlePersonalizedProductClick();
}
}
</script>

View File

@@ -0,0 +1,29 @@
<template>
<Carousel
:gap="40"
:items-to-show="1"
:wrap-around="true"
:autoplay="4200"
:transition="600"
:mouse-drag="false"
:touch-drag="true"
>
<Slide v-for="slide in slides" :key="slide.id">
<img :src="slide.formats?.medium?.url || slide.url" alt="Product Image" class="rounded-xl shadow-xl" />
</Slide>
<template #addons>
<Pagination />
</template>
</Carousel>
</template>
<script setup lang="ts">
import "vue3-carousel/dist/carousel.css";
import { Carousel, Slide, Pagination } from "vue3-carousel";
import type { MediaData } from "~/types";
defineProps<{
slides: MediaData[];
}>();
</script>

View File

@@ -0,0 +1,36 @@
<template>
<NuxtLink
:to="path"
:title="ariaLabel"
:aria-label="ariaLabel"
:aria-checked="isActive"
:class="{
'border-black bg-white': isActive,
'border-transparent cursor-pointer bg-gray-100 opacity-80 hover:opacity-100 hover:border-gray-400 pointer': !isActive,
'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"
>
<div v-if="$slots.default" class="w-16 h-16 bg-transparent overflow-hidden">
<slot></slot>
</div>
<div
class="text-sm text-gray-800 pointer-events-none"
:class="{
'font-semibold': isActive
}"
>
{{ label }}
</div>
</NuxtLink>
</template>
<script setup lang="ts">
defineProps<{
label: string;
path: string;
isActive?: boolean;
ariaLabel?: string;
ariaDisabled?: boolean;
}>();
</script>

49
components/Step.vue Normal file
View File

@@ -0,0 +1,49 @@
<template>
<li :class="{ 'text-black': active, 'text-green-600': isCompleted, 'cursor-pointer hover:text-black group': url }" class="flex">
<button class="flex items-center" @click="navigate(url)" :disabled="!url">
<!-- Completed step: checkmark -->
<span
v-if="isCompleted"
class="flex items-center justify-center w-6 h-6 me-2 text-xs bg-green-100 text-green-600 rounded-full shrink-0"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
</svg>
</span>
<!-- Current step: filled circle with number -->
<span
v-else-if="active"
class="flex items-center justify-center w-6 h-6 me-2 text-xs font-bold bg-black text-white rounded-full shrink-0"
>
{{ number }}
</span>
<!-- Future step: outline circle with number -->
<span
v-else
class="flex items-center justify-center w-6 h-6 me-2 text-xs border-2 border-gray-300 text-gray-400 rounded-full shrink-0"
>
{{ number }}
</span>
<span :class="{ 'font-semibold': active, 'hidden lg:block': !active && !isCompleted }" class="group-hover:underline group-hover:underline-offset-4">
{{ text }}
</span>
</button>
</li>
</template>
<script setup lang="ts">
const props = defineProps<{
number: number;
active?: boolean;
text: string;
url?: string;
}>();
// Step is completed if it has a URL (means we can go back to it)
const isCompleted = computed(() => Boolean(props.url));
function navigate(url?: string) {
if (!url) return;
navigateTo(url);
}
</script>

22
components/Stepper.vue Normal file
View File

@@ -0,0 +1,22 @@
<template>
<ol
class="flex items-center justify-between lg:justify-start w-full p-3 space-x-2 text-sm font-medium text-center text-gray-500 bg-white border border-gray-200 rounded-lg shadow-sm sm:text-base sm:p-4 sm:space-x-4 rtl:space-x-reverse"
>
<Step :number="1" :active="step === 1" :url="step > 1 && step !== 4 ? getCheckoutBaseUrl(1) : undefined" text="E-Mail-Adresse" />
<Step :number="2" :active="step === 2" :url="step > 2 && step !== 4 ? getCheckoutBaseUrl(2) : undefined" text="Lieferadresse" />
<Step :number="3" :active="step === 3" :url="step > 3 && step !== 4 ? getCheckoutBaseUrl(3) : undefined" text="Bezahlung" />
<Step :number="4" :active="step === 4" text="Bestelldetails" />
</ol>
</template>
<script setup lang="ts">
const checkoutBaseUrl = "/checkout";
defineProps<{
step: number;
}>();
function getCheckoutBaseUrl(step: number) {
return `${checkoutBaseUrl}/${step}`;
}
</script>

74
components/Teaser.vue Normal file
View File

@@ -0,0 +1,74 @@
<template>
<section class="w-full relative overflow-hidden min-h-[16rem]" :style="backgroundStyle">
<div class="absolute inset-0 bg-gradient-to-b from-black/40 to-black/60"></div>
<div class="container mx-auto px-6 h-full flex flex-col items-center justify-between py-16 relative z-10">
<!-- Top content with heading and optional subheading -->
<div class="text-center mt-12 mb-8">
<h2 class="font-bebas text-4xl md:text-5xl lg:text-6xl text-white tracking-tight leading-none">
{{ title }}
</h2>
<p v-if="subtitle" class="mt-4 text-white/90 text-lg md:text-xl max-w-2xl mx-auto">
{{ subtitle }}
</p>
<!-- Button after title option -->
<div v-if="buttonPosition === 'after-title'" class="mt-8">
<NuxtLink
v-if="href"
:to="href"
class="bg-white text-gray-900 hover:bg-gray-200 hover:text-red-600 transition-colors px-8 py-3 rounded-full font-medium"
>
{{ buttonText }}
</NuxtLink>
<button
v-else
@click="$emit('button-click')"
class="bg-white text-gray-900 hover:bg-gray-200 hover:text-red-600 transition-colors px-8 py-3 rounded-full font-medium"
>
{{ buttonText }}
</button>
</div>
</div>
<!-- Bottom button option -->
<div v-if="buttonPosition === 'bottom'" class="mb-12">
<NuxtLink
v-if="href"
:to="href"
class="bg-white text-gray-900 hover:bg-gray-200 hover:text-red-600 transition-colors px-8 py-3 rounded-full font-medium"
>
{{ buttonText }}
</NuxtLink>
<button
v-else
@click="$emit('button-click')"
class="bg-white text-gray-900 hover:bg-gray-200 hover:text-red-600 transition-colors px-8 py-3 rounded-full font-medium"
>
{{ buttonText }}
</button>
</div>
</div>
</section>
</template>
<script setup lang="ts">
const props = defineProps<{
backgroundImage: string;
title: string;
subtitle?: string;
buttonText?: string;
buttonPosition?: "bottom" | "after-title";
height?: string;
href?: string;
}>();
const emit = defineEmits(["button-click"]);
const backgroundStyle = computed(() => ({
backgroundImage: `url('${props.backgroundImage}')`,
backgroundSize: "cover",
backgroundPosition: "center",
height: props.height || "70vh"
}));
</script>

View File

@@ -0,0 +1,31 @@
<template>
<section class="py-16 px-6 xl:container mx-auto">
<details open class="group bg-white rounded-xl shadow-md p-6">
<summary class="cursor-pointer flex justify-between items-center">
<h3 class="text-2xl font-bold">Technische Details</h3>
<svg
class="w-5 h-5 transition-transform duration-200 group-open:rotate-180"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</summary>
<div class="pt-6">
<dl class="grid grid-cols-1 sm:grid-cols-2 gap-6">
<div v-for="(value, label) in specs" :key="label" class="border-b border-gray-100 pb-3">
<dt class="text-gray-500 text-sm">{{ label }}</dt>
<dd class="font-medium text-gray-900 mt-1">{{ value }}</dd>
</div>
</dl>
</div>
</details>
</section>
</template>
<script setup lang="ts">
defineProps<{
specs: Record<string, string>;
}>();
</script>

View File

@@ -0,0 +1,95 @@
<template>
<div class="mx-auto text-center md:max-w-xl lg:max-w-3xl mb-3">
<Heading :level="1" html-tag="h2" v-if="showTitle">{{ title }}</Heading>
<p v-if="description" class="mb-6 pb-2 text-gray-800 md:mb-12 md:pb-0">
{{ description }}
</p>
</div>
<div class="grid gap-6 text-center md:grid-cols-3 lg:gap-12">
<div v-for="(testimonial, index) in testimonials" :key="index" class="mb-12 md:mb-0">
<p class="mb-4 text-gray-800">
<span v-if="showQuoteIcon" class="inline-block pe-2" :style="{ color: primaryColor }">
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 448 512" class="w-5 h-5">
<path
d="M0 216C0 149.7 53.7 96 120 96h8c17.7 0 32 14.3 32 32s-14.3 32-32 32h-8c-30.9 0-56 25.1-56 56v8h64c35.3 0 64 28.7 64 64v64c0 35.3-28.7 64-64 64H64c-35.3 0-64-28.7-64-64V320 288 216zm256 0c0-66.3 53.7-120 120-120h8c17.7 0 32 14.3 32 32s-14.3 32-32 32h-8c-30.9 0-56 25.1-56 56v8h64c35.3 0 64 28.7 64 64v64c0 35.3-28.7 64-64 64H320c-35.3 0-64-28.7-64-64V320 288 216z"
/>
</svg>
</span>
{{ testimonial.comment }}
</p>
<ul v-if="showRating" class="mb-0 flex items-center justify-center">
<li v-for="i in 5" :key="i">
<svg
v-if="i <= testimonial.rating"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="h-5 w-5"
:style="{ color: starColor }"
>
<path
fill-rule="evenodd"
d="M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.007 5.404.433c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.433 2.082-5.006z"
clip-rule="evenodd"
/>
</svg>
<svg
v-else
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="h-5 w-5"
:style="{ color: starColor }"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.563.563 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.562.562 0 00-.182-.557l-4.204-3.602a.563.563 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z"
/>
</svg>
</li>
</ul>
</div>
</div>
</template>
<script lang="ts" setup>
interface Testimonial {
name?: string;
role?: string;
avatar?: string;
comment: string;
rating: number;
}
withDefaults(
defineProps<{
testimonials: Testimonial[];
title?: string;
description?: string;
primaryColor?: string;
starColor?: string;
showTitle?: boolean;
showName?: boolean;
showRole?: boolean;
showAvatar?: boolean;
showRating?: boolean;
showQuoteIcon?: boolean;
}>(),
{
title: "Kundenstimmen",
description: "",
primaryColor: "black",
starColor: "#fbbf24",
showTitle: true,
showName: true,
showRole: true,
showAvatar: true,
showRating: true,
showQuoteIcon: true
}
);
</script>

59
components/TrustBar.vue Normal file
View File

@@ -0,0 +1,59 @@
<template>
<section
class="py-8"
:class="variant === 'light' ? 'bg-white text-gray-700 border-y border-gray-200' : 'bg-gray-900 text-white'"
>
<div class="xl:container mx-auto px-6">
<div class="grid grid-cols-2 lg:grid-cols-4 gap-6 lg:gap-0">
<div
v-for="(pillar, index) in pillars"
:key="pillar.title"
class="flex items-start gap-3 lg:px-6 lg:border-l lg:first:border-l-0"
:class="variant === 'light' ? 'lg:border-gray-300' : 'lg:border-gray-400'"
>
<component :is="pillar.icon" class="w-6 h-6 flex-shrink-0 opacity-60" />
<div>
<h4 class="font-semibold text-sm lg:text-base">{{ pillar.title }}</h4>
<p class="text-xs lg:text-sm opacity-70 mt-1">{{ pillar.description }}</p>
</div>
</div>
</div>
</div>
</section>
</template>
<script setup lang="ts">
withDefaults(defineProps<{
variant?: "dark" | "light";
}>(), {
variant: "dark",
});
import IconMapPin from "~/components/icons/IconMapPin.vue";
import IconRecycle from "~/components/icons/IconRecycle.vue";
import IconTruck from "~/components/icons/IconTruck.vue";
import IconShield from "~/components/icons/IconShield.vue";
const pillars = [
{
icon: IconMapPin,
title: "Made in Stuttgart",
description: "Handgebunden in unserer Werkstatt keine langen Transportwege.",
},
{
icon: IconRecycle,
title: "100% Recyclingpapier",
description: "FSC-zertifiziert, Blauer Engel, CO₂-neutral produziert.",
},
{
icon: IconTruck,
title: "Schneller Versand",
description: "Deine Bestellung ist in 2-3 Werktagen bei Dir.",
},
{
icon: IconShield,
title: "Sicher bezahlen",
description: "BAR, PayPal, Kreditkarte, Lastschrift verschlüsselt & geschützt.",
},
];
</script>

View File

@@ -0,0 +1,41 @@
<template>
<section class="py-16">
<div class="xl:container mx-auto px-6">
<div class="text-center mb-10">
<h3 class="text-2xl font-bold mb-3">Anwendungsbereiche</h3>
<p class="text-gray-600 max-w-2xl mx-auto">Vielseitig einsetzbar für Kreative, Studenten und Berufstätige.</p>
</div>
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 lg:gap-6">
<div v-for="useCase in useCases" :key="useCase.title" class="text-center p-4 lg:p-6">
<component :is="iconComponents[useCase.icon]" class="w-8 h-8 lg:w-10 lg:h-10 mx-auto mb-3 text-gray-700" />
<h4 class="font-semibold mb-1 lg:mb-2 text-sm lg:text-base">{{ useCase.title }}</h4>
<p class="text-xs lg:text-sm text-gray-600">{{ useCase.description }}</p>
</div>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import IconPen from "~/components/icons/IconPen.vue";
import IconPalette from "~/components/icons/IconPalette.vue";
import IconClipboard from "~/components/icons/IconClipboard.vue";
import IconBriefcase from "~/components/icons/IconBriefcase.vue";
export interface UseCase {
icon: "pen" | "palette" | "clipboard" | "briefcase";
title: string;
description: string;
}
defineProps<{
useCases: UseCase[];
}>();
const iconComponents = {
pen: IconPen,
palette: IconPalette,
clipboard: IconClipboard,
briefcase: IconBriefcase,
};
</script>

View File

@@ -0,0 +1,9 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M20.25 14.15v4.25c0 1.094-.787 2.036-1.872 2.18-2.087.277-4.216.42-6.378.42s-4.291-.143-6.378-.42c-1.085-.144-1.872-1.086-1.872-2.18v-4.25m16.5 0a2.18 2.18 0 0 0 .75-1.661V8.706c0-1.081-.768-2.015-1.837-2.175a48.114 48.114 0 0 0-3.413-.387m4.5 8.006c-.194.165-.42.295-.673.38A23.978 23.978 0 0 1 12 15.75c-2.648 0-5.195-.429-7.577-1.22a2.016 2.016 0 0 1-.673-.38m0 0A2.18 2.18 0 0 1 3 12.489V8.706c0-1.081.768-2.015 1.837-2.175a48.111 48.111 0 0 1 3.413-.387m7.5 0V5.25A2.25 2.25 0 0 0 13.5 3h-3a2.25 2.25 0 0 0-2.25 2.25v.894m7.5 0a48.667 48.667 0 0 0-7.5 0M12 12.75h.008v.008H12v-.008Z"
/>
</svg>
</template>

View File

@@ -0,0 +1,9 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 0 0 2.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 0 0-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75 2.25 2.25 0 0 0-.1-.664m-5.8 0A2.251 2.251 0 0 1 13.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25ZM6.75 12h.008v.008H6.75V12Zm0 3h.008v.008H6.75V15Zm0 3h.008v.008H6.75V18Z"
/>
</svg>
</template>

View File

@@ -0,0 +1,14 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1 1 15 0Z"
/>
</svg>
</template>

View File

@@ -0,0 +1,9 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M4.098 19.902a3.75 3.75 0 0 0 5.304 0l6.401-6.402M6.75 21A3.75 3.75 0 0 1 3 17.25V4.125C3 3.504 3.504 3 4.125 3h5.25c.621 0 1.125.504 1.125 1.125v4.072M6.75 21a3.75 3.75 0 0 0 3.75-3.75V8.197M6.75 21h13.125c.621 0 1.125-.504 1.125-1.125v-5.25c0-.621-.504-1.125-1.125-1.125h-4.072M10.5 8.197l2.88-2.88c.438-.439 1.15-.439 1.59 0l3.712 3.713c.44.44.44 1.152 0 1.59l-2.879 2.88M6.75 17.25h.008v.008H6.75v-.008Z"
/>
</svg>
</template>

View File

@@ -0,0 +1,9 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125"
/>
</svg>
</template>

View File

@@ -0,0 +1,9 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19.5 12c0-1.232-.046-2.453-.138-3.662a4.006 4.006 0 0 0-3.7-3.7 48.678 48.678 0 0 0-7.324 0 4.006 4.006 0 0 0-3.7 3.7c-.017.22-.032.441-.046.662M19.5 12l3-3m-3 3-3-3m-12 3c0 1.232.046 2.453.138 3.662a4.006 4.006 0 0 0 3.7 3.7 48.656 48.656 0 0 0 7.324 0 4.006 4.006 0 0 0 3.7-3.7c.017-.22.032-.441.046-.662M4.5 12l3 3m-3-3-3 3"
/>
</svg>
</template>

View File

@@ -0,0 +1,9 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 12.75 11.25 15 15 9.75m-3-7.036A11.959 11.959 0 0 1 3.598 6 11.99 11.99 0 0 0 3 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285Z"
/>
</svg>
</template>

View File

@@ -0,0 +1,9 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M8.25 18.75a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m3 0h6m-9 0H3.375a1.125 1.125 0 0 1-1.125-1.125V14.25m17.25 4.5a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m3 0h1.125c.621 0 1.129-.504 1.09-1.124a17.902 17.902 0 0 0-3.213-9.193 2.056 2.056 0 0 0-1.58-.86H14.25M16.5 18.75h-2.25m0-11.177v-.958c0-.568-.422-1.048-.987-1.106a48.554 48.554 0 0 0-10.026 0 1.106 1.106 0 0 0-.987 1.106v7.635m12-6.677v6.677m0 4.5v-4.5m0 0h-12"
/>
</svg>
</template>