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:
84
components/Background.vue
Normal file
84
components/Background.vue
Normal 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
78
components/Button.vue
Normal 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
57
components/Carousel.vue
Normal 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>
|
||||
45
components/CheckoutError.vue
Normal file
45
components/CheckoutError.vue
Normal 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>
|
||||
66
components/CheckoutSkeleton.vue
Normal file
66
components/CheckoutSkeleton.vue
Normal 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
8
components/CodeBlock.vue
Normal 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
112
components/ContactForm.vue
Normal 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>
|
||||
55
components/ContactInfo.vue
Normal file
55
components/ContactInfo.vue
Normal 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>
|
||||
55
components/CookieBanner.vue
Normal file
55
components/CookieBanner.vue
Normal 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>
|
||||
62
components/FeatureModule.vue
Normal file
62
components/FeatureModule.vue
Normal 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
104
components/Footer.vue
Normal 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
104
components/Header.vue
Normal 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>
|
||||
21
components/HeaderNavLink.vue
Normal file
21
components/HeaderNavLink.vue
Normal 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
36
components/Heading.vue
Normal 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
119
components/Hero.vue
Normal 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
67
components/Input.vue
Normal 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>
|
||||
9
components/LoadingSpinner.vue
Normal file
9
components/LoadingSpinner.vue
Normal 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>
|
||||
59
components/LocationMap.vue
Normal file
59
components/LocationMap.vue
Normal 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>
|
||||
16
components/OpeningHours.vue
Normal file
16
components/OpeningHours.vue
Normal 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
19
components/PageHero.vue
Normal 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>
|
||||
51
components/PageSection.vue
Normal file
51
components/PageSection.vue
Normal 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>
|
||||
98
components/ProductCard.vue
Normal file
98
components/ProductCard.vue
Normal 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>
|
||||
29
components/ProductCarousel.vue
Normal file
29
components/ProductCarousel.vue
Normal 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>
|
||||
36
components/SelectionBox.vue
Normal file
36
components/SelectionBox.vue
Normal 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
49
components/Step.vue
Normal 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
22
components/Stepper.vue
Normal 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
74
components/Teaser.vue
Normal 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>
|
||||
31
components/TechnicalSpecs.vue
Normal file
31
components/TechnicalSpecs.vue
Normal 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>
|
||||
95
components/Testimonial.vue
Normal file
95
components/Testimonial.vue
Normal 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
59
components/TrustBar.vue
Normal 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>
|
||||
41
components/UseCaseGrid.vue
Normal file
41
components/UseCaseGrid.vue
Normal 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>
|
||||
9
components/icons/IconBriefcase.vue
Normal file
9
components/icons/IconBriefcase.vue
Normal 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>
|
||||
9
components/icons/IconClipboard.vue
Normal file
9
components/icons/IconClipboard.vue
Normal 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>
|
||||
14
components/icons/IconMapPin.vue
Normal file
14
components/icons/IconMapPin.vue
Normal 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>
|
||||
9
components/icons/IconPalette.vue
Normal file
9
components/icons/IconPalette.vue
Normal 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>
|
||||
9
components/icons/IconPen.vue
Normal file
9
components/icons/IconPen.vue
Normal 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>
|
||||
9
components/icons/IconRecycle.vue
Normal file
9
components/icons/IconRecycle.vue
Normal 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>
|
||||
9
components/icons/IconShield.vue
Normal file
9
components/icons/IconShield.vue
Normal 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>
|
||||
9
components/icons/IconTruck.vue
Normal file
9
components/icons/IconTruck.vue
Normal 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>
|
||||
Reference in New Issue
Block a user