feat: extract shop from mp/shop — initial libreshop/shop
Some checks failed
Build and publish / build (push) Failing after 19s

Source moved verbatim from mp/shop/ on 2026-04-29; mp was the first
concrete adapter consuming the libreshop toolkit. Builds and publishes
git.librete.ch/libreshop/shop on every main / v* push via the standard
.gitea/workflows/build.yml shared across libreshop components.
This commit is contained in:
Michael Czechowski
2026-04-29 17:48:56 +02:00
commit 44107c0734
134 changed files with 19521 additions and 0 deletions

View File

@@ -0,0 +1,55 @@
name: Build and publish
on:
push:
branches: [main]
tags: ["v*"]
pull_request:
branches: [main]
# Required secrets:
# REGISTRY git.librete.ch
# REGISTRY_USER libretech-bot
# REGISTRY_PASS bot PAT (write:package; bot is in libreshop Owners team)
# Required variable:
# PUBLISH_ENABLED "true" to actually push (off = build-only on PRs)
#
# Image: git.librete.ch/libreshop/shop
# main pushes → :main + :sha-<short>
# tag pushes → :<tag> + :latest
jobs:
build:
runs-on: ubuntu-latest
container:
image: git.librete.ch/libretech/runner-image:v1
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Login (only when publishing)
if: ${{ vars.PUBLISH_ENABLED == 'true' }}
uses: docker/login-action@v3
with:
registry: ${{ secrets.REGISTRY }}
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASS }}
- id: meta
uses: docker/metadata-action@v5
with:
images: ${{ secrets.REGISTRY }}/libreshop/shop
tags: |
type=ref,event=branch
type=ref,event=tag
type=sha,format=short
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/') }}
- uses: docker/build-push-action@v6
with:
context: .
push: ${{ vars.PUBLISH_ENABLED == 'true' && github.event_name == 'push' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

39
.gitignore vendored Normal file
View File

@@ -0,0 +1,39 @@
# Nuxt dev/build outputs
.output
.data
.nuxt
.nitro
.cache
dist
# Node dependencies
node_modules
# Logs
logs
*.log
# Misc
.DS_Store
.fleet
.idea
# Local env files
.env
.env.*
!.env.example
# libreshop additions
.env
.env.local
.env.*.local
node_modules/
.nuxt/
.output/
.cache/
.parcel-cache/
dist/
build/
coverage/
logs/
tmp/

8
CHANGELOG.md Normal file
View File

@@ -0,0 +1,8 @@
# Changelog
All notable changes to libreshop/shop are documented here.
## Unreleased
- Extracted from `mp/shop/` (2026-04-29). The component history before
the extraction lives in the `muellerprints` repository.

39
Dockerfile Normal file
View File

@@ -0,0 +1,39 @@
# Builder stage (use debian-based image for native binding compatibility)
FROM node:22-slim AS builder
WORKDIR /app
# Copy package files and install dependencies
COPY package.json package-lock.json ./
RUN npm ci --no-audit --no-fund
# Copy source code
COPY . .
# Build the application
# No build args needed - Nuxt runtimeConfig is overridden at runtime via NUXT_* env vars
RUN npm run build
# Development stage
FROM builder AS dev
EXPOSE 9999
CMD ["npm", "run", "dev"]
# Production stage (can use slim image since we only need node runtime)
FROM node:22-slim AS prod
WORKDIR /app
# Copy built application from builder
COPY --from=builder /app/.output /app/.output
# Runtime defaults (overridden by docker-compose environment)
ENV NODE_ENV=production \
HOST=0.0.0.0 \
PORT=9999
EXPOSE 9999
CMD ["node", ".output/server/index.mjs"]

25
README.md Normal file
View File

@@ -0,0 +1,25 @@
# libreshop/shop
Vue/Nuxt-style storefront base.
Part of the [libreshop](https://git.librete.ch/libreshop) toolkit. Image
published at `git.librete.ch/libreshop/shop` on every push to `main`
and on `v*` tags.
## Source
This repo was extracted from `mp/shop/` on 2026-04-29; mp was the
first concrete adapter consuming the toolkit. mp's `compose.yml` now
pulls `git.librete.ch/libreshop/shop:<pin>` instead of building locally.
## Build locally
```
docker build -t libreshop/shop:dev .
```
## Adapter contract
See `docker-entrypoint.sh` and `Dockerfile` for the runtime surface.
Adapters configure the component via env vars and bind-mounted volumes;
do not patch the running container or rely on internal paths.

23
app.vue Normal file
View File

@@ -0,0 +1,23 @@
<template>
<NuxtLoadingIndicator color="#dc2626" />
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>
<script setup lang="ts">
// Initialize cart on app mount (client-side only)
// Skip on checkout pages that have their own order loading logic
const route = useRoute();
const { initialize: initCart } = useCart();
// Pages that manage their own order state and shouldn't have cart auto-init
const skipCartInitPaths = ["/checkout/3", "/checkout/result"];
onMounted(() => {
const shouldSkip = skipCartInitPaths.some((path) => route.path.startsWith(path));
if (!shouldSkip) {
initCart();
}
});
</script>

16
app/router.options.ts Normal file
View File

@@ -0,0 +1,16 @@
import type { RouterConfig } from "@nuxt/schema";
export default <RouterConfig>{
scrollBehavior(to, from, savedPosition) {
if (savedPosition) {
return savedPosition;
}
// Product details → Product details: keep scroll position
if (from.path.startsWith("/details/") && to.path.startsWith("/details/")) {
return false;
}
return { top: 0, behavior: "instant" };
}
};

24
assets/css/main.css Normal file
View File

@@ -0,0 +1,24 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--vc-slide-gap: 0;
}
html,
body {
@apply scroll-smooth;
}
#__nuxt {
@apply min-h-screen flex flex-col overflow-x-hidden;
}
main {
@apply flex-grow;
}
.carousel__slide {
@apply pb-0;
}

BIN
assets/landingpage/01.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
assets/landingpage/02.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 828 KiB

BIN
assets/landingpage/03.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

BIN
assets/landingpage/04.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 658 KiB

BIN
assets/production/01.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

BIN
assets/production/02.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 825 KiB

BIN
assets/production/03.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 607 KiB

BIN
assets/production/04.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 410 KiB

View File

@@ -0,0 +1,213 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="323.76422"
height="59.779175"
viewBox="0 0 323.76422 59.779175"
fill="none"
version="1.1"
id="svg73"
sodipodi:docname="visa-mastercard-paypal.svg"
inkscape:version="1.3.2 (091e20ef0f, 2023-11-25, custom)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview73"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
showguides="true"
inkscape:zoom="1.9350978"
inkscape:cx="-5.1676975"
inkscape:cy="-17.053402"
inkscape:window-width="1920"
inkscape:window-height="1131"
inkscape:window-x="0"
inkscape:window-y="32"
inkscape:window-maximized="1"
inkscape:current-layer="svg73">
<sodipodi:guide
position="324.35976,584.29259"
orientation="1,0"
id="guide73"
inkscape:locked="false" />
<sodipodi:guide
position="-0.92886851,516.07956"
orientation="0,-1"
id="guide74"
inkscape:locked="false" />
</sodipodi:namedview>
<path
d="M 0,5.9999999 C 0,2.6859999 2.686,0 6,0 h 79.953999 c 3.314,0 6,2.6859999 6,5.9999999 V 53.770001 c 0,3.314 -2.686,6 -6,6 H 6 c -3.314,0 -6,-2.686 -6,-6 z"
fill="#293381"
id="path1" />
<rect
x="3.5370026"
y="2.2989957"
width="84.8806"
height="55.172401"
fill="#293381"
id="rect2" />
<path
d="m 81.031999,42.815001 h -5.463 c -0.187,-0.863 -0.407,-1.699 -0.539,-2.549 0.005,-0.293 -0.117,-0.572 -0.333,-0.76 -0.215,-0.189 -0.501,-0.265 -0.778,-0.21 -2.027,0.047 -4.057,-0.027 -6.081,0.055 -0.461,0.098 -0.857,0.401 -1.083,0.829 -0.381,0.864 -0.713,1.752 -0.992,2.657 h -6.185 c 0.17,-0.48 0.298,-0.896 0.462,-1.296 2.756,-6.728 5.54,-13.444 8.257,-20.187 0.45,-1.492 1.85,-2.448 3.346,-2.285 1.493,0.055 2.989,0.011 4.549,0.011 1.621,7.949 3.222,15.799 4.84,23.735"
fill="#ffffff"
id="path2" />
<path
d="m 5.9072075,19.185526 c 0.339,-0.089 0.685,-0.149 1.033,-0.179 2.9619989,-0.013 5.9239985,-0.014 8.8859985,-0.002 0.775,-0.12 1.563,0.103 2.172,0.616 0.609,0.513 0.984,1.268 1.031,2.081 0.791,4.15 1.541,8.309 2.309,12.464 0.035,0.191 0.087,0.379 0.198,0.858 0.26,-0.539 0.427,-0.832 0.548,-1.144 1.782,-4.585 3.575,-9.165 5.317,-13.765 0.303,-0.798 0.64,-1.187 1.554,-1.136 1.649,0.092 3.306,0.026 5.158,0.026 -0.923,2.282 -1.777,4.415 -2.647,6.54 -2.209,5.392 -4.449,10.77 -6.612,16.181 -0.199,0.774 -0.938,1.26 -1.697,1.114 -1.658,-0.076 -3.321,-0.022 -5.115,-0.022 -0.678,-2.652 -1.355,-5.281 -2.022,-7.914 -0.903,-3.572 -1.849,-7.134 -2.673,-10.725 -0.23,-1.387 -1.189,-2.522 -2.483,-2.938 -1.6389992,-0.576 -3.2869985,-1.125 -4.9309985,-1.686 z"
fill="#ffffff"
id="path3" />
<path
d="m 60.809999,19.780001 c -0.329,1.592 -0.646,3.124 -0.971,4.691 -1.333,-0.369 -2.688,-0.644 -4.057,-0.823 -1.122,-0.088 -2.249,0.071 -3.308,0.467 -1.484,0.597 -1.581,2.013 -0.304,2.997 0.77,0.537 1.57,1.026 2.395,1.465 0.595,0.354 1.198,0.696 1.796,1.046 2.074,1.177 3.365,3.437 3.365,5.893 0,2.456 -1.29,4.715 -3.365,5.892 -2.983,1.678 -6.428,2.252 -9.767,1.625 -1.084,-0.143 -2.152,-0.394 -3.191,-0.749 -0.366,-0.2 -0.606,-0.583 -0.635,-1.012 0.214,-1.427 0.568,-2.832 0.847,-4.131 1.86,0.398 3.639,0.897 5.452,1.118 1.005,0.093 2.018,-0.055 2.959,-0.434 0.765,-0.162 1.347,-0.81 1.45,-1.614 0.103,-0.804 -0.296,-1.588 -0.994,-1.952 -0.603,-0.419 -1.229,-0.801 -1.874,-1.145 -1.037,-0.561 -2.041,-1.184 -3.009,-1.865 -1.458,-1.048 -2.384,-2.724 -2.518,-4.561 -0.135,-1.838 0.536,-3.64 1.825,-4.906 1.408,-1.468 3.221,-2.446 5.187,-2.799 2.919,-0.617 5.947,-0.34 8.717,0.797"
fill="#ffffff"
id="path4" />
<path
d="m 37.792206,42.757526 h -5.981 c 1.661,-7.931 3.305,-15.784 4.959,-23.681 h 5.969 c -1.659,7.94 -3.299,15.788 -4.947,23.681"
fill="#ffffff"
id="path5" />
<path
d="m 72.093999,25.941001 c 0.566,2.789 1.131,5.578 1.718,8.47 h -5.042 c 1.034,-2.917 2.03,-5.729 3.026,-8.54 z"
fill="#293381"
id="path6" />
<g
clip-path="url(#clip0)"
id="g9"
transform="translate(-125.99998,-131.99999)">
<rect
x="241.953"
y="132"
width="91.954002"
height="59.7701"
fill="#000000"
id="rect6" />
<path
d="M 279.039,176.343 H 296.82 V 147.426 H 279.039 Z"
fill="#f06022"
id="path7" />
<path
d="m 280.875,161.885 c -0.005,-2.781 0.627,-5.527 1.849,-8.031 1.222,-2.504 3.002,-4.702 5.206,-6.428 -3.246,-2.551 -7.27,-3.937 -11.415,-3.932 -4.91,0.031 -9.607,1.982 -13.068,5.428 -3.46,3.445 -5.402,8.106 -5.402,12.963 0,4.857 1.942,9.517 5.402,12.963 3.461,3.445 8.158,5.397 13.068,5.428 4.145,0.005 8.169,-1.382 11.415,-3.932 -2.204,-1.726 -3.984,-3.924 -5.206,-6.428 -1.222,-2.505 -1.854,-5.25 -1.849,-8.031"
fill="#ea1d25"
id="path8" />
<path
d="m 317.815,161.885 c 0.015,4.862 -1.922,9.531 -5.386,12.98 -3.463,3.449 -8.17,5.395 -13.084,5.411 -4.145,0.005 -8.17,-1.381 -11.415,-3.932 2.2,-1.729 3.977,-3.928 5.199,-6.431 1.221,-2.504 1.856,-5.248 1.856,-8.028 0,-2.78 -0.635,-5.524 -1.856,-8.028 -1.222,-2.503 -2.999,-4.702 -5.199,-6.431 3.245,-2.551 7.27,-3.937 11.415,-3.932 4.914,0.016 9.621,1.962 13.084,5.411 3.464,3.449 5.401,8.118 5.386,12.98"
fill="#f79d1d"
id="path9" />
</g>
<g
clip-path="url(#clip4)"
id="g33"
transform="translate(105.81021,-215.76083)">
<rect
x="126"
y="215.77"
width="91.954002"
height="59.7701"
fill="#ffffff"
id="rect29" />
<path
d="m 164.878,264.017 0.647,-4.188 -1.442,-0.034 h -6.886 l 4.786,-30.902 c 0.015,-0.094 0.063,-0.181 0.133,-0.243 0.071,-0.061 0.161,-0.095 0.255,-0.095 h 11.611 c 3.855,0 6.515,0.817 7.904,2.429 0.651,0.756 1.066,1.547 1.266,2.417 0.211,0.912 0.214,2.003 0.009,3.333 l -0.015,0.097 v 0.852 l 0.651,0.376 c 0.549,0.296 0.984,0.635 1.319,1.023 0.557,0.647 0.917,1.469 1.069,2.444 0.157,1.002 0.105,2.194 -0.152,3.545 -0.297,1.553 -0.778,2.905 -1.426,4.012 -0.597,1.02 -1.357,1.866 -2.259,2.522 -0.862,0.622 -1.885,1.095 -3.043,1.398 -1.121,0.297 -2.4,0.447 -3.803,0.447 h -0.903 c -0.646,0 -1.274,0.237 -1.767,0.662 -0.493,0.434 -0.82,1.026 -0.92,1.674 l -0.069,0.377 -1.143,7.381 -0.052,0.271 c -0.014,0.086 -0.037,0.129 -0.072,0.158 -0.031,0.026 -0.076,0.044 -0.119,0.044 z"
fill="#253b80"
id="path30" />
<path
d="m 184.415,236.93 v 0 0 c -0.035,0.225 -0.075,0.456 -0.119,0.693 -1.532,8.007 -6.77,10.772 -13.461,10.772 h -3.406 c -0.818,0 -1.508,0.606 -1.635,1.427 v 0 0 l -1.744,11.266 -0.494,3.193 c -0.083,0.54 0.325,1.026 0.86,1.026 h 6.042 c 0.715,0 1.323,-0.529 1.436,-1.248 l 0.059,-0.313 1.138,-7.352 0.073,-0.403 c 0.111,-0.721 0.72,-1.251 1.436,-1.251 h 0.903 c 5.854,0 10.437,-2.42 11.776,-9.424 0.559,-2.926 0.27,-5.37 -1.211,-7.088 -0.448,-0.518 -1.004,-0.948 -1.653,-1.298 z"
fill="#179bd7"
id="path31" />
<path
d="m 182.812,236.278 c -0.234,-0.069 -0.475,-0.132 -0.723,-0.189 -0.249,-0.055 -0.504,-0.104 -0.766,-0.147 -0.919,-0.151 -1.925,-0.223 -3.003,-0.223 h -9.101 c -0.224,0 -0.437,0.051 -0.627,0.145 -0.42,0.205 -0.732,0.61 -0.807,1.105 l -1.936,12.488 -0.056,0.365 c 0.127,-0.822 0.817,-1.427 1.635,-1.427 h 3.407 c 6.69,0 11.929,-2.767 13.46,-10.773 0.046,-0.237 0.084,-0.467 0.119,-0.693 -0.388,-0.209 -0.807,-0.388 -1.259,-0.541 -0.111,-0.038 -0.227,-0.074 -0.343,-0.11 z"
fill="#222d65"
id="path32" />
<path
d="m 167.785,236.969 c 0.076,-0.495 0.388,-0.9 0.807,-1.104 0.192,-0.093 0.404,-0.145 0.628,-0.145 h 9.101 c 1.078,0 2.084,0.072 3.003,0.223 0.262,0.043 0.517,0.092 0.766,0.148 0.247,0.057 0.489,0.12 0.723,0.189 0.116,0.035 0.231,0.072 0.344,0.108 0.452,0.153 0.871,0.333 1.259,0.541 0.455,-2.959 -0.004,-4.973 -1.575,-6.797 -1.731,-2.009 -4.857,-2.868 -8.857,-2.868 h -11.61 c -0.817,0 -1.514,0.605 -1.641,1.428 l -4.836,31.22 c -0.095,0.618 0.373,1.175 0.984,1.175 h 7.169 l 1.799,-11.629 z"
fill="#253b80"
id="path33" />
</g>
<defs
id="defs73">
<linearGradient
id="paint0_linear"
x1="364.228"
y1="289.76901"
x2="443.53799"
y2="369.07901"
gradientUnits="userSpaceOnUse">
<stop
stop-color="#289847"
id="stop63" />
<stop
offset="0.49"
stop-color="#1787B9"
id="stop64" />
<stop
offset="1"
stop-color="#1D3564"
id="stop65" />
</linearGradient>
<clipPath
id="clip0">
<path
d="m 241.953,138 c 0,-3.314 2.686,-6 6,-6 h 79.954 c 3.314,0 6,2.686 6,6 v 47.77 c 0,3.314 -2.686,6 -6,6 h -79.954 c -3.314,0 -6,-2.686 -6,-6 z"
fill="#ffffff"
id="path65" />
</clipPath>
<clipPath
id="clip1">
<path
d="m 357.906,138 c 0,-3.314 2.687,-6 6,-6 h 79.954 c 3.314,0 6,2.686 6,6 v 47.77 c 0,3.314 -2.686,6 -6,6 h -79.954 c -3.313,0 -6,-2.686 -6,-6 z"
fill="#ffffff"
id="path66" />
</clipPath>
<clipPath
id="clip2">
<path
d="m 126,305.539 c 0,-3.314 2.686,-6 6,-6 h 79.954 c 3.314,0 6,2.686 6,6 v 47.77 c 0,3.314 -2.686,6 -6,6 H 132 c -3.314,0 -6,-2.686 -6,-6 z"
fill="#ffffff"
id="path67" />
</clipPath>
<clipPath
id="clip3">
<path
d="m 357.906,305.539 c 0,-3.314 2.687,-6 6,-6 h 79.954 c 3.314,0 6,2.686 6,6 v 47.77 c 0,3.314 -2.686,6 -6,6 h -79.954 c -3.313,0 -6,-2.686 -6,-6 z"
fill="#ffffff"
id="path68" />
</clipPath>
<clipPath
id="clip4">
<path
d="m 126,221.77 c 0,-3.314 2.686,-6 6,-6 h 79.954 c 3.314,0 6,2.686 6,6 v 47.77 c 0,3.313 -2.686,6 -6,6 H 132 c -3.314,0 -6,-2.687 -6,-6 z"
fill="#ffffff"
id="path69" />
</clipPath>
<clipPath
id="clip5">
<path
d="m 241.953,221.77 c 0,-3.314 2.686,-6 6,-6 h 79.954 c 3.314,0 6,2.686 6,6 v 47.77 c 0,3.313 -2.686,6 -6,6 h -79.954 c -3.314,0 -6,-2.687 -6,-6 z"
fill="#ffffff"
id="path70" />
</clipPath>
<clipPath
id="clip6">
<path
d="m 357.906,221.77 c 0,-3.314 2.687,-6 6,-6 h 79.954 c 3.314,0 6,2.686 6,6 v 47.77 c 0,3.313 -2.686,6 -6,6 h -79.954 c -3.313,0 -6,-2.687 -6,-6 z"
fill="#ffffff"
id="path71" />
</clipPath>
<clipPath
id="clip7">
<path
d="m 241.953,389.309 c 0,-3.314 2.686,-6 6,-6 h 79.954 c 3.314,0 6,2.686 6,6 v 47.77 c 0,3.313 -2.686,6 -6,6 h -79.954 c -3.314,0 -6,-2.687 -6,-6 z"
fill="#ffffff"
id="path72" />
</clipPath>
<clipPath
id="clip8">
<path
d="m 357.906,389.309 c 0,-3.314 2.687,-6 6,-6 h 79.954 c 3.314,0 6,2.686 6,6 v 47.77 c 0,3.313 -2.686,6 -6,6 h -79.954 c -3.313,0 -6,-2.687 -6,-6 z"
fill="#ffffff"
id="path73" />
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 11 KiB

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>

289
composables/useCart.ts Normal file
View File

@@ -0,0 +1,289 @@
import type { Cart, Order, CartProduct } from "~/types";
import { trackEvent } from "~/utils/trackEvent";
const CART_STORAGE_KEY = "shop:cart";
const MAX_RETRY_ATTEMPTS = 3;
const RETRY_DELAYS = [1000, 2000, 4000]; // Exponential backoff: 1s, 2s, 4s
// Module-level promise for deduplication (not reactive, just a lock)
let initPromise: Promise<void> | null = null;
export function useCart() {
const shopApi = useShopApi();
// Use useState for proper Nuxt SSR/client state management
const isInitialized = useState<boolean>("cart:initialized", () => false);
const isInitializing = useState<boolean>("cart:initializing", () => false);
const hasError = useState<boolean>("cart:hasError", () => false);
const retryCount = useState<number>("cart:retryCount", () => 0);
const cart = useState<Cart>("cart", () => ({
uuid: "",
products: [],
productsCount: 0,
total: 0,
subtotal: 0,
VAT: 0,
delivery: 0,
deliveryMethod: null,
emailAddress: "",
invoiceAddress: "",
deliveryAddress: "",
invoiceAddressStructured: null,
deliveryAddressStructured: null,
acceptedTermsAndConditionsAt: false
}));
function calculateCount(products: CartProduct[] | null) {
return products ? products.reduce((sum, { count }) => sum + count, 0) : 0;
}
function overwrite(order: Order) {
cart.value.products = (order.cart as CartProduct[] | null) ?? [];
cart.value.productsCount = calculateCount(cart.value.products);
cart.value.total = order.total ?? 0;
cart.value.subtotal = order.subtotal ?? 0;
cart.value.VAT = order.VAT ?? 0;
cart.value.delivery = order.delivery?.price ?? 0;
cart.value.deliveryMethod = order.delivery?.id ?? null;
cart.value.uuid = order.uuid ?? "";
cart.value.emailAddress = order.email ?? "";
cart.value.invoiceAddress = order.invoiceAddress ?? "";
cart.value.deliveryAddress = order.deliveryAddress ?? "";
cart.value.invoiceAddressStructured = order.invoiceAddressStructured ?? null;
cart.value.deliveryAddressStructured = order.deliveryAddressStructured ?? null;
cart.value.acceptedTermsAndConditionsAt = Boolean(order.acceptedTermsAndConditionsAt);
// Persist to localStorage (client-only)
if (import.meta.client) {
localStorage.setItem(CART_STORAGE_KEY, order.uuid);
}
}
/**
* Sleep utility for retry delays
*/
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Reset cart to empty state
*/
function resetCart() {
cart.value = {
uuid: "",
products: [],
productsCount: 0,
total: 0,
subtotal: 0,
VAT: 0,
delivery: 0,
deliveryMethod: null,
emailAddress: "",
invoiceAddress: "",
deliveryAddress: "",
invoiceAddressStructured: null,
deliveryAddressStructured: null,
acceptedTermsAndConditionsAt: false
};
}
/**
* Attempt to fetch cart with retry logic
*/
async function fetchWithRetry(attempt = 0): Promise<Order | null> {
const storedUuid = localStorage.getItem(CART_STORAGE_KEY);
try {
let order: Order | null = null;
if (storedUuid) {
try {
order = await shopApi.getOrder(storedUuid);
// If order is paid, we need a fresh cart
if (order?.paymentAuthorised) {
order = null;
}
} catch {
// Order not found or error - will create new one
order = null;
}
}
// Create new order if we don't have a valid one
if (!order) {
order = await shopApi.createOrder();
}
return order;
} catch (error) {
// Track retry attempt
if (attempt > 0) {
trackEvent("cart-fetch-retry", { attempt, maxAttempts: MAX_RETRY_ATTEMPTS });
}
// Check if we should retry
if (attempt < MAX_RETRY_ATTEMPTS - 1) {
const delay = RETRY_DELAYS[attempt] || RETRY_DELAYS[RETRY_DELAYS.length - 1];
console.warn(`Cart fetch attempt ${attempt + 1} failed, retrying in ${delay}ms...`);
await sleep(delay);
return fetchWithRetry(attempt + 1);
}
// All retries exhausted
trackEvent("cart-fetch-failed", { attempts: attempt + 1 });
throw error;
}
}
async function fetch() {
// Only run on client
if (!import.meta.client) return;
hasError.value = false;
retryCount.value = 0;
try {
const order = await fetchWithRetry();
if (order) {
overwrite(order);
}
} catch (error) {
console.error("Error fetching/creating cart after retries:", error);
localStorage.removeItem(CART_STORAGE_KEY);
hasError.value = true;
resetCart();
}
}
/**
* Retry cart initialization after an error
*/
async function retry() {
if (!import.meta.client) return;
hasError.value = false;
isInitialized.value = false;
retryCount.value += 1;
trackEvent("cart-manual-retry", { retryCount: retryCount.value });
await initialize();
}
/**
* Initialize cart - ensures cart is fetched exactly once.
* Safe to call from multiple components; subsequent calls return the same promise.
*/
async function initialize() {
// Only run on client
if (!import.meta.client) return;
// Already initialized
if (isInitialized.value) return;
// Already initializing - wait for existing promise
if (isInitializing.value && initPromise) {
return initPromise;
}
// Start initialization
isInitializing.value = true;
initPromise = fetch().finally(() => {
isInitialized.value = true;
isInitializing.value = false;
});
return initPromise;
}
/**
* Wait for cart to be ready. Use this in components that need the cart.
*/
async function ensureReady() {
if (!import.meta.client) return;
if (!isInitialized.value) {
await initialize();
}
}
async function addProduct(productId: number, count = 1) {
// Ensure cart is initialized
if (!cart.value.uuid) {
await fetch();
}
// Verify we have a valid uuid before making the API call
if (!cart.value.uuid) {
throw new Error("Failed to initialize cart - no order UUID available");
}
const order = await shopApi.addProductToCart(cart.value.uuid, productId, count);
overwrite(order);
}
async function removeProduct(productId: number, count = 1) {
const order = await shopApi.removeProductFromCart(cart.value.uuid, productId, count);
overwrite(order);
}
async function update(data: Partial<Order>) {
const order = await shopApi.updateOrder(cart.value.uuid, data);
overwrite(order);
}
async function checkout() {
return await shopApi.checkoutOrder(cart.value.uuid);
}
// Expose individual computed refs for easier access in components
const uuid = computed(() => cart.value.uuid);
const products = computed(() => cart.value.products);
const productsCount = computed(() => cart.value.productsCount);
const total = computed(() => cart.value.total);
const subtotal = computed(() => cart.value.subtotal);
const VAT = computed(() => cart.value.VAT);
const delivery = computed(() => cart.value.delivery);
const deliveryMethod = computed(() => cart.value.deliveryMethod);
const emailAddress = computed(() => cart.value.emailAddress);
const invoiceAddress = computed(() => cart.value.invoiceAddress);
const deliveryAddress = computed(() => cart.value.deliveryAddress);
const invoiceAddressStructured = computed(() => cart.value.invoiceAddressStructured);
const deliveryAddressStructured = computed(() => cart.value.deliveryAddressStructured);
const acceptedTermsAndConditionsAt = computed(() => cart.value.acceptedTermsAndConditionsAt);
return {
// State
isInitialized,
isInitializing,
hasError,
retryCount,
// Individual computed refs for easy template access
uuid,
products,
productsCount,
total,
subtotal,
VAT,
delivery,
deliveryMethod,
emailAddress,
invoiceAddress,
deliveryAddress,
invoiceAddressStructured,
deliveryAddressStructured,
acceptedTermsAndConditionsAt,
// Methods
initialize,
ensureReady,
fetch,
retry,
overwrite,
addProduct,
removeProduct,
update,
checkout
};
}

View File

@@ -0,0 +1,287 @@
// Page content for static pages
// Migrated from Strapi CMS to enable better styling and semantic URLs
export interface ContactInfo {
name: string;
street: string;
postalCode: string;
city: string;
phone: string;
fax?: string;
email: string;
website: string;
vatId: string;
}
export interface OpeningHoursDay {
day: string;
hours: string;
}
export interface PageSection {
title?: string;
content: string; // markdown or HTML
}
// =============================================================================
// CONTACT INFORMATION
// =============================================================================
export const CONTACT_INFO: ContactInfo = {
name: "Max Müller",
street: "Rotenbergstraße 39",
postalCode: "70190",
city: "Stuttgart",
phone: "+49 711 262 49 64",
fax: "+49 711 262 48 60",
email: "paperwork@muellerprints.de",
website: "https://www.muellerprints.de",
vatId: "DE 147595459",
};
// =============================================================================
// OPENING HOURS
// =============================================================================
export const OPENING_HOURS: OpeningHoursDay[] = [
{ day: "Montag", hours: "09:00 - 18:00" },
{ day: "Dienstag", hours: "09:00 - 18:00" },
{ day: "Mittwoch", hours: "09:00 - 18:00" },
{ day: "Donnerstag", hours: "09:00 - 18:00" },
{ day: "Freitag", hours: "09:00 - 16:00" },
{ day: "Samstag", hours: "Geschlossen" },
{ day: "Sonntag", hours: "Geschlossen" },
];
// =============================================================================
// ABOUT PAGE CONTENT
// =============================================================================
export const ABOUT_CONTENT = {
hero: {
title: "Über uns",
subtitle: "Handarbeit aus Stuttgart seit über 30 Jahren",
image: "/images/production/04.png",
},
sections: [
{
content: `**Wir kombinieren traditionelles Buchbinderhandwerk mit moderner Technik, um einzigartige und langlebige Drucksachen zu erschaffen.**
Jedes unserer Produkte wird mit viel Liebe zum Detail und in echter Handarbeit gefertigt.
Da unsere Editionen auf max. 250 Exemplare limitiert sind, kannst Du sicher sein, ein besonderes Stück zu besitzen hergestellt in unserer Werkstatt in Stuttgart.`,
},
{
title: "Unsere Philosophie",
content: `Wir glauben an Nachhaltigkeit und Qualität. Deshalb verwenden wir ausschließlich **100% Recyclingpapier** mit FSC-Zertifizierung und produzieren CO₂-neutral.
Jedes Notizbuch erzählt eine Geschichte von den Händen, die es gebunden haben, bis zu den Ideen, die Du darin festhältst.`,
},
],
images: ["/images/production/01.png", "/images/production/02.png", "/images/production/03.png"],
};
// =============================================================================
// LEGAL PAGE CONTENT (Markdown)
// =============================================================================
export const IMPRESSUM_CONTENT = `# Impressum
## muellerprints.
**Inhaber:** Max Müller
Rotenbergstraße 39
70190 Stuttgart
T +49 (0)711 / 262 49 64
F +49 (0)711 / 262 48 60
paperwork@muellerprints.de
[www.muellerprints.de](https://www.muellerprints.de)
Umsatzsteuer-Identifikationsnummer gemäß § 27 a Umsatzsteuergesetz: DE 147595459
Plattform der EU-Kommission zur Online-Streitbeilegung: [https://ec.europa.eu/consumers/odr](https://ec.europa.eu/consumers/odr)
Wir sind zur Teilnahme an einem Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle weder verpflichtet noch bereit.
© muellerprints. Stuttgart 2025
Mitglied der Initiative "Fairness im Handel".
Nähere Informationen: [https://www.fairness-im-handel.de](https://www.fairness-im-handel.de)`;
export const KONTAKT_CONTENT = `# Kontakt
Für Fragen, Anregungen oder Probleme stehen wir Ihnen gerne zur Verfügung.
Sie erreichen unseren Kundenservice telefonisch unter **+49 711 262 49 64** oder per E-Mail unter [paperwork@muellerprints.de](mailto:paperwork@muellerprints.de).`;
export const AGB_CONTENT = `# Allgemeine Geschäftsbedingungen mit Kundeninformationen
## Inhaltsverzeichnis
1. Geltungsbereich
2. Vertragsschluss
3. Widerrufsrecht
4. Preise und Zahlungsbedingungen
5. Liefer- und Versandbedingungen
6. Eigentumsvorbehalt
7. Mängelhaftung (Gewährleistung)
8. Anwendbares Recht
9. Alternative Streitbeilegung
## 1) Geltungsbereich
1.1 Diese Allgemeinen Geschäftsbedingungen (nachfolgend "AGB") des Max Müller (nachfolgend "Verkäufer"), gelten für alle Verträge über die Lieferung von Waren, die ein Verbraucher oder Unternehmer (nachfolgend „Kunde") mit dem Verkäufer hinsichtlich der vom Verkäufer in seinem Online-Shop dargestellten Waren abschließt. Hiermit wird der Einbeziehung von eigenen Bedingungen des Kunden widersprochen, es sei denn, es ist etwas anderes vereinbart.
1.2 Verbraucher im Sinne dieser AGB ist jede natürliche Person, die ein Rechtsgeschäft zu Zwecken abschließt, die überwiegend weder ihrer gewerblichen noch ihrer selbständigen beruflichen Tätigkeit zugerechnet werden können.
1.3 Unternehmer im Sinne dieser AGB ist eine natürliche oder juristische Person oder eine rechtsfähige Personengesellschaft, die bei Abschluss eines Rechtsgeschäfts in Ausübung ihrer gewerblichen oder selbständigen beruflichen Tätigkeit handelt.
## 2) Vertragsschluss
2.1 Die im Online-Shop des Verkäufers enthaltenen Produktbeschreibungen stellen keine verbindlichen Angebote seitens des Verkäufers dar, sondern dienen zur Abgabe eines verbindlichen Angebots durch den Kunden.
2.2 Der Kunde kann das Angebot über das in den Online-Shop des Verkäufers integrierte Online-Bestellformular abgeben. Dabei gibt der Kunde, nachdem er die ausgewählten Waren in den virtuellen Warenkorb gelegt und den elektronischen Bestellprozess durchlaufen hat, durch Klicken des den Bestellvorgang abschließenden Buttons ein rechtlich verbindliches Vertragsangebot in Bezug auf die im Warenkorb enthaltenen Waren ab.
2.3 Der Verkäufer kann das Angebot des Kunden innerhalb von fünf Tagen annehmen, indem er dem Kunden eine schriftliche Auftragsbestätigung oder eine Auftragsbestätigung in Textform (Fax oder E-Mail) übermittelt, wobei insoweit der Zugang der Auftragsbestätigung beim Kunden maßgeblich ist, oder indem er dem Kunden die bestellte Ware liefert, wobei insoweit der Zugang der Ware beim Kunden maßgeblich ist, oder indem er den Kunden nach Abgabe von dessen Bestellung zur Zahlung auffordert.
2.4 Die Annahme des Angebots (und damit der Vertragsschluss) erfolgt erst mit der Versendung der Ware bzw. der ausdrücklichen Auftragsbestätigung. Bei elektronischer Zahlung erfolgt die Annahme mit Bestätigung der Zahlungstransaktion.
## 3) Widerrufsrecht
3.1 Verbrauchern steht grundsätzlich ein Widerrufsrecht zu.
3.2 Nähere Informationen zum Widerrufsrecht ergeben sich aus der Widerrufsbelehrung des Verkäufers.
## 4) Preise und Zahlungsbedingungen
4.1 Sofern sich aus der Produktbeschreibung des Verkäufers nichts anderes ergibt, handelt es sich bei den angegebenen Preisen um Gesamtpreise, die die gesetzliche Umsatzsteuer enthalten. Gegebenenfalls zusätzlich anfallende Liefer- und Versandkosten werden in der jeweiligen Produktbeschreibung gesondert angegeben.
4.2 Die Zahlungsmöglichkeit/en wird/werden dem Kunden im Online-Shop des Verkäufers mitgeteilt.
4.3 Bei Auswahl der Zahlungsart „PayPal" erfolgt die Zahlungsabwicklung über den Zahlungsdienstleister PayPal (Europe) S.à r.l. et Cie, S.C.A., 22-24 Boulevard Royal, L-2449 Luxembourg, unter Geltung der PayPal-Nutzungsbedingungen.
## 5) Liefer- und Versandbedingungen
5.1 Die Lieferung von Waren erfolgt auf dem Versandweg an die vom Kunden angegebene Lieferanschrift, sofern nichts anderes vereinbart ist.
5.2 Sendet das Transportunternehmen die versandte Ware an den Verkäufer zurück, da eine Zustellung beim Kunden nicht möglich war, trägt der Kunde die Kosten für den erfolglosen Versand.
5.3 Bei Selbstabholung informiert der Verkäufer den Kunden zunächst per E-Mail darüber, dass die von ihm bestellte Ware zur Abholung bereit steht. Nach Erhalt dieser E-Mail kann der Kunde die Ware nach Absprache mit dem Verkäufer am Sitz des Verkäufers abholen.
## 6) Eigentumsvorbehalt
Tritt der Verkäufer in Vorleistung, behält er sich bis zur vollständigen Bezahlung des geschuldeten Kaufpreises das Eigentum an der gelieferten Ware vor.
## 7) Mängelhaftung (Gewährleistung)
7.1 Ist die Kaufsache mangelhaft, gelten die Vorschriften der gesetzlichen Mängelhaftung.
7.2 Handelt der Kunde als Verbraucher, wird er gebeten, angelieferte Waren mit offensichtlichen Transportschäden bei dem Zusteller zu reklamieren und den Verkäufer hiervon in Kenntnis zu setzen.
## 8) Anwendbares Recht
Für sämtliche Rechtsbeziehungen der Parteien gilt das Recht der Bundesrepublik Deutschland unter Ausschluss der Gesetze über den internationalen Kauf beweglicher Waren. Bei Verbrauchern gilt diese Rechtswahl nur insoweit, als nicht der gewährte Schutz durch zwingende Bestimmungen des Rechts des Staates, in dem der Verbraucher seinen gewöhnlichen Aufenthalt hat, entzogen wird.
## 9) Alternative Streitbeilegung
9.1 Die EU-Kommission stellt im Internet unter folgendem Link eine Plattform zur Online-Streitbeilegung bereit: [https://ec.europa.eu/consumers/odr](https://ec.europa.eu/consumers/odr)
Diese Plattform dient als Anlaufstelle zur außergerichtlichen Beilegung von Streitigkeiten aus Online-Kauf- oder Dienstleistungsverträgen, an denen ein Verbraucher beteiligt ist.
9.2 Der Verkäufer ist zur Teilnahme an einem Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle weder verpflichtet noch bereit.`;
export const DATENSCHUTZ_CONTENT = `# Datenschutzerklärung
## 1) Information über die Erhebung personenbezogener Daten und Kontaktdaten des Verantwortlichen
1.1 Wir freuen uns, dass Sie unsere Website besuchen und bedanken uns für Ihr Interesse. Im Folgenden informieren wir Sie über den Umgang mit Ihren personenbezogenen Daten bei der Nutzung unserer Website. Personenbezogene Daten sind hierbei alle Daten, mit denen Sie persönlich identifiziert werden können.
1.2 Verantwortlicher für die Datenverarbeitung auf dieser Website im Sinne der Datenschutz-Grundverordnung (DSGVO) ist Max Müller, Rotenbergstraße 39, 70190 Stuttgart, Deutschland, Tel.: +49 711 262 49 64, E-Mail: paperwork@muellerprints.de. Der für die Verarbeitung von personenbezogenen Daten Verantwortliche ist diejenige natürliche oder juristische Person, die allein oder gemeinsam mit anderen über die Zwecke und Mittel der Verarbeitung von personenbezogenen Daten entscheidet.
## 2) Datenerfassung beim Besuch unserer Website
Bei der bloß informatorischen Nutzung unserer Website, also wenn Sie sich nicht registrieren oder uns anderweitig Informationen übermitteln, erheben wir nur solche Daten, die Ihr Browser an unseren Server übermittelt (sog. „Server-Logfiles"). Wenn Sie unsere Website aufrufen, erheben wir die folgenden Daten, die für uns technisch erforderlich sind, um Ihnen die Website anzuzeigen:
- Unsere besuchte Website
- Datum und Uhrzeit zum Zeitpunkt des Zugriffes
- Menge der gesendeten Daten in Byte
- Quelle/Verweis, von welchem Sie auf die Seite gelangten
- Verwendeter Browser
- Verwendetes Betriebssystem
- Verwendete IP-Adresse (ggf.: in anonymisierter Form)
Die Verarbeitung erfolgt gemäß Art. 6 Abs. 1 lit. f DSGVO auf Basis unseres berechtigten Interesses an der Verbesserung der Stabilität und Funktionalität unserer Website. Eine Weitergabe oder anderweitige Verwendung der Daten findet nicht statt. Wir behalten uns allerdings vor, die Server-Logfiles nachträglich zu überprüfen, sollten konkrete Anhaltspunkte auf eine rechtswidrige Nutzung hinweisen.
## 3) Cookies
Um den Besuch unserer Website attraktiv zu gestalten und die Nutzung bestimmter Funktionen zu ermöglichen, verwenden wir auf verschiedenen Seiten sogenannte Cookies. Hierbei handelt es sich um kleine Textdateien, die auf Ihrem Endgerät abgelegt werden. Einige der von uns verwendeten Cookies werden nach dem Ende der Browser-Sitzung, also nach Schließen Ihres Browsers, wieder gelöscht (sog. Sitzungs-Cookies). Andere Cookies verbleiben auf Ihrem Endgerät und ermöglichen, Ihren Browser beim nächsten Besuch wiederzuerkennen (sog. persistente Cookies).
Sie können Ihren Browser so einstellen, dass Sie über das Setzen von Cookies informiert werden und einzeln über deren Annahme entscheiden oder die Annahme von Cookies für bestimmte Fälle oder generell ausschließen können.
## 4) Kontaktaufnahme
Im Rahmen der Kontaktaufnahme mit uns (z.B. per Kontaktformular oder E-Mail) werden personenbezogene Daten erhoben. Welche Daten im Falle eines Kontaktformulars erhoben werden, ist aus dem jeweiligen Kontaktformular ersichtlich. Diese Daten werden ausschließlich zum Zweck der Beantwortung Ihres Anliegens bzw. für die Kontaktaufnahme und die damit verbundene technische Administration gespeichert und verwendet.
Rechtsgrundlage für die Verarbeitung dieser Daten ist unser berechtigtes Interesse an der Beantwortung Ihres Anliegens gemäß Art. 6 Abs. 1 lit. f DSGVO. Zielt Ihre Kontaktierung auf den Abschluss eines Vertrages ab, so ist zusätzliche Rechtsgrundlage für die Verarbeitung Art. 6 Abs. 1 lit. b DSGVO. Ihre Daten werden nach abschließender Bearbeitung Ihrer Anfrage gelöscht.
## 5) Datenverarbeitung bei Eröffnung eines Kundenkontos und zur Vertragsabwicklung
Gemäß Art. 6 Abs. 1 lit. b DSGVO werden personenbezogene Daten weiterhin erhoben und verarbeitet, wenn Sie uns diese zur Durchführung eines Vertrages oder bei der Eröffnung eines Kundenkontos mitteilen. Welche Daten erhoben werden, ist aus den jeweiligen Eingabeformularen ersichtlich.
Eine Löschung Ihres Kundenkontos ist jederzeit möglich und kann durch eine Nachricht an die o.g. Adresse des Verantwortlichen erfolgen. Wir speichern und verwenden die von Ihnen mitgeteilten Daten zur Vertragsabwicklung. Nach vollständiger Abwicklung des Vertrages oder Löschung Ihres Kundenkontos werden Ihre Daten mit Rücksicht auf steuer- und handelsrechtliche Aufbewahrungsfristen gesperrt und nach Ablauf dieser Fristen gelöscht.
## 6) Nutzung von Kundendaten zur Direktwerbung
Wenn Sie Ihre E-Mail-Adresse beim Kauf von Waren mitteilen, behalten wir uns vor, Ihnen regelmäßig Angebote für ähnliche Waren aus unserem Sortiment per E-Mail zuzusenden. Hierfür müssen wir keine gesonderte Einwilligung von Ihnen einholen.
Sie können der Verwendung Ihrer E-Mail-Adresse jederzeit widersprechen, ohne dass hierfür andere als die Übermittlungskosten nach den Basistarifen entstehen. Eine Abmeldung kann über den in jeder E-Mail enthaltenen Link erfolgen.
## 7) Datenverarbeitung zur Bestellabwicklung
Die von uns erhobenen personenbezogenen Daten werden im Rahmen der Vertragsabwicklung an das mit der Lieferung beauftragte Transportunternehmen weitergegeben, soweit dies zur Lieferung der Ware erforderlich ist.
Ihre Zahlungsdaten geben wir im Rahmen der Zahlungsabwicklung an das beauftragte Kreditinstitut weiter, sofern dies für die Zahlungsabwicklung erforderlich ist. Sofern Zahlungsdienstleister eingesetzt werden, informieren wir Sie hierüber nachstehend explizit.
## 8) PayPal
Bei Zahlung via PayPal, Kreditkarte via PayPal, Lastschrift via PayPal oder falls angeboten "Kauf auf Rechnung" oder "Ratenzahlung" via PayPal geben wir Ihre Zahlungsdaten im Rahmen der Zahlungsabwicklung an die PayPal (Europe) S.à r.l. et Cie, S.C.A., 22-24 Boulevard Royal, L-2449 Luxembourg (nachfolgend "PayPal"), weiter. Die Weitergabe erfolgt gemäß Art. 6 Abs. 1 lit. b DSGVO und nur insoweit, als dies für die Zahlungsabwicklung erforderlich ist.
## 9) Rechte des Betroffenen
9.1 Das geltende Datenschutzrecht gewährt Ihnen gegenüber dem Verantwortlichen hinsichtlich der Verarbeitung Ihrer personenbezogenen Daten umfassende Betroffenenrechte (Auskunfts- und Interventionsrechte), über die wir Sie nachstehend informieren:
- Auskunftsrecht gemäß Art. 15 DSGVO
- Recht auf Berichtigung gemäß Art. 16 DSGVO
- Recht auf Löschung gemäß Art. 17 DSGVO
- Recht auf Einschränkung der Verarbeitung gemäß Art. 18 DSGVO
- Recht auf Unterrichtung gemäß Art. 19 DSGVO
- Recht auf Datenübertragbarkeit gemäß Art. 20 DSGVO
- Recht auf Widerruf erteilter Einwilligungen gemäß Art. 7 Abs. 3 DSGVO
- Recht auf Beschwerde gemäß Art. 77 DSGVO
9.2 WIDERSPRUCHSRECHT
Wenn wir im Rahmen einer Interessenabwägung Ihre personenbezogenen Daten aufgrund unseres überwiegenden berechtigten Interesses verarbeiten, haben Sie das jederzeitige Recht, aus Gründen, die sich aus Ihrer besonderen Situation ergeben, gegen diese Verarbeitung Widerspruch mit Wirkung für die Zukunft einzulegen.
Machen Sie von Ihrem Widerspruchsrecht Gebrauch, beenden wir die Verarbeitung der betroffenen Daten. Eine Weiterverarbeitung bleibt aber vorbehalten, wenn wir zwingende schutzwürdige Gründe für die Verarbeitung nachweisen können, die Ihre Interessen, Grundrechte und Grundfreiheiten überwiegen, oder wenn die Verarbeitung der Geltendmachung, Ausübung oder Verteidigung von Rechtsansprüchen dient.
## 10) Dauer der Speicherung personenbezogener Daten
Die Dauer der Speicherung von personenbezogenen Daten bemisst sich anhand der jeweiligen gesetzlichen Aufbewahrungsfrist (z.B. handels- und steuerrechtliche Aufbewahrungsfristen). Nach Ablauf der Frist werden die entsprechenden Daten routinemäßig gelöscht, sofern sie nicht mehr zur Vertragserfüllung oder Vertragsanbahnung erforderlich sind und/oder unsererseits kein berechtigtes Interesse an der Weiterspeicherung fortbesteht.`;
export const VERSAND_CONTENT = `Unsere Lieferungen erfolgen in der Regel innerhalb von **5 Arbeitstagen** nach Bestelleingang, sofern nicht anders vereinbart.
Die Lieferung erfolgt an die von Ihnen angegebene Lieferadresse. Sollte die Lieferung ausnahmsweise nicht möglich sein, werden wir Sie umgehend darüber informieren und gegebenenfalls einen neuen Liefertermin vereinbaren.
Die Versandkosten werden während des Bestellvorgangs angezeigt und richten sich nach dem Lieferort sowie dem Gesamtgewicht der Bestellung.`;
export const ZAHLUNG_CONTENT = `Wir bieten Ihnen verschiedene Zahlungsmöglichkeiten an, um Ihnen den Einkauf so bequem wie möglich zu gestalten.
Alle Transaktionen werden über verschlüsselte Verbindungen abgewickelt. Ihre sensiblen Daten sind durch modernste Sicherheitsstandards geschützt Sie können bei uns sorgenfrei einkaufen.
Bei Zahlung per Kreditkarte oder PayPal werden Sie während des Bestellvorgangs auf die jeweilige Zahlungsplattform weitergeleitet. Nach erfolgreicher Autorisierung kehren Sie automatisch zu uns zurück und erhalten Ihre Bestellbestätigung.`;
// =============================================================================
// GOOGLE MAPS EMBED
// =============================================================================
export const GOOGLE_MAPS_EMBED_URL = "https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d2628.8892444744!2d9.1967!3d48.7891!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x4799c37b5e0f5a6b%3A0x6e9f1d2b3c4d5e6f!2sRotenbergstra%C3%9Fe%2039%2C%2070190%20Stuttgart!5e0!3m2!1sde!2sde!4v1234567890";

View File

@@ -0,0 +1,369 @@
// Product content keyed by cover type
// This content is displayed on product detail pages as storytelling modules
export type CoverType = "Hardcover" | "Softcover" | "Heft" | "Spiralbindung";
export interface FeatureModule {
eyebrow?: string;
headline: string;
subtitle?: string;
body: string;
image?: string;
imageRight?: boolean;
}
export interface UseCase {
icon: "pen" | "palette" | "clipboard" | "briefcase";
title: string;
description: string;
}
// =============================================================================
// FEATURE MODULES - Storytelling sections per cover type
// =============================================================================
export const FEATURE_MODULES: Record<CoverType, FeatureModule[]> = {
Hardcover: [
{
eyebrow: "Handarbeit",
headline: "Klassische Fadenheftung",
subtitle: "Strapazierfähig bei intensivem Gebrauch",
body: `Jedes Heft wird mit Singer-Stich-Heftung von Hand gebunden.
Das Ergebnis: Ein Notizbuch, das sich vollständig flach öffnen
lässt und auch nach Jahren intensiver Nutzung nicht auseinanderfällt.
Der rote Vorsatzbogen setzt einen dezenten Farbakzent
Deine erste Begegnung beim Öffnen.`,
image: "/images/features/hardcover/binding-closeup.jpg",
imageRight: false,
},
{
eyebrow: "100% Recycling",
headline: "VIVUS 89 Papier",
subtitle: "Außergewöhnliche Schreibqualität",
body: `VIVUS 89 ist nicht irgendein Recyclingpapier.
Die matte, ungestrichene Oberfläche mit feiner Textur verhindert
Durchschlagen selbst bei Füllfederhaltern und Aquarellfarben.
Mit 120g/qm und 1,2-fachem Volumen fühlt sich jede Seite
substanziell an, ohne starr zu wirken.`,
image: "/images/features/hardcover/paper-texture.jpg",
imageRight: true,
},
{
headline: "160 Seiten für Deine Ideen",
subtitle: "Raum für 3-6 Monate intensive Arbeit",
body: `160 Seiten bedeuten genug Platz für ein abgeschlossenes Projekt,
eine komplette Reise, oder ein Quartal Deines Bullet Journals.
Blanko-Seiten geben Dir absolute Freiheit: Skizzieren, Schreiben,
Collagieren ohne Raster, das Deine Kreativität einschränkt.`,
image: "/images/features/hardcover/lifestyle-desk.jpg",
imageRight: false,
},
{
headline: "Kein Heft gleicht dem anderen",
subtitle: "13 einzigartige Mustervarianten",
body: `Jeder Einband wird individuell mit variierenden geometrischen
Mustern bedruckt. Die Kombination aus mattem Schwarz (300g/qm Karton)
und den Mustern macht Dein Heft unverwechselbar.`,
image: "/images/features/hardcover/cover-pattern.jpg",
imageRight: true,
},
{
eyebrow: "Made in Stuttgart",
headline: "Transparente Lieferkette",
subtitle: "Messbare Nachhaltigkeit",
body: `<strong>100% Recyclingpapier</strong> (FSC® C018175)<br>
<strong>CO₂-neutral</strong> produziert<br>
<strong>Blauer Engel + EU Ecolabel</strong> zertifiziert<br>
<strong>Hergestellt in Stuttgart</strong>, Deutschland<br><br>
Keine langen Transportwege, keine Ausbeutung, kein Greenwashing.
Nur ehrliches Handwerk mit messbaren Umweltstandards.`,
image: "/images/production/04.png",
imageRight: false,
},
],
Softcover: [
{
eyebrow: "Leicht & Flexibel",
headline: "Der tägliche Begleiter",
subtitle: "Passt in jede Tasche",
body: `Das Softcover ist Dein unkomplizierter Alltagsbegleiter.
Leicht, flexibel und robust perfekt für unterwegs.
Die weiche Hülle schmiegt sich an und übersteht jeden Rucksack.`,
image: "/images/features/softcover/flexible-cover.jpg",
imageRight: false,
},
{
eyebrow: "100% Recycling",
headline: "VIVUS 89 Papier",
subtitle: "Dieselbe Qualität, leichteres Format",
body: `Auch im Softcover kommt unser bewährtes VIVUS 89 Recyclingpapier
zum Einsatz. 120g/qm für optimale Schreibqualität ohne Durchschlagen.
Perfekt für Füllfederhalter, Fineliner und Aquarellstifte.`,
image: "/images/features/softcover/paper-writing.jpg",
imageRight: true,
},
{
headline: "Praktische Steppstich-Bindung",
subtitle: "Liegt flach, bleibt offen",
body: `Die Steppstich-Bindung ermöglicht ein vollständiges Aufklappen.
Ideal zum Schreiben, Zeichnen und für alle, die beide Seiten
gleichzeitig nutzen möchten.`,
image: "/images/features/softcover/binding-detail.jpg",
imageRight: false,
},
{
eyebrow: "Made in Stuttgart",
headline: "Nachhaltig produziert",
subtitle: "Lokale Fertigung, globale Standards",
body: `<strong>FSC® zertifiziert</strong><br>
<strong>Blauer Engel</strong> Umweltzeichen<br>
<strong>CO₂-neutral</strong> hergestellt<br><br>
Kurze Wege, faire Produktion, messbare Nachhaltigkeit.`,
image: "/images/features/softcover/workshop.png",
imageRight: true,
},
],
Heft: [
{
eyebrow: "Kompakt & Praktisch",
headline: "Das klassische Notizheft",
subtitle: "Für schnelle Notizen und Ideen",
body: `Manchmal braucht man kein dickes Notizbuch sondern ein
handliches Heft für den Moment. Perfekt für Meeting-Notizen,
Einkaufslisten oder spontane Skizzen.`,
image: "/images/features/heft/compact-size.jpg",
imageRight: false,
},
{
eyebrow: "100% Recycling",
headline: "Qualität im Kleinformat",
subtitle: "VIVUS 89 auch im Heft",
body: `Unser Recyclingpapier macht auch im kleinen Format keine
Kompromisse. Die gleiche Schreibqualität, die Du von unseren
größeren Notizbüchern kennst.`,
image: "/images/features/heft/paper-quality.jpg",
imageRight: true,
},
{
headline: "Ruckzuck-Heftung",
subtitle: "Einfach, aber solide",
body: `Die klassische Rückstich-Heftung hält Dein Heft zusammen
und ermöglicht ein flaches Aufklappen. Bewährt seit Generationen,
nachhaltig für die Zukunft.`,
image: "/images/features/heft/binding.jpg",
imageRight: false,
},
],
Spiralbindung: [
{
eyebrow: "360° Flexibilität",
headline: "Wire-O-Bindung",
subtitle: "Komplett umklappbar",
body: `Die Spiralbindung lässt sich vollständig umklappen
ideal für beengte Arbeitsflächen. Schreibe auf einer Seite,
während die andere flach auf dem Tisch liegt.`,
image: "/images/features/spiral/360-flip.jpg",
imageRight: false,
},
{
eyebrow: "100% Recycling",
headline: "VIVUS 89 Papier",
subtitle: "Premium-Qualität, flexibles Format",
body: `Unser bewährtes Recyclingpapier in der praktischen
Spiralbindung. 120g/qm verhindern Durchschlagen
auch bei intensiver Nutzung mit verschiedenen Stiften.`,
image: "/images/features/spiral/paper-texture.jpg",
imageRight: true,
},
{
headline: "Seiten heraustrennbar",
subtitle: "Perforation für sauberes Abtrennen",
body: `Jede Seite lässt sich sauber heraustrennen perfekt,
wenn Du Notizen weitergeben oder Skizzen verschenken möchtest.
Die Mikroperforation sorgt für glatte Kanten.`,
image: "/images/features/spiral/tear-out.jpg",
imageRight: false,
},
{
eyebrow: "Made in Stuttgart",
headline: "Nachhaltig & Praktisch",
subtitle: "Das Beste aus beiden Welten",
body: `<strong>Recycling-Drahtbindung</strong><br>
<strong>FSC® zertifiziertes Papier</strong><br>
<strong>CO₂-neutral</strong> produziert<br><br>
Funktionalität trifft Nachhaltigkeit.`,
image: "/images/features/spiral/workshop.jpg",
imageRight: true,
},
],
};
// =============================================================================
// TECHNICAL SPECS - Collapsible details per cover type
// =============================================================================
export const TECHNICAL_SPECS: Record<CoverType, Record<string, string>> = {
Hardcover: {
Format: "150 × 210 mm (A5)",
Seitenanzahl: "160 Seiten (80 Blatt)",
Papier: "VIVUS 89, 120g/qm, 100% Recycling",
Einband: "300g/qm Recyclingkarton",
Bindung: "Klassische Fadenheftung (Singer-Stich)",
Vorsatzpapier: "Rot durchgefärbt",
Zertifizierung: "FSC® C018175, Blauer Engel, EU Ecolabel",
Herstellung: "Stuttgart, Deutschland",
},
Softcover: {
Format: "150 × 210 mm (A5)",
Seitenanzahl: "96 Seiten (48 Blatt)",
Papier: "VIVUS 89, 120g/qm, 100% Recycling",
Einband: "250g/qm Recyclingkarton, flexibel",
Bindung: "Steppstich-Heftung",
Zertifizierung: "FSC® C018175, Blauer Engel, EU Ecolabel",
Herstellung: "Stuttgart, Deutschland",
},
Heft: {
Format: "148 × 210 mm (A5)",
Seitenanzahl: "20, 40 oder 60 Seiten",
Papier: "VIVUS 89, 120g/qm, 100% Recycling",
Einband: "200g/qm Recyclingkarton",
Bindung: "Rückstich-Heftung",
Zertifizierung: "FSC® C018175, Blauer Engel",
Herstellung: "Stuttgart, Deutschland",
},
Spiralbindung: {
Format: "150 × 210 mm (A5)",
Seitenanzahl: "120 Seiten (60 Blatt)",
Papier: "VIVUS 89, 120g/qm, 100% Recycling",
Einband: "300g/qm Recyclingkarton",
Bindung: "Wire-O-Bindung (Doppeldraht)",
Perforation: "Mikroperforation zum Heraustrennen",
Zertifizierung: "FSC® C018175, Blauer Engel, EU Ecolabel",
Herstellung: "Stuttgart, Deutschland",
},
};
// =============================================================================
// USE CASES - Same for all products
// =============================================================================
export const USE_CASES: UseCase[] = [
{
icon: "pen",
title: "Schreibprojekte",
description: "Romane, Tagebuch, Morning Pages",
},
{
icon: "palette",
title: "Mixed-Media",
description: "Aquarell, Collage, Skizzen",
},
{
icon: "clipboard",
title: "Sketchbook",
description: "Layouts ohne Raster",
},
{
icon: "briefcase",
title: "Arbeit & Studium",
description: "Meetings, Forschung, Konzepte",
},
];
// =============================================================================
// HELPER: Get cover type from product.cover.name
// =============================================================================
export function getCoverType(coverName?: string): CoverType {
if (!coverName) return "Hardcover";
const name = coverName.toLowerCase();
if (name.includes("heft")) return "Heft";
if (name.includes("hardcover")) return "Hardcover";
if (name.includes("softcover")) return "Softcover";
if (name.includes("spiral") || name.includes("wire")) return "Spiralbindung";
return "Hardcover"; // fallback
}
// =============================================================================
// COMPOSABLE
// =============================================================================
import type { Product } from "~/types";
export function useProductContent(
coverName: Ref<string | undefined> | ComputedRef<string | undefined>,
product?: Ref<Product | null> | ComputedRef<Product | null>
) {
const coverType = computed(() => getCoverType(coverName.value));
const featureModules = computed(() => FEATURE_MODULES[coverType.value] ?? []);
// Derive technical specs from product data when available
const technicalSpecs = computed(() => {
const baseSpecs = TECHNICAL_SPECS[coverType.value] ?? {};
const prod = product?.value;
if (!prod) return baseSpecs;
const derivedSpecs: Record<string, string> = {};
// Format from copyText
if (prod.cover?.copyText?.format) {
derivedSpecs["Format"] = prod.cover.copyText.format;
}
// Seitenanzahl from pages
if (prod.pages?.name) {
derivedSpecs["Seitenanzahl"] = prod.pages.name;
} else if (prod.pages?.count) {
const sheets = Math.floor(prod.pages.count / 2);
derivedSpecs["Seitenanzahl"] = `${prod.pages.count} Seiten (${sheets} Blatt)`;
}
// Parse paper string: "VIVUS 89: 100% Recyclingpapier, ... Inhalt: 120g/qm, Einband: 300g/qm. Zertifizierung: ..."
if (prod.cover?.copyText?.paper) {
const paperText = prod.cover.copyText.paper;
// Extract paper name and description (before "Inhalt:")
const inhaltMatch = paperText.match(/^(.+?)(?:\s*Inhalt:|$)/);
if (inhaltMatch) {
derivedSpecs["Papier"] = inhaltMatch[1].trim().replace(/,\s*$/, "");
}
// Extract Inhalt (paper weight)
const inhaltWeightMatch = paperText.match(/Inhalt:\s*([^,]+)/);
if (inhaltWeightMatch) {
derivedSpecs["Papiergewicht"] = inhaltWeightMatch[1].trim();
}
// Extract Einband (cover weight)
const einbandMatch = paperText.match(/Einband:\s*([^.]+)/);
if (einbandMatch) {
derivedSpecs["Einband"] = einbandMatch[1].trim();
}
// Extract Zertifizierung
const zertMatch = paperText.match(/Zertifizierung:\s*(.+)$/);
if (zertMatch) {
derivedSpecs["Zertifizierung"] = zertMatch[1].trim();
}
}
// Bindung from cover binding
if (prod.cover?.binding) {
derivedSpecs["Bindung"] = prod.cover.binding;
}
// Merge: product data overrides base specs
return { ...baseSpecs, ...derivedSpecs };
});
return {
coverType,
featureModules,
technicalSpecs,
useCases: USE_CASES,
};
}

147
composables/useShopApi.ts Normal file
View File

@@ -0,0 +1,147 @@
import type {
Product,
Order,
ProductVariantResponse,
PatternVariantsResponse,
ProductCover,
Legal,
DeliveryMethod,
PaymentMethod,
ApiResponse
} from "~/types";
/**
* Shop API composable.
* All requests go through dedicated Nuxt server API routes.
* The API token is handled server-side and never exposed to clients.
*/
export function useShopApi() {
return {
// Products
async getProducts(coverId?: string, page = 1, pageSize = 24) {
const params = new URLSearchParams();
if (coverId) params.append("cover", coverId);
params.append("page", page.toString());
params.append("pageSize", pageSize.toString());
return await $fetch<ApiResponse<Product>>(`/api/products?${params}`);
},
async getCheapestProducts(coverId?: string, page = 1, pageSize = 24) {
const params = new URLSearchParams();
if (coverId) params.append("cover", coverId);
params.append("page", page.toString());
params.append("pageSize", pageSize.toString());
return await $fetch<ApiResponse<Product>>(`/api/products/promo?${params}`);
},
async getProductById(id: string) {
return await $fetch<Product>(`/api/products/${id}`);
},
async getProductBySlug(slug: string) {
return await $fetch<Product | null>(`/api/product/${slug}`);
},
async getProductVariants(id: string): Promise<ProductVariantResponse> {
return await $fetch<ProductVariantResponse>(`/api/products/${id}/variants`);
},
async getProductVariantsByProductId(id: number): Promise<ProductVariantResponse> {
return await $fetch<ProductVariantResponse>(`/api/products/${id}/variants`);
},
async getPatternVariants(id: string): Promise<PatternVariantsResponse> {
return await $fetch<PatternVariantsResponse>(`/api/products/${id}/variants/pattern`);
},
async getPatternVariantsByProductId(id: number): Promise<PatternVariantsResponse> {
return await $fetch<PatternVariantsResponse>(`/api/products/${id}/variants/pattern`);
},
// Product metadata
async getProductRulings() {
return await $fetch<ApiResponse<unknown>>(`/api/product-rulings`);
},
async getProductPatterns() {
return await $fetch<ApiResponse<unknown>>(`/api/product-patterns`);
},
async getProductPages() {
return await $fetch<ApiResponse<unknown>>(`/api/product-pages`);
},
async getProductCovers() {
return await $fetch<ApiResponse<ProductCover>>(`/api/product-covers`);
},
async getProductCoverById(id: string) {
return await $fetch<ApiResponse<ProductCover>>(`/api/product-covers/${id}`);
},
// Orders
async createOrder(): Promise<Order> {
return await $fetch<Order>("/api/orders", { method: "POST" });
},
async getOrder(uuid: string): Promise<Order> {
return await $fetch<Order>(`/api/orders/${uuid}`);
},
async updateOrder(uuid: string, data: Partial<Order>): Promise<Order> {
return await $fetch<Order>(`/api/orders/${uuid}`, {
method: "PUT",
body: { data }
});
},
async addProductToCart(uuid: string, productId: number, count = 1): Promise<Order> {
return await $fetch<Order>(`/api/orders/${uuid}/add-product/${productId}?count=${count}`, {
method: "PUT"
});
},
async removeProductFromCart(uuid: string, productId: number, count = 1): Promise<Order> {
return await $fetch<Order>(`/api/orders/${uuid}/remove-product/${productId}?count=${count}`, {
method: "PUT"
});
},
async checkoutOrder(uuid: string): Promise<{ id: string }> {
const returnUrl = import.meta.client ? window.location.href : "";
return await $fetch<{ id: string }>(`/api/orders/${uuid}/checkout?returnUrl=${encodeURIComponent(returnUrl)}`, {
method: "POST"
});
},
async capturePayment(uuid: string, paypalOrderId: string): Promise<Order> {
return await $fetch<Order>(`/api/orders/${uuid}/capture/${paypalOrderId}`, {
method: "POST"
});
},
// Payment & Delivery
async getPaymentMethods() {
return await $fetch<ApiResponse<PaymentMethod>>(`/api/payments`);
},
async getDeliveryMethods() {
return await $fetch<ApiResponse<DeliveryMethod>>(`/api/deliveries`);
},
// Content
async getLegal(): Promise<Legal> {
return await $fetch<Legal>(`/api/legal`);
},
async getContent(): Promise<unknown> {
return await $fetch<unknown>(`/api/content`);
},
async getWebsites(): Promise<unknown> {
return await $fetch<unknown>(`/api/websites`);
}
};
}

35
error.vue Normal file
View File

@@ -0,0 +1,35 @@
<template>
<NuxtLayout>
<main class="flex flex-col items-center justify-center py-24 px-4 text-center min-h-[60vh]">
<p class="text-6xl font-bold text-gray-300 mb-4">{{ error?.statusCode || 500 }}</p>
<Heading :level="1" html-tag="h1" classes="!mt-0">
{{ isNotFound ? "Seite nicht gefunden" : "Ein Fehler ist aufgetreten" }}
</Heading>
<p class="text-gray-600 max-w-md mb-8">
{{ isNotFound
? "Die angeforderte Seite existiert nicht oder wurde verschoben."
: "Etwas ist schiefgelaufen. Bitte versuchen Sie es später erneut."
}}
</p>
<Button data-cy="error-home-button" @click="handleBack">Zurück zum Shop</Button>
</main>
</NuxtLayout>
</template>
<script setup lang="ts">
import type { NuxtError } from "#app";
const props = defineProps<{
error: NuxtError;
}>();
const isNotFound = computed(() => props.error?.statusCode === 404);
useHead({
title: isNotFound.value ? "Seite nicht gefunden | MUELLERPRINTS" : "Fehler | MUELLERPRINTS",
});
function handleBack() {
clearError({ redirect: "/" });
}
</script>

39
layouts/default.vue Normal file
View File

@@ -0,0 +1,39 @@
<template>
<div>
<Header :covers="covers" :is-dark-mode="isDarkMode" />
<slot />
<Footer :covers="covers" />
<CookieBanner />
</div>
</template>
<script setup lang="ts">
import type { CoverNavItem } from "~/types";
import { slugify } from "~/utils/slugify";
const isDarkMode = ref(false);
// Fetch covers (SSR-safe)
const { data: coversData } = await useFetch("/api/product-covers", {
transform: (response: { data?: Array<{ id: string | number; attributes?: { name: string; binding?: string; sort?: number }; name?: string; binding?: string; sort?: number }> }) =>
response.data?.map((cover) => {
const name = cover.attributes?.name ?? cover.name ?? "";
return {
label: name,
id: cover.id,
slug: slugify(name),
description: cover.attributes?.binding ?? cover.binding ?? "",
sort: cover.attributes?.sort ?? cover.sort ?? 0
};
}) ?? []
});
const covers = computed<CoverNavItem[]>(() => coversData.value ?? []);
function toggleDarkMode(value: boolean) {
isDarkMode.value = value;
}
// Provide to children
provide("toggleDarkMode", toggleDarkMode);
</script>

112
nuxt.config.ts Normal file
View File

@@ -0,0 +1,112 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
compatibilityDate: "2025-07-15",
devtools: { enabled: true },
// Port configuration
devServer: {
port: 9999
},
// Modules
modules: ["@pinia/nuxt", "@nuxtjs/tailwindcss", "@nuxt/image", "@nuxtjs/sitemap"],
// Sitemap configuration
sitemap: {
// Disable auto-discovery features
autoLastmod: false,
discoverImages: false,
discoverVideos: false,
// Use only our custom source for URLs
sources: ["/api/__sitemap__/urls"],
// Exclude all auto-discovered routes - we handle all URLs in our custom endpoint
excludeAppSources: true
},
// Runtime config (environment variables)
// NUXT_ prefix is auto-mapped by Nuxt
runtimeConfig: {
// Server-only (private) - never exposed to client
shopApiToken: "", // NUXT_SHOP_API_TOKEN
cmsInternalUrl: "http://cms:5555", // NUXT_CMS_INTERNAL_URL - internal docker network URL
siteUrl: "https://muellerprints-paperwork.com", // NUXT_SITE_URL - for sitemap
// Public (client + server)
public: {
baseUrl: "", // NUXT_PUBLIC_BASE_URL
paypalClientId: "", // NUXT_PUBLIC_PAYPAL_CLIENT_ID
paymentEnvironment: "sandbox", // NUXT_PUBLIC_PAYMENT_ENVIRONMENT
umamiScriptUrl: "", // NUXT_PUBLIC_UMAMI_SCRIPT_URL
umamiWebsiteId: "" // NUXT_PUBLIC_UMAMI_WEBSITE_ID
}
},
// SSR + Hybrid Rendering
routeRules: {
// Cart & Checkout: Client-only (no SSR)
"/cart": { ssr: false },
"/checkout/**": { ssr: false }
// All other pages use default SSR
},
// App config
app: {
head: {
htmlAttrs: { lang: "de" },
charset: "utf-8",
viewport: "width=device-width, initial-scale=1",
title: "MUELLERPRINTS - Handgefertigte Notizbücher aus Stuttgart",
meta: [{ name: "description", content: "Handgefertigte Notizbücher aus Stuttgart mit 100% Recyclingpapier" }],
link: [
{ rel: "icon", type: "image/png", sizes: "32x32", href: "/favicon-32x32.png" },
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
{ rel: "preconnect", href: "https://fonts.gstatic.com", crossorigin: "" },
{ rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=Bebas+Neue&display=swap" }
],
script: []
}
},
// TypeScript
typescript: {
strict: true
},
// Tailwind
tailwindcss: {
configPath: "tailwind.config.ts",
cssPath: "~/assets/css/main.css"
},
// Build
build: {
transpile: ["vue-markdown-render", "vue-strapi-blocks-renderer"]
},
// Vite configuration for optimization
vite: {
build: {
// Split chunks for better caching
rollupOptions: {
output: {
manualChunks: {
// Separate vendor chunks
"vue-vendor": ["vue", "vue-router"],
"carousel": ["vue3-carousel"],
"paypal": ["@paypal/paypal-js"]
}
}
}
},
// Optimize deps
optimizeDeps: {
include: ["vue", "vue-router"]
}
},
// Experimental features for better performance
experimental: {
// Enable payload extraction for smaller HTML
payloadExtraction: true
}
});

12199
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

34
package.json Normal file
View File

@@ -0,0 +1,34 @@
{
"name": "shop",
"type": "module",
"private": true,
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare"
},
"dependencies": {
"@headlessui/vue": "^1.7.23",
"@nuxt/image": "^2.0.0",
"@nuxtjs/sitemap": "^7.4.7",
"@nuxtjs/tailwindcss": "^6.14.0",
"@paypal/paypal-js": "^9.0.1",
"@pinia/nuxt": "^0.11.3",
"debug": "^4.4.3",
"marked": "^17.0.1",
"nuxt": "^4.2.1",
"vue": "^3.5.25",
"vue-confetti-explosion": "^1.0.2",
"vue-markdown-render": "^2.3.0",
"vue-router": "^4.6.3",
"vue-strapi-blocks-renderer": "^0.2.2",
"vue3-carousel": "^0.17.0"
},
"devDependencies": {
"@tailwindcss/forms": "^0.5.10",
"@types/debug": "^4.1.12",
"typescript": "^5.9.3"
}
}

42
pages/about.vue Normal file
View File

@@ -0,0 +1,42 @@
<template>
<main class="pb-20">
<!-- Hero -->
<PageHero :title="ABOUT_CONTENT.hero.title" :subtitle="ABOUT_CONTENT.hero.subtitle" :image="ABOUT_CONTENT.hero.image" />
<!-- Main Content -->
<PageSection v-for="(section, index) in ABOUT_CONTENT.sections" :key="index" :title="section.title" :content="section.content" :variant="index % 2 === 0 ? 'white' : 'gray'" />
<!-- Production Images -->
<section class="py-12 lg:py-16 bg-gray-50">
<div class="xl:container mx-auto px-6">
<h2 class="text-2xl lg:text-3xl font-bold mb-8">Einblicke in unsere Werkstatt</h2>
<div class="grid md:grid-cols-3 gap-6">
<img v-for="(image, index) in ABOUT_CONTENT.images" :key="index" :src="image" alt="Produktion" class="rounded-xl shadow-lg w-full aspect-[4/3] object-cover" loading="lazy" />
</div>
</div>
</section>
<!-- Contact CTA -->
<section class="py-12 lg:py-16">
<div class="xl:container mx-auto px-6 text-center">
<h2 class="text-2xl lg:text-3xl font-bold mb-4">Fragen?</h2>
<p class="text-gray-600 mb-8 max-w-xl mx-auto">Wir freuen uns auf Ihre Nachricht. Besuchen Sie uns in Stuttgart oder schreiben Sie uns.</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<NuxtLink to="/kontakt" class="px-8 py-3 bg-gray-900 text-white font-semibold rounded-full hover:bg-gray-800 transition-colors"> Kontakt aufnehmen </NuxtLink>
<NuxtLink to="/anfahrt" class="px-8 py-3 border-2 border-gray-900 text-gray-900 font-semibold rounded-full hover:bg-gray-100 transition-colors"> Anfahrt </NuxtLink>
</div>
</div>
</section>
</main>
</template>
<script setup lang="ts">
import { ABOUT_CONTENT } from "~/composables/usePageContent";
useSeoMeta({
title: "Über uns | MUELLERPRINTS",
description: "Handarbeit aus Stuttgart seit über 30 Jahren. Wir kombinieren traditionelles Buchbinderhandwerk mit moderner Technik.",
ogTitle: "Über uns | MUELLERPRINTS",
ogDescription: "Handarbeit aus Stuttgart seit über 30 Jahren.",
});
</script>

15
pages/agb.vue Normal file
View File

@@ -0,0 +1,15 @@
<template>
<main class="pb-20">
<PageHero title="Allgemeine Geschäftsbedingungen" subtitle="AGB mit Kundeninformationen" />
<PageSection :content="AGB_CONTENT" />
</main>
</template>
<script setup lang="ts">
import { AGB_CONTENT } from "~/composables/usePageContent";
useSeoMeta({
title: "AGB | MUELLERPRINTS",
description: "Allgemeine Geschäftsbedingungen von MUELLERPRINTS. Informationen zu Vertragsschluss, Widerrufsrecht, Lieferung und mehr.",
});
</script>

67
pages/anfahrt.vue Normal file
View File

@@ -0,0 +1,67 @@
<template>
<main class="pb-20">
<!-- Hero -->
<PageHero title="Anfahrt" subtitle="So finden Sie uns in Stuttgart" />
<!-- Map and Address -->
<section class="py-12 lg:py-16">
<div class="xl:container mx-auto px-6">
<div class="grid lg:grid-cols-2 gap-12">
<!-- Map -->
<div>
<LocationMap
:address="{
name: CONTACT_INFO.name,
street: CONTACT_INFO.street,
postalCode: CONTACT_INFO.postalCode,
city: CONTACT_INFO.city,
}"
:show-address="false"
/>
</div>
<!-- Address and Directions -->
<div class="space-y-8">
<div class="bg-white rounded-xl shadow-lg p-8">
<h2 class="text-xl font-bold mb-6">Adresse</h2>
<ContactInfo :contact="CONTACT_INFO" />
</div>
<div class="bg-gray-50 rounded-xl p-8">
<h3 class="text-lg font-bold mb-4">Anfahrt mit dem Auto</h3>
<p class="text-gray-600">Wir befinden uns in Stuttgart-Ost im Stadtteil Gaisburg. Parkmöglichkeiten finden Sie in den umliegenden Straßen.</p>
</div>
<div class="bg-gray-50 rounded-xl p-8">
<h3 class="text-lg font-bold mb-4">Mit öffentlichen Verkehrsmitteln</h3>
<p class="text-gray-600">
<strong>Stadtbahn:</strong> Haltestelle Ostendplatz (U4, U9)<br />
Von dort ca. 5 Minuten Fußweg.
</p>
</div>
</div>
</div>
</div>
</section>
<!-- Opening Hours Teaser -->
<section class="py-12 lg:py-16 bg-gray-900 text-white">
<div class="xl:container mx-auto px-6 text-center">
<h2 class="text-2xl font-bold mb-4">Besuchen Sie uns</h2>
<p class="text-gray-300 mb-8">Wir freuen uns auf Ihren Besuch in unserer Werkstatt.</p>
<NuxtLink to="/oeffnungszeiten" class="inline-block px-8 py-3 bg-white text-gray-900 font-semibold rounded-full hover:bg-gray-100 transition-colors"> Öffnungszeiten ansehen </NuxtLink>
</div>
</section>
</main>
</template>
<script setup lang="ts">
import { CONTACT_INFO } from "~/composables/usePageContent";
useSeoMeta({
title: "Anfahrt | MUELLERPRINTS",
description: `Besuchen Sie uns in Stuttgart: ${CONTACT_INFO.street}, ${CONTACT_INFO.postalCode} ${CONTACT_INFO.city}. Jetzt Route planen.`,
ogTitle: "Anfahrt | MUELLERPRINTS",
ogDescription: `So finden Sie uns: ${CONTACT_INFO.street}, ${CONTACT_INFO.postalCode} ${CONTACT_INFO.city}`,
});
</script>

180
pages/cart.vue Normal file
View File

@@ -0,0 +1,180 @@
<template>
<main class="pt-4 pb-20 lg:container lg:max-w-screen-lg lg:mx-auto px-6">
<div class="relative w-full">
<!-- Loading state -->
<div v-if="isLoading" class="flex items-center justify-center h-[40vh]">
<div class="text-center">
<LoadingSpinner />
<p class="text-xl mt-4">Warenkorb wird geladen...</p>
</div>
</div>
<!-- Cart has products -->
<div v-else-if="cartProducts.length > 0" class="flex flex-col gap-8 w-full" data-e2e="cart">
<Heading :level="2" html-tag="h1" classes="text-center">
Im Warenkorb gesamt: <span class="text-nowrap">{{ numberFormatter(cart.total.value) }} </span>
</Heading>
<div class="grid place-content-center">
<Button href="/checkout" @click="handleCheckoutClickTop">Jetzt bezahlen</Button>
</div>
<hr />
<div class="flex flex-col gap-8">
<ul class="divide-y divide-gray-300" data-e2e="cart-products">
<li v-for="(position, index) in cartProducts" :key="index" class="py-6 gap-6 flex items-center justify-between">
<a v-if="position.product.images?.images" :href="`/details/${position.product.slug}`">
<img :src="position.product.images.images[0].formats.thumbnail.url" :alt="position.product.name" class="w-24 object-cover" />
</a>
<div v-else class="bg-black opacity-5 w-12 h-12"></div>
<div class="flex flex-col gap-3 lg:gap-6 lg:flex-row lg:items-center justify-between flex-grow">
<a :href="`/details/${position.product.slug}`" class="text-xl font-bold flex-grow">{{ position.product.name }}</a>
<div class="flex items-center justify-between">
<select
@change="changeCountCart(position, parseInt(($event.target as HTMLSelectElement).value))"
class="block appearance-none w-16 text-center bg-white border border-gray-300 hover:border-gray-500 px-4 py-2 rounded shadow leading-tight focus:outline-none focus:border-indigo-500 focus:shadow-outline"
>
<option v-for="count in 10" :key="count" :selected="position.count === count" :value="count">
{{ count }}
</option>
</select>
<div class="flex flex-col gap-2 flex-end w-24">
<span class="text-xl font-bold text-right text-nowrap">{{ numberFormatter(position.product.totalProductPrice * position.count) }} </span>
<button @click="removeFromCart(position.product.id, position.count)" class="text-gray-500 hover:text-gray-900 hover:underline text-right">
Entfernen
</button>
</div>
</div>
</div>
</li>
</ul>
<hr />
<div>
<div class="flex flex-col lg:flex-row justify-between mb-2">
<span class="text-2xl font-bold">Deine Gesamtsumme</span>
<div class="flex flex-col lg:flex-end">
<span class="text-2xl font-bold lg:text-right text-nowrap">{{ totalFormatted }} </span>
<span class="text-gray-600 text-sm lg:text-right">
Enthält MwSt. in Höhe von {{ VATFormatted }} {{ deliveryFormattedOrEmpty ? `inkl. ${deliveryFormattedOrEmpty}` : `zzgl.` }} Versandkosten
</span>
</div>
</div>
</div>
<div class="grid lg:place-content-end">
<Button href="/checkout" classes="w-full" @click="handleCheckoutClickBottom">Jetzt bezahlen</Button>
</div>
</div>
</div>
<!-- Cart is empty -->
<div v-else class="w-full">
<Heading :level="2" html-tag="h1" classes="text-center">Dein Warenkorb ist leer.</Heading>
<div class="grid place-content-center">
<NuxtLink
to="/notebooks"
class="inline-flex items-center justify-center px-8 py-3 rounded-full font-medium transition-all duration-200 bg-gray-900 text-white hover:bg-gray-700"
@click="handleContinueShopping"
>
Entdecke unsere Produkte
</NuxtLink>
</div>
</div>
</div>
</main>
</template>
<script setup lang="ts">
import { numberFormatter } from "~/utils/numberFormatter";
import { trackEvent } from "~/utils/trackEvent";
import type { CartProduct } from "~/types";
const shopApi = useShopApi();
const cart = useCart();
// Ensure cart is initialized on mount
onMounted(async () => {
await cart.ensureReady();
// Track cart view after cart is loaded
trackEvent("cart-viewed", {
itemCount: cart.productsCount.value,
cartValue: cart.total.value
});
});
// Local computed for cleaner template access
const isLoading = computed(() => !cart.isInitialized.value);
const cartProducts = computed(() => cart.products.value || ([] as CartProduct[]));
const totalFormatted = computed(() => numberFormatter(cart.total.value));
const VATFormatted = computed(() => numberFormatter(cart.VAT.value));
const deliveryFormattedOrEmpty = computed(() => (cart.delivery.value ? numberFormatter(cart.delivery.value, "€ ") : ""));
async function removeFromCart(productId: number, count = 1) {
try {
const product = cartProducts.value.find((p) => p.product.id === productId);
const cartAfterUpdate = await shopApi.removeProductFromCart(cart.uuid.value, productId, count);
cart.overwrite(cartAfterUpdate);
trackEvent("cart-product-removed", {
productId,
productSlug: product?.product.slug,
quantity: count,
cartValue: cart.total.value
});
} catch (error) {
console.error(`Could not remove product ${productId} from cart:`, error);
}
}
async function addToCart(productId: number, count = 1) {
try {
const cartAfterUpdate = await shopApi.addProductToCart(cart.uuid.value, productId, count);
cart.overwrite(cartAfterUpdate);
} catch (error) {
console.error(`Could not add product ${productId} to cart:`, error);
}
}
async function changeCountCart(position: CartProduct, count: number) {
const oldCount = position.count;
if (count > position.count) {
await addToCart(position.product.id, count - position.count);
} else if (count < position.count) {
await removeFromCart(position.product.id, position.count - count);
}
trackEvent("cart-quantity-changed", {
productId: position.product.id,
oldQuantity: oldCount,
newQuantity: count
});
}
function handleCheckoutClickTop() {
trackEvent("cart-cta-top-clicked", {
itemCount: cart.productsCount.value,
cartValue: cart.total.value
});
}
function handleCheckoutClickBottom() {
trackEvent("cart-cta-bottom-clicked", {
itemCount: cart.productsCount.value,
cartValue: cart.total.value
});
}
function handleContinueShopping() {
trackEvent("cart-empty-continue-shopping");
}
// SEO
useSeoMeta({
title: "Warenkorb | MUELLERPRINTS",
description: "Ihr Warenkorb bei MUELLERPRINTS"
});
</script>

150
pages/checkout/1.vue Normal file
View File

@@ -0,0 +1,150 @@
<template>
<main class="pt-4 lg:container mx-auto px-4 pb-20">
<!-- Error state -->
<div v-if="displayState === 'error'" class="py-12">
<CheckoutError @retry="cart.retry()" />
</div>
<!-- Loading skeleton -->
<div v-else-if="displayState === 'loading'">
<Stepper :step="1" />
<div class="mt-8 mb-12 max-w-screen-md mx-auto">
<div class="h-8 w-3/4 bg-gray-200 rounded mb-8 animate-pulse"></div>
<CheckoutSkeleton variant="form" :fields="1" />
<div class="flex gap-4 mt-6 animate-pulse">
<div class="h-5 w-5 bg-gray-200 rounded"></div>
<div class="h-4 w-64 bg-gray-200 rounded"></div>
</div>
<hr class="my-12" />
<div class="h-12 bg-gray-200 rounded-full w-full animate-pulse"></div>
</div>
</div>
<!-- Main content -->
<div v-else-if="displayState === 'main-content'">
<Stepper :step="1" />
<div class="mt-8 mb-12 flex flex-col gap-8 max-w-screen-md mx-auto">
<Heading :level="2" html-tag="h1">Wie lauten deine Kontaktinformationen?</Heading>
<div v-if="uuid">
<form @submit.prevent="submit" class="flex flex-col gap-12">
<Input label="E-Mail-Adresse" v-model="emailAddress" :required="true" autocomplete="email" />
<label class="flex gap-4 text-sm cursor-pointer">
<input v-model="acceptedTermsAndConditions" required type="checkbox" class="w-4 cursor-pointer" />
<span>
Mit der Anmeldung bestätige ich, die
<a href="/agb" target="_blank" class="underline" @click="trackEvent('checkout-terms-clicked')">AGB</a>
und
<a href="/datenschutz" target="_blank" class="underline" @click="trackEvent('checkout-privacy-clicked')">Datenschutzerklärung</a>
gelesen und verstanden zu haben und stimme diesen zu.
</span>
</label>
<hr />
<Button type="submit" classes="w-full" :is-pending="formSubmitIsPending">Weiter zur Lieferadresse</Button>
</form>
</div>
</div>
</div>
<div v-else-if="displayState === 'products-warning'">
<div class="bg-yellow-100 border-l-4 border-yellow-500 p-4 mb-8">
<p class="text-yellow-700 font-semibold mb-4">Dein Warenkorb ist leer, bitte füge Produkte hinzu, um fortzufahren.</p>
<NuxtLink to="/" class="text-yellow-700 hover:underline">Zurück zu unseren Produkten</NuxtLink>
</div>
</div>
</main>
</template>
<script setup lang="ts">
import { trackEvent } from "~/utils/trackEvent";
const COOKIE_CONSENT_KEY = "shop:cookie-consent";
const cart = useCart();
const emailAddress = ref("");
const acceptedTermsAndConditions = ref(false);
const formSubmitIsPending = ref(false);
const uuid = computed(() => cart.uuid.value);
const displayState = computed(() => {
// Error state - show retry option
if (cart.hasError.value) {
return "error";
}
// Still initializing - show skeleton
if (!cart.isInitialized.value) {
return "loading";
}
// Initialized but no products - show warning
if (cart.productsCount.value === 0) {
return "products-warning";
}
// Ready to show form
return "main-content";
});
onMounted(async () => {
// Ensure cart is ready before using its values
await cart.ensureReady();
emailAddress.value = cart.emailAddress.value || "";
// Track checkout step 1 view
trackEvent("checkout-step-1-viewed", {
cartValue: cart.total.value,
itemCount: cart.productsCount.value
});
});
// Track terms acceptance
watch(acceptedTermsAndConditions, (accepted) => {
if (accepted) {
trackEvent("checkout-step-1-terms-accepted");
}
});
async function submit() {
if (!uuid.value) {
console.error("Cart not found, cannot submit form");
return;
}
if (!acceptedTermsAndConditions.value) {
console.error("Terms and conditions not accepted, cannot submit form");
return;
}
try {
formSubmitIsPending.value = true;
// Use cart.update() to sync local state after API call
await cart.update({
email: emailAddress.value,
acceptedTermsAndConditionsAt: new Date().toISOString()
});
if (import.meta.client) {
localStorage.setItem(COOKIE_CONSENT_KEY, new Date().toISOString());
}
trackEvent("checkout-step-1-completed", {
cartValue: cart.total.value
});
navigateTo("/checkout/2");
} catch (error) {
console.error("Error submitting email address", error);
} finally {
formSubmitIsPending.value = false;
}
}
// SEO
useSeoMeta({
title: "Checkout - E-Mail | MUELLERPRINTS"
});
</script>

274
pages/checkout/2.vue Normal file
View File

@@ -0,0 +1,274 @@
<template>
<main class="pt-4 lg:container mx-auto px-4 pb-20">
<!-- Error state -->
<div v-if="displayState === 'error'" class="py-12">
<CheckoutError @retry="cart.retry()" />
</div>
<!-- Loading skeleton -->
<div v-else-if="displayState === 'loading'">
<Stepper :step="2" />
<div class="mt-8 mb-12 max-w-screen-md mx-auto">
<div class="h-8 w-3/4 bg-gray-200 rounded mb-8 animate-pulse"></div>
<CheckoutSkeleton variant="form" :fields="4" />
<hr class="my-12" />
<CheckoutSkeleton variant="delivery" />
<hr class="my-12" />
<div class="h-12 bg-gray-200 rounded-full w-full animate-pulse"></div>
</div>
</div>
<!-- Terms warning -->
<div v-else-if="displayState === 'terms-warning'">
<div class="bg-yellow-100 border-l-4 border-yellow-500 p-4 mb-8">
<p class="text-yellow-700 font-semibold mb-4">
Bitte, akzeptiere die
<a href="/agb" target="_blank" class="underline" @click="trackEvent('checkout-terms-clicked')">AGB</a>
und
<a href="/datenschutz" target="_blank" class="underline" @click="trackEvent('checkout-privacy-clicked')">Datenschutzerklärung</a>, um fortzufahren.
</p>
<NuxtLink to="/checkout/1" class="text-yellow-700 hover:underline">Zurück zur Kasse</NuxtLink>
</div>
</div>
<!-- Main content -->
<div v-else-if="displayState === 'main-content'">
<Stepper :step="2" />
<div class="mt-8 mb-12 flex flex-col gap-8 max-w-screen-md mx-auto">
<Heading html-tag="h1" :level="2">Gib deinen Namen und Adresse ein:</Heading>
<form @submit.prevent="submit" v-if="uuid">
<div class="flex flex-col gap-4 max-w-screen-md">
<Input label="Vorname" v-model="address.name" :required="true" autocomplete="given-name" />
<Input label="Nachname" v-model="address.surname" :required="true" autocomplete="family-name" />
<Input label="Straße und Hausnummer:" v-model="address.street" :required="true" autocomplete="street-address" />
<div class="flex gap-4">
<Input label="PLZ" v-model="address.postalCode" :required="true" label-class="w-1/2" autocomplete="postal-code" />
<Input label="Ort" v-model="address.city" :required="true" label-class="w-1/2" autocomplete="address-level2" />
</div>
<label class="flex items-center gap-2 my-4 cursor-pointer">
<input type="checkbox" v-model="showOptionalDeliveryAddress" />
<span>Abweichende Lieferadresse</span>
</label>
</div>
<div class="flex flex-col gap-4 max-w-screen-md" v-if="showOptionalDeliveryAddress">
<Input label="Vorname" v-model="deliveryAddress.name" :required="showOptionalDeliveryAddress" />
<Input label="Nachname" v-model="deliveryAddress.surname" :required="showOptionalDeliveryAddress" />
<Input label="Straße und Hausnummer:" v-model="deliveryAddress.street" :required="showOptionalDeliveryAddress" />
<div class="flex gap-4">
<Input label="PLZ" v-model="deliveryAddress.postalCode" label-class="w-1/2" :required="showOptionalDeliveryAddress" />
<Input label="Ort" v-model="deliveryAddress.city" label-class="w-1/2" :required="showOptionalDeliveryAddress" />
</div>
</div>
<hr class="my-12" />
<fieldset class="space-y-4 mb-8" aria-required="true">
<p class="text-md font-semibold">Wähle deine Versandart:</p>
<div v-for="method in deliveryMethods" :key="method.id" class="relative">
<input
type="radio"
name="delivery-method"
:id="'delivery-' + method.id"
:value="method.id"
v-model="selectedDeliveryMethod"
class="peer right-6 top-6 absolute"
required
/>
<label
:for="'delivery-' + method.id"
class="inline-flex items-center justify-between w-full p-5 bg-white border-2 rounded-lg cursor-pointer group border-neutral-200/70 text-neutral-600 peer-checked:border-blue-400 peer-checked:text-neutral-900 peer-checked:bg-blue-200/50 hover:text-neutral-900 hover:border-neutral-300"
>
<div class="flex items-center space-x-5">
<div class="flex flex-col justify-start">
<div class="w-full text-lg font-semibold">{{ method.name }}</div>
<div class="w-full text-sm opacity-60">{{ method.description }}</div>
<div class="w-full text-sm mt-2 font-medium">
{{ method.price === 0 ? "Kostenlos" : numberFormatter(method.price, "€") }}
</div>
</div>
</div>
</label>
</div>
</fieldset>
<hr class="my-12" />
<div class="mt-12">
<Button classes="w-full" type="submit" :is-pending="formSubmitIsPending">Weiter zur Zahlung</Button>
</div>
</form>
</div>
</div>
</main>
</template>
<script setup lang="ts">
import type { Address, StructuredAddress } from "~/types";
import { trackEvent } from "~/utils/trackEvent";
import { numberFormatter } from "~/utils/numberFormatter";
const cart = useCart();
const shopApi = useShopApi();
const isLoading = ref(true);
const displayState = computed(() => {
// Error state - show retry option
if (cart.hasError.value) {
return "error";
}
// Still loading
if (isLoading.value) {
return "loading";
}
// Terms not accepted - show warning
if (!acceptedTermsAndConditionsAt.value) {
return "terms-warning";
}
// Ready to show form
return "main-content";
});
const showOptionalDeliveryAddress = ref(false);
const formSubmitIsPending = ref(false);
const deliveryMethods = ref<any[]>([]);
const selectedDeliveryMethod = ref<number | null>(null);
const address = ref<Address>({
name: "",
surname: "",
street: "",
postalCode: "",
city: ""
});
const deliveryAddress = ref<Address>({
name: "",
surname: "",
street: "",
postalCode: "",
city: ""
});
const uuid = computed(() => cart.uuid.value);
const acceptedTermsAndConditionsAt = computed(() => cart.acceptedTermsAndConditionsAt.value);
onMounted(async () => {
try {
// Ensure cart is initialized first
await cart.ensureReady();
const { data } = await shopApi.getDeliveryMethods();
deliveryMethods.value = data.map(({ id, attributes }: any) => ({ id, ...attributes }));
selectedDeliveryMethod.value = cart.deliveryMethod.value;
if (cart.invoiceAddressStructured.value) {
address.value = {
name: cart.invoiceAddressStructured.value.givenName,
surname: cart.invoiceAddressStructured.value.familyName,
street: cart.invoiceAddressStructured.value.streetAddress,
city: cart.invoiceAddressStructured.value.addressLevel2,
postalCode: cart.invoiceAddressStructured.value.postalCode
};
}
if (cart.deliveryAddressStructured.value) {
deliveryAddress.value = {
name: cart.deliveryAddressStructured.value.givenName,
surname: cart.deliveryAddressStructured.value.familyName,
street: cart.deliveryAddressStructured.value.streetAddress,
city: cart.deliveryAddressStructured.value.addressLevel2,
postalCode: cart.deliveryAddressStructured.value.postalCode
};
}
// Track checkout step 2 view
trackEvent("checkout-step-2-viewed", {
cartValue: cart.total.value
});
} catch (e) {
console.error("Error fetching delivery methods", e);
} finally {
isLoading.value = false;
}
});
// Track delivery method selection
watch(selectedDeliveryMethod, (methodId) => {
if (methodId) {
const method = deliveryMethods.value.find((m) => m.id === methodId);
trackEvent("checkout-step-2-delivery-selected", {
methodId,
methodName: method?.name,
price: method?.price ?? 0
});
}
});
// Track different delivery address toggle
watch(showOptionalDeliveryAddress, (toggled) => {
if (toggled) {
trackEvent("checkout-step-2-different-delivery-toggled");
}
});
function mapToStructuredAddress(addr: Address): StructuredAddress {
return {
givenName: addr.name,
familyName: addr.surname,
streetAddress: addr.street,
postalCode: addr.postalCode,
addressLevel2: addr.city,
country: "DE"
};
}
function addressToString(addr: Address) {
return `${addr.name} ${addr.surname}\n${addr.street}\n${addr.postalCode} ${addr.city}`;
}
async function submit() {
if (!uuid.value) {
console.error("Cart not found, cannot submit address form");
return;
}
const invoiceAddressString = addressToString(address.value);
const deliveryAddressString = showOptionalDeliveryAddress.value ? addressToString(deliveryAddress.value) : invoiceAddressString;
const invoiceAddressStructured = mapToStructuredAddress(address.value);
const deliveryAddressStructured = showOptionalDeliveryAddress.value ? mapToStructuredAddress(deliveryAddress.value) : invoiceAddressStructured;
try {
formSubmitIsPending.value = true;
// Use cart.update() to sync local state after API call
await cart.update({
invoiceAddress: invoiceAddressString,
deliveryAddress: deliveryAddressString,
invoiceAddressStructured,
deliveryAddressStructured,
delivery: selectedDeliveryMethod.value
});
const selectedMethod = deliveryMethods.value.find((m) => m.id === selectedDeliveryMethod.value);
trackEvent("checkout-step-2-completed", {
cartValue: cart.total.value,
deliveryMethod: selectedMethod?.name
});
navigateTo("/checkout/3");
} catch (error) {
console.error("Error submitting address form:", error);
} finally {
formSubmitIsPending.value = false;
}
}
// SEO
useSeoMeta({
title: "Checkout - Adresse | MUELLERPRINTS"
});
</script>

374
pages/checkout/3.vue Normal file
View File

@@ -0,0 +1,374 @@
<template>
<main class="pt-4 lg:container mx-auto px-4 pb-20">
<!-- Error state -->
<div v-if="displayState === 'error'" class="py-12">
<CheckoutError
title="Bestellung nicht gefunden"
message="Deine Bestellung konnte nicht geladen werden. Bitte versuche es erneut."
@retry="fetchOrder"
/>
</div>
<!-- Loading skeleton -->
<div v-else-if="displayState === 'loading'">
<Stepper :step="3" />
<div class="mt-8 mb-12 flex flex-col-reverse lg:flex-row gap-8 mx-auto">
<div class="lg:w-1/2">
<div class="h-48 bg-gray-200 rounded-lg animate-pulse"></div>
</div>
<div class="lg:w-1/2">
<CheckoutSkeleton variant="summary" />
</div>
</div>
</div>
<!-- Main content -->
<div v-else-if="displayState === 'main-content'">
<Stepper :step="3" />
<div class="mt-8 mb-12 flex flex-col-reverse lg:flex-row gap-8 mx-auto relative">
<div class="lg:w-1/2">
<div
v-if="hasAuthorisedPayment"
id="alert-additional-content-3"
class="p-4 mb-4 text-green-800 border border-green-300 rounded-lg bg-green-50"
role="alert"
>
<div class="flex items-center">
<svg class="flex-shrink-0 w-4 h-4 me-2" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 20">
<path
d="M10 .5a9.5 9.5 0 1 0 9.5 9.5A9.51 9.51 0 0 0 10 .5ZM9.5 4a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3ZM12 15H8a1 1 0 0 1 0-2h1v-3H8a1 1 0 0 1 0-2h2a1 1 0 0 1 1 1v4h1a1 1 0 0 1 0 2Z"
/>
</svg>
<span class="sr-only">Info</span>
<h3 class="text-lg font-semibold">Das hat geklappt!</h3>
</div>
<div class="mt-2 mb-4">
<p>Deine Bezahlung ist erfolgreich angekommen. Herzlichen Glückwunsch!</p>
</div>
<div class="flex justify-center items-center my-6">
<LoadingSpinner v-if="isRedirecting" />
</div>
<div class="flex items-center gap-2 justify-between">
<a
:href="`/checkout/result/${orderData.uuid}`"
class="text-nowrap text-white bg-green-800 hover:bg-green-900 focus:ring-4 focus:outline-none focus:ring-green-300 font-medium rounded-lg text-md px-3 py-1.5 me-2 text-center inline-flex items-center"
>
Zur Bestellübersicht
</a>
<p class="text-sm">In wenigen Momenten wirst du weitergeleitet, dort erhälst du alle Informationen zu deiner Bestellung.</p>
</div>
</div>
<div v-else>
<div v-if="!hasPaymentError" id="paypal-button-container" class="payment sticky top-4" ref="paymentContainer"></div>
<div v-else class="p-4 lg:p-8 text-center mx-auto rounded-md bg-rose-100" data-e2e="payment-error">
<span class="text-rose-800">Es ist ein Fehler aufgetreten. Bitte versuche es erneut.</span>
</div>
<!-- Trust signals -->
<div class="mt-6 pt-6 border-t border-gray-200">
<div class="flex items-center gap-2 text-gray-600 text-sm mb-3">
<svg class="w-4 h-4 text-green-600" fill="none" viewBox="0 0 24 24" stroke-width="2" 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>
<span>Sichere SSL-Verschlüsselung</span>
</div>
<p class="text-xs text-gray-500">
Zahlungsarten: PayPal, Kreditkarte, Lastschrift, Rechnung
</p>
<p class="text-xs text-gray-500 mt-2">
Fragen? <a href="/kontakt" class="underline hover:text-gray-700">Kontakt</a> oder <a href="tel:+4971125350740" class="underline hover:text-gray-700">0711 253 507 40</a>
</p>
</div>
</div>
</div>
<div class="lg:w-1/2 flex flex-col-reverse lg:flex-col mt-10 lg:mt-0">
<div v-if="orderData && orderData.cart" class="flex flex-col gap-8">
<Heading :level="1">Deine Bestellung</Heading>
<ul class="divide-y divide-gray-300" data-e2e="cart-products">
<li v-for="(position, index) in orderData.cart" :key="index" class="py-6 gap-6 flex items-center justify-between">
<img
v-if="position.product?.images?.images"
:src="position.product?.images?.images[0]?.formats?.thumbnail?.url"
:alt="position.product.name"
class="w-16 lg:w-24 object-cover"
/>
<div v-else class="bg-black opacity-5 w-6 h-6"></div>
<span class="lg:text-xl flex-grow">{{ position.product.name }}</span>
<div class="block text-center bg-white border border-gray-300 px-4 py-2 rounded shadow leading-tight">
{{ position.count }}
</div>
<div class="flex flex-col gap-2 flex-end w-24">
<span class="lg:text-xl text-right text-nowrap">{{ numberFormatter(position.product.totalProductPrice * position.count) }} </span>
</div>
</li>
</ul>
<hr />
<div>
<div class="flex justify-between mb-2">
<span class="text-2xl">Zwischensumme</span>
<span class="text-2xl">{{ numberFormatter(orderData.subtotal) }} </span>
</div>
<div class="flex justify-between mb-2">
<span class="text-2xl">Versand</span>
<span class="text-2xl">{{ orderData.delivery?.price ? numberFormatter(orderData.delivery.price, "€") : "KOSTENFREI" }}</span>
</div>
<div class="flex justify-between mb-2">
<span class="text-2xl font-bold">Gesamtsumme</span>
<div class="flex flex-col flex-end">
<span class="text-2xl font-bold text-right">{{ numberFormatter(orderData.total) }} </span>
<span class="text-gray-600 text-sm text-right">inkl. MwSt. {{ numberFormatter(orderData.VAT) }} </span>
</div>
</div>
</div>
</div>
<div class="flex flex-col gap-8">
<div class="bg-gray-100 p-4 lg:p-8 text-gray-700 rounded-lg relative">
<div class="text-lg">
<Heading :level="3">Kontaktinformation</Heading>
<p>{{ orderData.email }}</p>
</div>
<a v-if="!hasAuthorisedPayment" href="/checkout/1" class="absolute bottom-8 right-12 hover:underline underline-offset-2" @click="trackEvent('checkout-change-email-clicked')">Ändern</a>
</div>
<div class="bg-gray-100 p-4 lg:p-8 text-gray-700 rounded-lg relative">
<Heading :level="3">Rechnungsadresse:</Heading>
<p class="text-lg whitespace-pre-line" v-text="orderData.invoiceAddress" />
<a v-if="!hasAuthorisedPayment" href="/checkout/2" class="absolute bottom-8 right-12 hover:underline underline-offset-2" @click="trackEvent('checkout-change-invoice-address-clicked')">Ändern</a>
</div>
<div v-if="orderData.deliveryAddress" class="bg-gray-100 p-4 lg:p-8 text-gray-700 rounded-lg relative">
<Heading :level="3">Lieferadresse:</Heading>
<p class="text-lg whitespace-pre-line" v-text="orderData.deliveryAddress" />
<a v-if="!hasAuthorisedPayment" href="/checkout/2" class="absolute bottom-8 right-12 hover:underline underline-offset-2" @click="trackEvent('checkout-change-delivery-address-clicked')">Ändern</a>
</div>
<div class="bg-gray-100 p-4 lg:p-8 text-gray-700 rounded-lg">
<div>
<Heading :level="4">Hinweise zum Datenschutz</Heading>
<p class="mt-1 text-sm">
Die personenbezogenen Daten werden für die Abwicklung der Bestellung automatisiert verarbeitet. Der Schutz Ihrer persönlichen Daten ist uns
wichtig. Daher verwenden wir bei der Übertragung moderne Verschlüsselungstechnologien. Weiteres entnehmen Sie bitte unseren
<a href="/datenschutz" target="_blank" class="underline" @click="trackEvent('checkout-privacy-clicked')">Datenschutzhinweisen</a>.
</p>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Terms warning -->
<div v-else-if="displayState === 'terms-warning'">
<div class="bg-yellow-100 border-l-4 border-yellow-500 p-4 mb-8">
<p class="text-yellow-700 font-semibold mb-4">
Bitte, akzeptiere die
<a href="/agb" target="_blank" class="underline" @click="trackEvent('checkout-terms-clicked')">AGB</a>
und
<a href="/datenschutz" target="_blank" class="underline" @click="trackEvent('checkout-privacy-clicked')">Datenschutzerklärung</a>, um fortzufahren.
</p>
<NuxtLink to="/checkout/1" class="text-yellow-700 hover:underline">Zurück zur Kasse</NuxtLink>
</div>
</div>
<!-- Address warning -->
<div v-else-if="displayState === 'address-warning'">
<div class="bg-yellow-100 border-l-4 border-yellow-500 p-4 mb-8">
<p class="text-yellow-700 font-semibold mb-4">Bitte, gib deine Lieferadresse an, um fortzufahren.</p>
<NuxtLink to="/checkout/2" class="text-yellow-700 hover:underline">Zurück zur Kasse</NuxtLink>
</div>
</div>
</main>
</template>
<script setup lang="ts">
import { numberFormatter } from "~/utils/numberFormatter";
import { trackEvent } from "~/utils/trackEvent";
import type { Order } from "~/types";
const config = useRuntimeConfig();
const shopApi = useShopApi();
const CART_UUID_KEY = "shop:cart";
const hasPaymentError = ref(false);
const hasOrderError = ref(false);
const hasAuthorisedPayment = ref(false);
const orderData = ref<Order>({} as Order);
const isLoading = ref(true);
const isRedirecting = ref(false);
const paymentContainer = ref<HTMLElement | null>(null);
const displayState = computed(() => {
// Error state
if (hasOrderError.value) {
return "error";
}
// Loading state
if (isLoading.value || !orderData.value) {
return "loading";
}
// Validation warnings
if (!orderData.value.acceptedTermsAndConditionsAt) {
return "terms-warning";
}
if (!orderData.value.invoiceAddress) {
return "address-warning";
}
return "main-content";
});
onMounted(async () => {
await fetchOrder();
if (displayState.value === "main-content") {
try {
await initializePayPalButtons();
} catch (error) {
console.error("Error initializing PayPal:", error);
hasPaymentError.value = true;
}
}
});
async function fetchOrder() {
try {
isLoading.value = true;
hasOrderError.value = false;
const uuidFromLocalStorage = import.meta.client ? localStorage.getItem(CART_UUID_KEY) : null;
if (!uuidFromLocalStorage) {
console.error("No UUID in local storage");
hasOrderError.value = true;
isLoading.value = false;
return;
}
orderData.value = await shopApi.getOrder(uuidFromLocalStorage);
if (orderData.value.paymentAuthorised) {
hasAuthorisedPayment.value = true;
}
// Track checkout step 3 view
trackEvent("checkout-step-3-viewed", {
orderTotal: orderData.value.total,
itemCount: orderData.value.cart?.length ?? 0
});
} catch (error) {
console.error("Error fetching order:", error);
hasOrderError.value = true;
} finally {
isLoading.value = false;
}
}
async function initializePayPalButtons() {
if (!orderData.value.total) {
console.error("Order total not available or zero");
hasPaymentError.value = true;
return;
}
const { loadScript } = await import("@paypal/paypal-js");
try {
const paypal = await loadScript({ clientId: config.public.paypalClientId as string, currency: "EUR" });
if (!paypal || !paypal.Buttons) {
console.error("PayPal script not loaded");
hasPaymentError.value = true;
return;
}
await paypal
.Buttons({
createOrder: async () => {
try {
trackEvent("checkout-paypal-initiated", {
orderTotal: orderData.value.total
});
const response = await shopApi.checkoutOrder(orderData.value.uuid);
return response.id;
} catch (error) {
console.error("Error creating PayPal order:", error);
hasPaymentError.value = true;
trackEvent("checkout-payment-error", {
stage: "create-order",
orderTotal: orderData.value.total
});
throw error;
}
},
onApprove: async (data) => {
try {
// Capture payment server-side (CMS captures via PayPal server SDK and updates order)
const capturedOrder = await shopApi.capturePayment(orderData.value.uuid, data.orderID!);
if (capturedOrder.paymentAuthorised) {
orderData.value = capturedOrder;
await handleSuccessfulPayment();
} else {
console.error("Payment capture did not result in authorisation");
hasPaymentError.value = true;
trackEvent("checkout-payment-error", {
stage: "capture-failed",
orderTotal: orderData.value.total
});
}
} catch (error) {
console.error("Error capturing payment:", error);
hasPaymentError.value = true;
trackEvent("checkout-payment-error", {
stage: "capture-exception",
orderTotal: orderData.value.total
});
}
},
onError: (err) => {
console.error("PayPal button error:", err);
hasPaymentError.value = true;
trackEvent("checkout-payment-error", {
stage: "paypal-button",
orderTotal: orderData.value.total
});
}
})
.render("#paypal-button-container");
} catch (error) {
console.error("Error loading PayPal script:", error);
hasPaymentError.value = true;
}
}
async function handleSuccessfulPayment() {
if (!orderData.value.uuid) {
console.error("Order UUID not available");
return;
}
hasAuthorisedPayment.value = true;
isRedirecting.value = true;
// Track successful payment
trackEvent("checkout-payment-completed", {
orderTotal: orderData.value.total,
itemCount: orderData.value.cart?.length ?? 0
});
setTimeout(() => {
navigateTo(`/checkout/result/${orderData.value.uuid}`);
}, 3000);
window.scrollTo(0, 0);
}
// SEO
useSeoMeta({
title: "Checkout - Zahlung | MUELLERPRINTS"
});
</script>

8
pages/checkout/index.vue Normal file
View File

@@ -0,0 +1,8 @@
<script setup lang="ts">
// Redirect /checkout to /checkout/1
definePageMeta({
redirect: "/checkout/1"
});
navigateTo("/checkout/1", { replace: true });
</script>

View File

@@ -0,0 +1,236 @@
<template>
<main class="pt-4 lg:container mx-auto px-4">
<div v-if="displayState === 'loading'">
<div class="flex items-center justify-center h-[60vh]">
<div class="text-center">
<LoadingSpinner />
<p class="text-2xl mt-8 font-semibold">Bestellung wird geladen...</p>
<p class="text-lg">Bitte warte einen Moment, während wir deine Bestellung vorbereiten.</p>
</div>
</div>
</div>
<div v-else-if="displayState === 'main-content'">
<Stepper :step="4" />
<div class="flex gap-12 relative max-w-screen-lg mx-auto my-8">
<div v-if="order && order.id" class="flex flex-col gap-8 w-full">
<ClientOnly>
<ConfettiExplosion :colors="['#2563eb', '#ec4899', '#16a34a']" />
</ClientOnly>
<div>
<Heading :level="1">Vielen Dank für deine Bestellung bei MUELLERPRINTS!</Heading>
<Heading :level="2">Deine Bestellnummer: {{ order.id }}</Heading>
</div>
<p class="text-lg lg:w-2/3">
In Kürze erhälst du von uns eine E-Mail mit allen Einzelheiten zu deiner Bestellung. Du kannst sie auch hier herunterladen, sobald sie erstellt
wurde.
</p>
<div>
<Button id="download-invoice" :is-pending="!hasReachedMaxFailedRequests && (!isReadyToDownload || isDownloadPending)" @click="downloadInvoice">
<span v-if="hasReachedMaxFailedRequests">Bald verfügbar</span>
<span v-else>Rechnung herunterladen</span>
</Button>
<div v-if="hasReachedMaxFailedRequests" class="text-sm mt-3">
Es konnte noch keine Rechnung ermittelt werden. Bitte prüfe deine E-Mails oder kontaktiere uns: order@muellerprints.de
</div>
</div>
<hr />
<div class="flex flex-col gap-8">
<ul class="divide-y divide-gray-300" data-e2e="order-products">
<li v-for="(position, index) in orderProducts" :key="index" class="py-6 gap-6">
<div v-if="position.product" class="flex items-center justify-between">
<img
v-if="position.product?.images?.images"
:src="position.product?.images?.images[0]?.formats?.thumbnail?.url"
:alt="position.product?.name"
class="w-16 lg:w-24 object-cover"
/>
<div v-else class="bg-black opacity-5 w-12 h-12"></div>
<a :href="`/details/${position.product?.slug}`" class="p-2 text-xl font-bold flex-grow">{{ position.product?.name }}</a>
<span
data-e2e="cart-products-item-count"
class="block appearance-none w-16 text-center bg-white border border-gray-300 hover:border-gray-500 px-4 py-2 rounded shadow leading-tight focus:outline-none focus:border-indigo-500 focus:shadow-outline"
>
{{ position?.count }}
</span>
</div>
</li>
</ul>
</div>
</div>
</div>
</div>
<div v-else-if="displayState === 'terms-warning'">
<div class="bg-yellow-100 border-l-4 border-yellow-500 p-4 mb-8">
<p class="text-yellow-700 font-semibold">
Bitte, akzeptiere die
<a href="/agb" target="_blank" class="underline">AGB</a>
und
<a href="/datenschutz" target="_blank" class="underline">Datenschutzerklärung</a>, um fortzufahren.
</p>
<NuxtLink to="/checkout/1" class="text-yellow-700 hover:underline">Zurück zur Kasse</NuxtLink>
</div>
</div>
<div v-else-if="displayState === 'address-warning'">
<div class="bg-yellow-100 border-l-4 border-yellow-500 p-4 mb-8">
<p class="text-yellow-700 font-semibold">Bitte, gib deine Lieferadresse an, um fortzufahren.</p>
<NuxtLink to="/checkout/2" class="text-yellow-700 hover:underline">Zurück zur Kasse</NuxtLink>
</div>
</div>
<div v-else-if="displayState === 'payment-warning'">
<div class="bg-yellow-100 border-l-4 border-yellow-500 p-4 mb-8">
<p class="text-yellow-700 font-semibold">Bitte, gib eine Zahlungsart an, um fortzufahren.</p>
<NuxtLink to="/checkout/3" class="text-yellow-700 hover:underline">Zurück zur Kasse</NuxtLink>
</div>
</div>
<div v-else>
<div class="flex items-center justify-center h-[60vh]">
<div class="text-center">
<div class="w-20 h-20"></div>
<p class="text-2xl mt-8 font-semibold">Bestellung nicht gefunden...</p>
<p class="text-lg">Leider konnte deine Bestellung nicht unter dieser Adresse gefunden werden.</p>
<p class="text-lg mt-6">
Falls das Problem bestehen bleibt, <a href="/kontakt" class="underline">kontaktiere uns</a> bitte. Schicke deine Bestell-ID bitte an
paperwork@muellerprints.de
</p>
<pre class="mt-3 p-2 rounded-sm bg-gray-100">Bestell-ID: {{ uuid }}</pre>
</div>
</div>
</div>
</main>
</template>
<script setup lang="ts">
import type { Order } from "~/types";
import { trackEvent } from "~/utils/trackEvent";
const route = useRoute();
const uuid = computed(() => route.params.uuid as string);
const shopApi = useShopApi();
const order = ref<Order | null>(null);
const isLoading = ref(true);
const isDownloadPending = ref(false);
const failedDownloadRequests = ref(0);
const hasReachedMaxFailedRequests = ref(false);
const displayState = ref("loading");
const maxFailedRequests = 20;
const orderProducts = computed(() => order.value?.cart ?? []);
const isReadyToDownload = computed(() => !!order.value?.invoice);
onMounted(async () => {
let hasTrackedConversion = false;
const fetchAndSetOrder = async () => {
try {
order.value = await shopApi.getOrder(uuid.value);
updateDisplayState();
if (!order.value?.acceptedTermsAndConditionsAt || !order.value?.invoiceAddress || !order.value?.paymentAuthorised) {
console.error("Unauthorised view");
isLoading.value = false;
return;
}
// Track order confirmation view (only once)
if (!hasTrackedConversion) {
hasTrackedConversion = true;
trackEvent("order-confirmation-viewed", {
orderId: order.value.id,
orderTotal: order.value.total,
itemCount: order.value.cart?.length ?? 0
});
}
if (order.value?.invoice?.url) {
console.log("Invoice fetched successfully");
isLoading.value = false;
return;
}
failedDownloadRequests.value++;
if (failedDownloadRequests.value < maxFailedRequests) {
console.log("Retrying to fetch invoice", failedDownloadRequests.value);
setTimeout(fetchAndSetOrder, 3000);
} else {
hasReachedMaxFailedRequests.value = true;
isLoading.value = false;
}
} catch (e) {
console.error("Error fetching order:", e);
isLoading.value = false;
updateDisplayState();
}
};
await fetchAndSetOrder();
});
function updateDisplayState() {
if (!order.value) {
displayState.value = "loading";
return;
}
if (!order.value.id) {
displayState.value = "not-found-error";
return;
}
if (!order.value.acceptedTermsAndConditionsAt) {
displayState.value = "terms-warning";
return;
}
if (!order.value.invoiceAddress) {
displayState.value = "address-warning";
return;
}
if (!order.value.paymentAuthorised) {
displayState.value = "payment-warning";
return;
}
displayState.value = "main-content";
}
function downloadInvoice() {
if (!isReadyToDownload.value) {
console.error("Invoice not ready to download");
return;
}
try {
isDownloadPending.value = true;
if (order.value.invoice) {
trackEvent("order-invoice-downloaded", {
orderId: order.value.id
});
window.open(order.value.invoice.url, "_blank");
} else {
console.error("Invoice URL not available");
}
} catch (e) {
console.error("Error downloading invoice:", e);
} finally {
isDownloadPending.value = false;
}
}
// SEO
useSeoMeta({
title: "Bestellbestätigung | MUELLERPRINTS"
});
</script>

15
pages/datenschutz.vue Normal file
View File

@@ -0,0 +1,15 @@
<template>
<main class="pb-20">
<PageHero title="Datenschutzerklärung" subtitle="Informationen zum Datenschutz" />
<PageSection :content="DATENSCHUTZ_CONTENT" />
</main>
</template>
<script setup lang="ts">
import { DATENSCHUTZ_CONTENT } from "~/composables/usePageContent";
useSeoMeta({
title: "Datenschutz | MUELLERPRINTS",
description: "Datenschutzerklärung von MUELLERPRINTS. Informationen zur Erhebung und Verarbeitung personenbezogener Daten.",
});
</script>

596
pages/details/[slug].vue Normal file
View File

@@ -0,0 +1,596 @@
<template>
<div v-if="displayState === 'loading'" class="pt-0">
<div class="flex items-center justify-center h-[60vh] mt-24">
<div class="text-center">
<LoadingSpinner />
<p class="text-2xl mt-8 font-semibold">Produktbeschreibung wird geladen...</p>
</div>
</div>
</div>
<div v-else-if="displayState === 'not-found-error'" class="pt-0">
<div class="flex items-center justify-center h-[60vh] mt-24">
<div class="text-center">
<div class="w-12 h-12"></div>
<p class="text-2xl mt-8 font-semibold">Produkt nicht gefunden</p>
<p class="text-lg mt-6">
Vielleicht ist das Produkt aktuell nicht lieferbar. Falls du Auskunft erhalten möchtest,
<a href="/kontakt" class="underline">kontaktiere uns</a> und schicke diese Produkt-Nr. mit:
</p>
<pre class="mt-3 p-2 rounded-sm bg-gray-100">Produkt-Nr.: {{ slug }}</pre>
</div>
</div>
</div>
<Background
v-else-if="displayState === 'main-content'"
:coverId="product?.cover?.id"
:patternId="product?.pattern?.id"
:shade="200"
class="pt-0"
itemscope
:gradient="true"
itemtype="https://schema.org/Product"
>
<!-- Schema.org metadata -->
<meta itemprop="name" :content="product?.name" />
<meta itemprop="description" :content="getProductDescription()" />
<meta itemprop="image" :content="productImage?.url" />
<meta itemprop="sku" :content="String(product?.id)" />
<div itemprop="brand" itemscope itemtype="https://schema.org/Brand">
<meta itemprop="name" content="MUELLERPRINTS. Paperwork" />
<meta itemprop="image" content="https://muellerprints-paperwork.com/paperwork-logo.png" />
</div>
<!-- Aggregate Rating -->
<div v-if="aggregateRating" itemprop="aggregateRating" itemscope itemtype="https://schema.org/AggregateRating">
<meta itemprop="ratingValue" :content="aggregateRating.ratingValue" />
<meta itemprop="ratingCount" :content="aggregateRating.ratingCount" />
<meta itemprop="reviewCount" :content="aggregateRating.reviewCount" />
<meta itemprop="bestRating" content="5" />
<meta itemprop="worstRating" content="1" />
</div>
<!-- Reviews -->
<div v-for="(testimonial, index) in testimonials" :key="index" itemprop="review" itemscope itemtype="https://schema.org/Review">
<div itemprop="reviewRating" itemscope itemtype="https://schema.org/Rating">
<meta itemprop="ratingValue" :content="String(testimonial.rating)" />
<meta itemprop="worstRating" content="1" />
<meta itemprop="bestRating" content="5" />
</div>
<div itemprop="author" itemscope itemtype="https://schema.org/Person">
<meta itemprop="name" :content="testimonial.name ?? 'Unknown'" />
</div>
<meta itemprop="reviewBody" :content="testimonial.comment" />
</div>
<main class="pt-10 px-6 xl:container lg:mx-auto">
<div>
<p class="text-gray-800 text-lg">{{ product?.cover?.copyText?.format }}</p>
<Heading :level="1" html-tag="h1" classes="xl:w-2/3">
<span data-e2e="title">{{ product?.name }}</span>
</Heading>
<Heading :level="2" html-tag="h2" classes="xl:w-2/3">
<span data-e2e="subtitle" v-html="product?.cover?.copyText?.details"></span>
</Heading>
<p class="text-gray-800 text-lg lg:w-4/5" data-e2e="subtitle">
{{ product?.cover?.copyText?.paper }}
</p>
<div class="flex flex-col-reverse lg:flex-row-reverse gap-4 xl:gap-12">
<div class="lg:w-1/2 xl:w-2/5 xl:pt-8">
<div class="rounded-lg bg-white bg-opacity-100 shadow-md mb-16 relative">
<!-- Pages Variant Selection -->
<section
v-if="productVariantsPages?.length"
class="relative overflow-hidden flex flex-col xl:flex-row-reverse gap-4 xl:gap-12 py-6 px-6 xl:px-8 xl:pt-20"
>
<h2 class="hidden lg:block pointer-events-none absolute left-8 top-4 text-right xl:text-left pt-3 tracking-tight opacity-70 text-xl font-semibold">
Seiten
</h2>
<div class="flex gap-4 z-10">
<SelectionBox
v-for="pages in productVariantsPages"
:key="pages.id"
:label="pages.name"
:path="pages.productSlug ? `/details/${pages.productSlug}` : ''"
:is-active="pages.active"
:aria-disabled="isAddingToCart || isChangingVariant"
@click="handleVariantClick('pages', pages)"
/>
</div>
</section>
<hr v-if="productVariantsPages?.length" class="border-t-[1px] border-black opacity-40" />
<!-- Ruling Variant Selection -->
<section
v-if="productVariantsRuling?.length"
class="relative overflow-hidden flex flex-col xl:flex-row-reverse gap-4 xl:gap-12 py-6 px-6 xl:px-8 xl:pt-20"
>
<h2 class="hidden lg:block pointer-events-none absolute left-8 top-4 text-right xl:text-left pt-3 tracking-tight opacity-70 text-xl font-semibold">
Layout
</h2>
<div class="flex gap-4 z-10">
<SelectionBox
v-for="ruling in productVariantsRuling"
:key="ruling.id"
:label="ruling.name"
:path="ruling.productSlug ? `/details/${ruling.productSlug}` : ''"
:is-active="ruling.active"
:aria-disabled="isAddingToCart || isChangingVariant"
@click="handleVariantClick('ruling', ruling)"
>
<img v-if="ruling.iconUrl" :alt="ruling.name" :src="ruling.iconUrl" />
</SelectionBox>
</div>
</section>
<hr v-if="productVariantsRuling?.length" class="border-t-[1px] border-black opacity-40" />
<!-- Cover Variant Selection -->
<section
v-if="productVariantsCover?.length"
class="flex flex-col relative xl:flex-row-reverse gap-4 xl:gap-12 py-6 px-6 xl:px-8 xl:pt-20 overflow-hidden"
>
<h2 class="hidden lg:block pointer-events-none absolute left-8 top-4 text-right xl:text-left pt-3 tracking-tight opacity-70 text-xl font-semibold">
Einband
</h2>
<div class="flex gap-4 z-10">
<SelectionBox
v-for="cover in productVariantsCover"
:key="cover.id"
:label="cover.name"
:path="cover.productSlug ? `/details/${cover.productSlug}` : ''"
:is-active="cover.active"
:aria-disabled="isAddingToCart || isChangingVariant"
@click="handleVariantClick('cover', cover)"
>
<img v-if="cover.iconUrl" :alt="cover.name" :src="cover.iconUrl" />
</SelectionBox>
</div>
</section>
<hr v-if="productVariantsCover?.length" class="border-t-[1px] border-black opacity-40" />
<!-- Price and Add to Cart -->
<section v-if="product?.totalProductPrice" class="flex flex-col lg:flex-row gap-6 py-6 px-6 lg:px-8 items-center">
<div class="w-full lg:w-1/3 flex flex-row-reverse lg:flex-row" itemprop="offers" itemscope itemtype="http://schema.org/Offer">
<meta itemprop="priceCurrency" content="EUR" />
<meta itemprop="price" :content="formatPriceForSchema(product.totalProductPrice)" />
<meta itemprop="priceValidUntil" :content="getPriceValidUntilDate()" />
<meta itemprop="availability" content="https://schema.org/InStock" />
<meta itemprop="url" :content="getProductUrl()" />
<meta itemprop="itemCondition" content="https://schema.org/NewCondition" />
<span class="oldstyle-nums text-3xl tracking-tight font-bold text-nowrap">
{{ numberFormatter(product.totalProductPrice) }}
</span>
</div>
<div class="w-full lg:w-2/3">
<Button @click="addToCart" :is-pending="isAddingToCart" classes="w-full">In den Warenkorb</Button>
</div>
</section>
<section v-else class="py-6 px-6 lg:px-8">
<div itemprop="offers" itemscope itemtype="http://schema.org/Offer">
<meta itemprop="availability" content="https://schema.org/OutOfStock" />
<meta itemprop="url" :content="getProductUrl()" />
<meta itemprop="itemCondition" content="https://schema.org/NewCondition" />
</div>
<Button :classes="`w-full ${ctaBgColor}`">Produktanfrage</Button>
<p class="text-xs p-4 px-6 text-gray-600">
Aktuell können wir diesen Artikel nicht liefern. Schicke uns bitte eine Anfrage, dann erfährst du als Erstes sobald wir wieder lieferfähig sind. Danke für deine Geduld!
</p>
</section>
</div>
<div class="flex flex-col my-4 items-center gap-4">
<img class="h-10" src="~/assets/visa-mastercard-paypal.svg" alt="VISA Mastercard PayPal Logos" />
<span class="text-sm text-center w-4/5 opacity-60">Unterstützte Zahlungsanbieter: VISA, Mastercard und PayPal mit SSL Verschlüsselung</span>
</div>
</div>
<div class="flex flex-col items-center lg:w-1/2 xl:w-3/5 xl:pt-2 mx-auto">
<a v-if="productImage" :href="productImage.url" class="flex items-center justify-center mx-8" target="_blank" rel="noopener noreferrer">
<img :src="productImage.formats?.medium?.url" :alt="`Overhead product shot of MUELLERPRINTS. Paperwork ${product?.name}`" class="object-contain" />
</a>
<div v-else class="bg-black opacity-5 shadow-sm w-full object-cover min-h-48"></div>
</div>
</div>
</div>
</main>
</Background>
<!-- Trust Bar -->
<TrustBar v-if="displayState === 'main-content'" variant="light" />
<!-- Feature Modules -->
<template v-if="displayState === 'main-content' && featureModules.length">
<FeatureModule
v-for="(module, i) in featureModules"
:key="i"
:id="`feature-${i}`"
:eyebrow="module.eyebrow"
:headline="module.headline"
:subtitle="module.subtitle"
:body="module.body"
:image="module.image"
:image-right="module.imageRight"
:class="i % 2 === 0 ? 'bg-white' : 'bg-gray-50'"
/>
</template>
<!-- Technical Specs -->
<TechnicalSpecs v-if="displayState === 'main-content'" :specs="technicalSpecs" />
<!-- Use Case Grid -->
<UseCaseGrid v-if="displayState === 'main-content'" :use-cases="useCases" />
<!-- Pattern Variants Section -->
<section
class="py-16 xl:container mx-auto px-6"
v-if="displayState === 'main-content' && productVariantsPatterns?.length"
data-e2e="pattern-variants"
>
<div class="text-center mb-10">
<h3 class="text-2xl font-bold mb-3">{{ productVariantsPatterns.length }} weitere Muster</h3>
<p class="text-gray-600 max-w-2xl mx-auto">
Jedes Muster ist ein Unikat. Finde das Design, das zu dir passt.
</p>
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-6 lg:gap-8">
<div v-for="pattern in productVariantsPatterns" :key="pattern.id">
<ProductCard :product="pattern.product" variant="neutral" />
</div>
</div>
</section>
<!-- Testimonials Section (hidden for now)
<section v-if="displayState === 'main-content' && testimonials.length > 0" class="py-16 px-6 xl:container mx-auto">
<div class="text-center mb-10">
<h3 class="text-2xl font-bold mb-3">Kundenstimmen</h3>
<p class="text-gray-600 max-w-2xl mx-auto">Was unsere Kunden über ihre Notizbücher sagen.</p>
</div>
<LazyTestimonial
:testimonials="testimonials"
:show-title="false"
:show-rating="true"
:show-avatar="false"
:show-quote-icon="true"
primary-color="#374151"
star-color="#374151"
/>
</section>
-->
</template>
<script setup lang="ts">
import { numberFormatter } from "~/utils/numberFormatter";
import { randomTailwindColor } from "~/utils/randomTailwindColor";
import { trackEvent } from "~/utils/trackEvent";
import { scrollProgress } from "~/utils/scrollProgress";
import { useProductContent } from "~/composables/useProductContent";
import type { Product, ProductVariantResponse, PatternVariantsResponse, Testimonial } from "~/types";
const route = useRoute();
const slug = computed(() => route.params.slug as string);
const shopApi = useShopApi();
const { addProduct: cartAddProduct } = useCart();
const isAddingToCart = ref(false);
const isChangingVariant = ref(false);
const hasScrolled50 = ref(false);
const hasScrolled100 = ref(false);
// Fetch all product data in one useAsyncData call for reliable SSR
const { data: pageData, status, refresh } = await useAsyncData(
() => `product-page-${slug.value}`,
async () => {
// First fetch the product
const product = await $fetch<Product | null>(`/api/product/${slug.value}`);
if (!product?.id) {
return { product: null, productVariants: { pages: [], ruling: [], cover: [] }, patternVariants: { patterns: [] } };
}
// Then fetch variants in parallel
const [productVariants, patternVariants] = await Promise.all([
$fetch<ProductVariantResponse>(`/api/products/${product.id}/variants`).catch(() => ({ pages: [], ruling: [], cover: [] })),
$fetch<PatternVariantsResponse>(`/api/products/${product.id}/variants/pattern`).catch(() => ({ patterns: [] }))
]);
return { product, productVariants, patternVariants };
},
{
default: () => ({
product: null as Product | null,
productVariants: { pages: [], ruling: [], cover: [] } as ProductVariantResponse,
patternVariants: { patterns: [] } as PatternVariantsResponse
})
}
);
// Refetch when slug changes (client-side navigation)
watch(slug, async () => {
await refresh();
isChangingVariant.value = false;
});
const product = computed(() => pageData.value?.product ?? null);
const productVariants = computed(() => pageData.value?.productVariants ?? { pages: [], ruling: [], cover: [] });
const patternVariants = computed(() => pageData.value?.patternVariants ?? { patterns: [] });
// Product content for storytelling modules
const coverName = computed(() => product.value?.cover?.name);
const { coverType, featureModules: baseFeatureModules, technicalSpecs, useCases } = useProductContent(coverName, product);
// Merge feature modules with slide images from CMS
const featureModules = computed(() => {
const slides = product.value?.cover?.slides ?? [];
return baseFeatureModules.value.map((module, i) => ({
...module,
image: slides[i]?.url ?? module.image,
}));
});
const displayState = computed(() => {
// Show loading only on initial load (no data yet)
if (status.value === "pending" && !product.value?.id) return "loading";
// Keep showing current content while refreshing
if (product.value?.id && product.value.publishedAt) return "main-content";
// Only show error if we're not loading and have no product
if (status.value !== "pending" && !product.value?.id) return "not-found-error";
if (status.value !== "pending" && !product.value?.publishedAt) return "not-found-error";
return "main-content";
});
const productImage = computed(() => {
return product.value?.images?.images?.[0];
});
const ctaBgColor = computed(() => {
if (product.value?.pattern) {
return randomTailwindColor(product.value.pattern.id, "bg", 700);
}
return "bg-black";
});
// Product variants computed properties
const productVariantsPages = computed(() => {
const variants = productVariants.value;
const prod = product.value;
if (!variants?.pages || !prod?.pages) return [];
return variants.pages
.filter(({ productVariant, id }) => !!productVariant || id === prod?.pages?.id)
.map((pages) => ({
id: pages.id,
name: pages.name,
iconUrl: pages.icon?.url,
productId: pages.productVariant?.id,
productSlug: pages.productVariant?.slug,
active: prod?.pages?.id === pages.id
}));
});
const productVariantsRuling = computed(() => {
const variants = productVariants.value;
const prod = product.value;
if (!variants?.ruling || !prod?.ruling) return [];
return variants.ruling
.filter(({ productVariant, id }) => !!productVariant || id === prod?.ruling?.id)
.map((ruling) => ({
id: ruling.id,
name: ruling.name,
iconUrl: ruling.icon?.url,
productId: ruling.productVariant?.id,
productSlug: ruling.productVariant?.slug,
active: prod?.ruling?.id === ruling.id
}));
});
const productVariantsCover = computed(() => {
const variants = productVariants.value;
const prod = product.value;
if (!variants?.cover || !prod?.cover) return [];
return variants.cover
.filter(({ productVariant, id }) => !!productVariant || id === prod?.cover?.id)
.map((cover) => ({
id: cover.id,
name: cover.name,
iconUrl: cover.icon?.url,
productId: cover.productVariant?.id,
productSlug: cover.productVariant?.slug,
active: prod?.cover?.id === cover.id
}));
});
const productVariantsPatterns = computed(() => {
const variants = patternVariants.value;
if (!variants?.patterns) return [];
return variants.patterns
.filter(({ productVariant }) => !!productVariant)
.map((pattern) => ({
id: pattern.id,
name: pattern.name,
imageUrl: pattern.image?.url,
productId: pattern.productVariant?.id,
productSlug: pattern.productVariant?.slug,
product: pattern.productVariant
}));
});
// Testimonials data
const testimonials: Testimonial[] = [
{
name: "Maria S.",
purpose: "Skizzenbuch",
comment: "Endlich ein Papier, das meine Aquarellstifte aushält. Kein Durchdrücken, nichts wellt sich. Hab schon das dritte bestellt.",
rating: 5
},
{
name: "Thomas W.",
purpose: "Tagebuch",
comment: "Schreibe seit Jahren Tagebuch und das hier ist mit Abstand das beste Notizbuch, das ich hatte. Liegt super in der Hand und die Seiten sind angenehm dick.",
rating: 5
},
{
name: "Julia B.",
purpose: "Studium",
comment: "Benutze es für Vorlesungsmitschriften. Das Papier ist top, mein Füller schmiert nicht. Sehr zufrieden!",
rating: 4
},
{
name: "Sophia K.",
purpose: "Bullet Journal",
comment: "Die Bindung ist echt gut, das Buch bleibt flach liegen beim Schreiben. Und ich mag, dass es in Deutschland hergestellt wird.",
rating: 5
},
{
name: "Lukas M.",
purpose: "Arbeit",
comment: "Nutze es für Meeting-Notizen. Sieht professionell aus und hält was aus. Hab's jetzt seit 8 Monaten täglich dabei.",
rating: 5
}
];
// Aggregate rating computed
const aggregateRating = computed(() => {
if (!testimonials.length) return null;
const totalRating = testimonials.reduce((sum, testimonial) => sum + testimonial.rating, 0);
const ratingCount = testimonials.length;
return {
ratingValue: (totalRating / ratingCount).toFixed(1),
ratingCount: ratingCount.toString(),
reviewCount: ratingCount.toString()
};
});
function formatPriceForSchema(price: number) {
return price.toString().replace(",", ".");
}
function getPriceValidUntilDate() {
const date = new Date();
date.setFullYear(date.getFullYear() + 1);
return date.toISOString().split("T")[0];
}
function getProductUrl() {
if (import.meta.client) {
return `${window.location.origin}/details/${slug.value}`;
}
return `/details/${slug.value}`;
}
function stripHtml(html: string): string {
return html.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
}
function getProductDescription() {
let description = "";
if (product.value?.cover?.copyText) {
const copyText = product.value.cover.copyText;
if (copyText.details) description += stripHtml(copyText.details) + " ";
if (copyText.format) description += "Format: " + copyText.format + ". ";
if (copyText.paper) description += "Papier: " + copyText.paper + ". ";
if (copyText.cover) description += "Einband: " + copyText.cover + ". ";
if (copyText.banderole) description += "Banderole: " + copyText.banderole + ". ";
}
return description.trim() || product.value?.name || "";
}
interface VariantItem {
id: number;
name: string;
active: boolean;
productSlug?: string;
}
function handleVariantClick(type: string, variant: VariantItem) {
if (!variant.active) {
isChangingVariant.value = true;
}
trackEvent(`product-change-${type}-clicked`, {
product: product.value?.id,
label: variant.name
});
}
function trackScrolling() {
if (hasScrolled100.value) return;
const progress = scrollProgress();
if (progress >= 50 && !hasScrolled50.value) {
trackEvent("product-details-scrolled-50");
hasScrolled50.value = true;
}
if (progress === 100) {
trackEvent("product-details-scrolled-100");
hasScrolled100.value = true;
}
}
async function addToCart() {
if (!product.value?.id) return;
trackEvent("product-details-add-to-cart-clicked", {
product: product.value.id,
price: product.value.totalProductPrice
});
try {
isAddingToCart.value = true;
await cartAddProduct(product.value.id);
trackEvent("product-details-add-to-cart-succeeded", {
product: product.value.id
});
navigateTo("/cart");
} catch (error) {
console.error(`Could not add product ${product.value.id} to cart:`, error);
trackEvent("product-details-add-to-cart-failed", {
product: product.value.id
});
} finally {
isAddingToCart.value = false;
}
}
// Lifecycle hooks
onMounted(() => {
window.addEventListener("scroll", trackScrolling);
// Track product view
if (product.value?.id) {
trackEvent("product-viewed", {
productId: product.value.id,
productSlug: slug.value,
productName: product.value.name,
price: product.value.totalProductPrice,
coverType: product.value.cover?.name
});
}
});
onUnmounted(() => {
window.removeEventListener("scroll", trackScrolling);
});
// SEO
useSeoMeta({
title: () => `${product.value?.name ?? "Produkt"} | MUELLERPRINTS`,
description: () => getProductDescription(),
ogTitle: () => product.value?.name,
ogDescription: () => getProductDescription(),
ogImage: () => productImage.value?.url,
ogType: "product"
});
</script>

15
pages/impressum.vue Normal file
View File

@@ -0,0 +1,15 @@
<template>
<main class="pb-20">
<PageHero title="Impressum" subtitle="Rechtliche Informationen" />
<PageSection :content="IMPRESSUM_CONTENT" />
</main>
</template>
<script setup lang="ts">
import { IMPRESSUM_CONTENT } from "~/composables/usePageContent";
useSeoMeta({
title: "Impressum | MUELLERPRINTS",
description: "Impressum und rechtliche Informationen von MUELLERPRINTS, Max Müller, Stuttgart.",
});
</script>

199
pages/index.vue Normal file
View File

@@ -0,0 +1,199 @@
<template>
<main class="pt-0">
<h1 class="sr-only">MUELLERPRINTS Handgefertigte Notizbücher aus Stuttgart</h1>
<Hero />
<!-- First cover section -->
<section v-if="covers[0]" class="my-20 lg:container mx-auto px-6">
<Heading v-if="covers[0]?.name" :level="2" html-tag="h2">{{ covers[0].name }}</Heading>
<p v-if="covers[0]?.copyText?.details" class="leading-6 tracking-tight xl:w-2/3" v-html="covers[0].copyText.details" />
<div class="mt-8">
<ClientOnly>
<LazyCarousel>
<Slide v-for="(item, j) in covers[0]?.products" :key="j">
<ProductCard :product="item" />
</Slide>
</LazyCarousel>
</ClientOnly>
</div>
</section>
<!-- Storytelling: Handwerk aus Stuttgart -->
<FeatureModule
eyebrow="Handwerk aus Stuttgart"
headline="Jedes Notizbuch ein Unikat"
subtitle="Tradition trifft Nachhaltigkeit"
:body="`
In unserer Werkstatt verbinden wir <strong>traditionelle Buchbindekunst</strong> mit modernem Design.
Jedes Notizbuch wird von Hand gebunden mit Fadenheftung, die ein flaches Aufschlagen ermöglicht.
Wir verwenden ausschließlich <strong>100% Recyclingpapier</strong> aus deutscher Produktion.
`"
:image="productionImage01"
class="bg-gray-50"
/>
<!-- Remaining cover sections with alternating backgrounds -->
<template v-for="(coverData, i) in covers.slice(1)" :key="i">
<section :class="['my-20 lg:container mx-auto px-6']">
<Heading v-if="coverData?.name" :level="2" html-tag="h2">{{ coverData.name }}</Heading>
<p v-if="coverData?.copyText?.details" class="leading-6 tracking-tight xl:w-2/3" v-html="coverData.copyText.details" />
<div class="mt-8">
<ClientOnly>
<LazyCarousel>
<Slide v-for="(item, j) in coverData?.products" :key="j">
<ProductCard :product="item" />
</Slide>
</LazyCarousel>
</ClientOnly>
</div>
</section>
</template>
<!-- CTA Section -->
<section class="py-16 bg-gray-900 text-white">
<div class="container mx-auto px-6 text-center">
<h3 class="text-3xl lg:text-4xl font-bebas mb-4">Premium Notizbücher für kreative Köpfe</h3>
<p class="text-lg opacity-80 max-w-2xl mx-auto mb-8">
Erfahre wie unsere handgefertigten Notizbücher aus recyceltem Papier hergestellt werden
</p>
<NuxtLink
to="/notebooks"
class="inline-block px-8 py-3 rounded-full font-medium bg-white text-gray-900 hover:bg-gray-200 transition-all duration-200"
@click="trackEvent('start-page-cta-clicked')"
>
Jetzt entdecken
</NuxtLink>
</div>
</section>
<!-- Testimonials (hidden for now)
<section v-if="testimonials?.length > 0" class="px-6 lg:container mx-auto mt-20 mb-32">
<div class="text-center mb-10">
<h3 class="text-2xl font-bold mb-3">Was unsere Kunden sagen</h3>
<p class="text-gray-600 max-w-2xl mx-auto">Erfahrungen und Bewertungen von zufriedenen Kunden</p>
</div>
<LazyTestimonial
:testimonials="testimonials"
:show-title="false"
:show-rating="true"
:show-avatar="false"
:show-quote-icon="true"
primary-color="#374151"
star-color="#374151"
/>
</section>
-->
</main>
</template>
<script setup lang="ts">
import { Slide } from "vue3-carousel";
import productionImage01 from "~/assets/production/01.png";
import { trackEvent } from "~/utils/trackEvent";
import { scrollProgress } from "~/utils/scrollProgress";
import type { Testimonial } from "~/types";
const shopApi = useShopApi();
// Fetch covers with their products
const { data: coversData } = await useAsyncData("landing-covers", async () => {
try {
const { data } = await shopApi.getProductCovers();
const covers = await Promise.all(
data.map(async (cover: any) => {
const productsResponse = await shopApi.getCheapestProducts(cover.id, 1, 10);
return {
id: cover.id,
name: cover.attributes?.name,
sort: cover.attributes?.sort ?? 0,
copyText: cover.attributes?.copyText,
products: productsResponse?.data ?? []
};
})
);
return covers
.filter((c) => c.products.length > 0)
.sort((a, b) => a.sort - b.sort);
} catch (error) {
console.error("Error fetching covers:", error);
return [];
}
});
const covers = computed(() => coversData.value ?? []);
// Testimonials
const testimonials: Testimonial[] = [
{
name: "Maria S.",
purpose: "Skizzenbuch",
comment: "Endlich ein Papier, das meine Aquarellstifte aushält. Kein Durchdrücken, nichts wellt sich. Hab schon das dritte bestellt.",
rating: 5
},
{
name: "Thomas W.",
purpose: "Tagebuch",
comment: "Schreibe seit Jahren Tagebuch und das hier ist mit Abstand das beste Notizbuch, das ich hatte. Liegt super in der Hand.",
rating: 5
},
{
name: "Julia B.",
purpose: "Studium",
comment: "Benutze es für Vorlesungsmitschriften. Das Papier ist top, mein Füller schmiert nicht.",
rating: 4
},
{
name: "Sophia K.",
purpose: "Bullet Journal",
comment: "Die Bindung ist echt gut, das Buch bleibt flach liegen beim Schreiben. Und ich mag, dass es in Deutschland hergestellt wird.",
rating: 5
}
];
// Scroll tracking
const hasScrolled50 = ref(false);
const hasScrolled100 = ref(false);
function trackScrolling() {
if (hasScrolled100.value) return;
const progress = scrollProgress();
if (progress >= 50 && !hasScrolled50.value) {
trackEvent("start-page-scrolled-50");
hasScrolled50.value = true;
}
if (progress === 100) {
trackEvent("start-page-scrolled-100");
hasScrolled100.value = true;
}
}
// Toggle dark mode for hero section
const toggleDarkMode = inject<(value: boolean) => void>("toggleDarkMode");
onMounted(() => {
toggleDarkMode?.(true);
window.addEventListener("scroll", trackScrolling);
// Track start page view
trackEvent("start-page-viewed", {
coverCount: covers.value.length
});
});
onUnmounted(() => {
toggleDarkMode?.(false);
window.removeEventListener("scroll", trackScrolling);
});
// SEO Meta
useSeoMeta({
title: "MUELLERPRINTS - Handgefertigte Notizbücher aus Stuttgart",
description: "Handgefertigte Notizbücher mit 100% Recyclingpapier, traditioneller Fadenheftung und einzigartigen Einbanddesigns. Made in Stuttgart.",
ogTitle: "MUELLERPRINTS - Handgefertigte Notizbücher",
ogDescription: "Handgefertigte Notizbücher mit 100% Recyclingpapier, traditioneller Fadenheftung und einzigartigen Einbanddesigns.",
ogType: "website"
});
</script>

44
pages/kontakt.vue Normal file
View File

@@ -0,0 +1,44 @@
<template>
<main class="pb-20">
<PageHero title="Kontakt" subtitle="Wir freuen uns auf Ihre Nachricht" />
<section class="py-12 lg:py-16">
<div class="xl:container mx-auto px-6">
<div class="grid lg:grid-cols-2 gap-12">
<!-- Contact Form -->
<div>
<h2 class="text-2xl font-bold mb-6">Schreiben Sie uns</h2>
<ContactForm />
</div>
<!-- Contact Info -->
<div class="space-y-8">
<div class="bg-gray-50 rounded-xl p-8">
<h2 class="text-xl font-bold mb-6">Kontaktdaten</h2>
<ContactInfo :contact="CONTACT_INFO" />
</div>
<div class="bg-gray-50 rounded-xl p-8">
<h3 class="text-lg font-bold mb-4">Öffnungszeiten</h3>
<OpeningHours :hours="OPENING_HOURS" />
</div>
<div class="flex flex-col sm:flex-row gap-4">
<NuxtLink to="/anfahrt" class="flex-1 text-center px-6 py-3 border-2 border-gray-900 text-gray-900 font-semibold rounded-full hover:bg-gray-100 transition-colors"> Anfahrt </NuxtLink>
<NuxtLink to="/oeffnungszeiten" class="flex-1 text-center px-6 py-3 border-2 border-gray-900 text-gray-900 font-semibold rounded-full hover:bg-gray-100 transition-colors"> Öffnungszeiten </NuxtLink>
</div>
</div>
</div>
</div>
</section>
</main>
</template>
<script setup lang="ts">
import { CONTACT_INFO, OPENING_HOURS } from "~/composables/usePageContent";
useSeoMeta({
title: "Kontakt | MUELLERPRINTS",
description: "Kontaktieren Sie MUELLERPRINTS per Telefon, E-Mail oder über unser Kontaktformular. Wir freuen uns auf Ihre Nachricht.",
});
</script>

182
pages/notebooks/[cover].vue Normal file
View File

@@ -0,0 +1,182 @@
<template>
<main>
<!-- Hero Section -->
<section class="bg-gray-900 text-white py-16 lg:py-24">
<div class="container mx-auto px-6 text-center">
<p class="text-sm uppercase tracking-widest opacity-60 mb-4">{{ coverData?.copyText?.format }}</p>
<h1 class="text-4xl lg:text-5xl font-bebas mb-4">{{ coverData?.name }}</h1>
<p v-if="coverData?.copyText?.details" class="text-lg opacity-80 max-w-2xl mx-auto" v-html="coverData.copyText.details" />
</div>
</section>
<!-- Category Navigation -->
<section class="py-8 border-b border-gray-200">
<div class="container mx-auto px-6">
<div class="flex flex-wrap justify-center gap-3">
<NuxtLink
to="/notebooks"
class="px-4 py-2 rounded-full text-sm font-medium transition-colors bg-gray-100 text-gray-700 hover:bg-gray-200"
>
Alle
</NuxtLink>
<NuxtLink
v-for="c in covers"
:key="c.id"
:to="`/notebooks/${c.slug}`"
class="px-4 py-2 rounded-full text-sm font-medium transition-colors"
:class="c.slug === cover ? 'bg-gray-900 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'"
>
{{ c.name }}
</NuxtLink>
</div>
</div>
</section>
<!-- Products Grid -->
<section class="py-12 lg:py-16">
<div class="container mx-auto px-6">
<!-- Section Header -->
<div class="flex justify-between items-center mb-8">
<div>
<h2 class="text-xl font-semibold">{{ pagination?.total ?? 0 }} Notizbücher</h2>
<p class="text-sm text-gray-500">Seite {{ currentPage }} von {{ pagination?.pageCount ?? 1 }}</p>
</div>
</div>
<!-- Loading State -->
<div v-if="pending" class="text-center py-12">
<LoadingSpinner />
<p class="mt-4 text-gray-500">Produkte werden geladen...</p>
</div>
<!-- Error State -->
<div v-else-if="error" class="text-center py-12 text-red-600">
<p>Fehler beim Laden der Produkte</p>
</div>
<!-- Products Grid -->
<div v-else class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6">
<ProductCard v-for="(item, i) in products" :key="i" :product="item" />
</div>
<!-- Pagination -->
<div v-if="pagination && pagination.pageCount > 1" class="mt-12 flex justify-center gap-2">
<NuxtLink
v-for="pageNum in pagination.pageCount"
:key="pageNum"
:to="pageNum === 1 ? `/notebooks/${cover}` : `/notebooks/${cover}?page=${pageNum}`"
class="px-4 py-2 rounded-full text-sm font-medium transition-colors"
:class="pageNum === currentPage ? 'bg-gray-900 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'"
>
{{ pageNum }}
</NuxtLink>
</div>
</div>
</section>
<!-- Footer CTA -->
<section class="py-16 bg-gray-50 border-t border-gray-200">
<div class="container mx-auto px-6 text-center">
<h3 class="text-2xl font-bold mb-3">Fragen zu unseren Produkten?</h3>
<p class="text-gray-600 max-w-xl mx-auto mb-6">
Jedes Notizbuch wird in unserer Stuttgarter Werkstatt von Hand gebunden.
</p>
<NuxtLink
to="/about"
class="inline-block px-6 py-3 rounded-full font-medium bg-gray-900 text-white hover:bg-gray-700 transition-colors"
>
Mehr über uns
</NuxtLink>
</div>
</section>
</main>
</template>
<script setup lang="ts">
import { slugify } from "~/utils/slugify";
import { trackEvent } from "~/utils/trackEvent";
const route = useRoute();
const cover = computed(() => route.params.cover as string);
const currentPage = computed(() => parseInt(route.query.page as string) || 1);
const shopApi = useShopApi();
// Fetch cover data - key must be dynamic to avoid stale cache on navigation
const { data: coverResponse } = await useAsyncData(
() => `cover-${cover.value}`,
() => shopApi.getProductCoverById(cover.value),
{ watch: [cover] }
);
const coverData = computed(() => coverResponse.value?.data?.attributes);
// Fetch products
const { data, pending, error } = await useAsyncData(
() => `products-cover-${cover.value}-page-${currentPage.value}`,
() => shopApi.getCheapestProducts(cover.value, currentPage.value, 24),
{ watch: [cover, currentPage] }
);
// Fetch all covers for category navigation
const { data: coversData } = await useAsyncData("covers-nav", async () => {
try {
const { data } = await shopApi.getProductCovers();
return data
.map((c: any) => ({
id: c.id,
name: c.attributes?.name,
slug: slugify(c.attributes?.name ?? ""),
sort: c.attributes?.sort ?? 0
}))
.sort((a: any, b: any) => a.sort - b.sort);
} catch {
return [];
}
});
const covers = computed(() => coversData.value ?? []);
const products = computed(() => data.value?.data ?? []);
const pagination = computed(() => data.value?.meta?.pagination);
// Strip HTML tags from CMS rich text for use in meta tags
function stripHtml(html: string): string {
return html.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
}
// SEO
useSeoMeta({
title: () => `${coverData.value?.name ?? "Notizbücher"} | MUELLERPRINTS`,
description: () => stripHtml(coverData.value?.copyText?.details ?? "Entdecken Sie unsere handgefertigten Notizbücher aus Stuttgart."),
ogTitle: () => `${coverData.value?.name ?? "Notizbücher"} | MUELLERPRINTS`,
ogDescription: () => stripHtml(coverData.value?.copyText?.details ?? "Entdecken Sie unsere handgefertigten Notizbücher aus Stuttgart.")
});
// Track category view on mount
onMounted(() => {
trackEvent("category-viewed", {
category: cover.value,
page: currentPage.value,
totalProducts: pagination.value?.total ?? 0
});
});
// Track pagination clicks
watch(currentPage, (newPage, oldPage) => {
if (newPage !== oldPage) {
trackEvent("category-pagination-clicked", {
category: cover.value,
page: newPage
});
}
});
// Track category filter changes
watch(cover, (newCover, oldCover) => {
if (newCover !== oldCover) {
trackEvent("category-filter-applied", {
from: oldCover,
to: newCover
});
}
});
</script>

176
pages/notebooks/index.vue Normal file
View File

@@ -0,0 +1,176 @@
<template>
<main>
<!-- Hero Section -->
<section class="bg-gray-900 text-white py-16 lg:py-24">
<div class="container mx-auto px-6 text-center">
<p class="text-sm uppercase tracking-widest opacity-60 mb-4">Unsere Kollektion</p>
<h1 class="text-4xl lg:text-5xl font-bebas mb-4">Alle Notizbücher</h1>
<p class="text-lg opacity-80 max-w-2xl mx-auto">
Handgefertigte Notizbücher aus Stuttgart mit 100% Recyclingpapier und traditioneller Fadenheftung.
</p>
</div>
</section>
<!-- Category Navigation -->
<section class="py-8 border-b border-gray-200">
<div class="container mx-auto px-6">
<div class="flex flex-wrap justify-center gap-3">
<NuxtLink
to="/notebooks"
class="px-4 py-2 rounded-full text-sm font-medium transition-colors bg-gray-900 text-white"
>
Alle
</NuxtLink>
<NuxtLink
v-for="cover in covers"
:key="cover.id"
:to="`/notebooks/${cover.slug}`"
class="px-4 py-2 rounded-full text-sm font-medium transition-colors bg-gray-100 text-gray-700 hover:bg-gray-200"
>
{{ cover.name }}
</NuxtLink>
</div>
</div>
</section>
<!-- Products Grid -->
<section class="py-12 lg:py-16">
<div class="container mx-auto px-6">
<!-- Section Header -->
<div class="flex justify-between items-center mb-8">
<div>
<h2 class="text-xl font-semibold">{{ pagination?.total ?? 0 }} Notizbücher</h2>
<p class="text-sm text-gray-500">Seite {{ currentPage }} von {{ pagination?.pageCount ?? 1 }}</p>
</div>
</div>
<!-- Loading State -->
<div v-if="pending" class="text-center py-12">
<LoadingSpinner />
<p class="mt-4 text-gray-500">Produkte werden geladen...</p>
</div>
<!-- Error State -->
<div v-else-if="error" class="text-center py-12 text-red-600">
<p>Fehler beim Laden der Produkte</p>
</div>
<!-- Products Grid -->
<div v-else class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6">
<ProductCard v-for="product in products" :key="product.id" :product="normalizeProduct(product)" />
</div>
<!-- Pagination -->
<div v-if="pagination && pagination.pageCount > 1" class="mt-12 flex justify-center gap-2">
<NuxtLink
v-for="pageNum in pagination.pageCount"
:key="pageNum"
:to="pageNum === 1 ? '/notebooks' : `/notebooks?page=${pageNum}`"
class="px-4 py-2 rounded-full text-sm font-medium transition-colors"
:class="pageNum === currentPage ? 'bg-gray-900 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'"
>
{{ pageNum }}
</NuxtLink>
</div>
</div>
</section>
<!-- Footer CTA -->
<section class="py-16 bg-gray-50 border-t border-gray-200">
<div class="container mx-auto px-6 text-center">
<h3 class="text-2xl font-bold mb-3">Fragen zu unseren Produkten?</h3>
<p class="text-gray-600 max-w-xl mx-auto mb-6">
Jedes Notizbuch wird in unserer Stuttgarter Werkstatt von Hand gebunden.
</p>
<NuxtLink
to="/about"
class="inline-block px-6 py-3 rounded-full font-medium bg-gray-900 text-white hover:bg-gray-700 transition-colors"
>
Mehr über uns
</NuxtLink>
</div>
</section>
</main>
</template>
<script setup lang="ts">
import type { Product } from "~/types";
import { slugify } from "~/utils/slugify";
import { trackEvent } from "~/utils/trackEvent";
const route = useRoute();
const currentPage = computed(() => parseInt(route.query.page as string) || 1);
const shopApi = useShopApi();
// Fetch products (cheapest variant per cover+pattern)
const { data, pending, error } = await useAsyncData(
() => `products-all-page-${currentPage.value}`,
() => shopApi.getCheapestProducts(undefined, currentPage.value, 20),
{ watch: [currentPage] }
);
// Fetch covers for category navigation
const { data: coversData } = await useAsyncData("covers-nav", async () => {
try {
const { data } = await shopApi.getProductCovers();
return data
.map((cover: any) => ({
id: cover.id,
name: cover.attributes?.name,
slug: slugify(cover.attributes?.name ?? ""),
sort: cover.attributes?.sort ?? 0
}))
.sort((a: any, b: any) => a.sort - b.sort);
} catch {
return [];
}
});
const covers = computed(() => coversData.value ?? []);
const products = computed(() => data.value?.data ?? []);
const pagination = computed(() => data.value?.meta?.pagination);
// Normalize Strapi response to Product type
function normalizeProduct(item: any): Product {
const attrs = item.attributes ?? item;
return {
id: item.id,
name: attrs.name,
slug: attrs.slug,
totalProductPrice: attrs.totalProductPrice ?? attrs.price,
pattern: attrs.pattern?.data ? { id: attrs.pattern.data.id, name: attrs.pattern.data.attributes?.name } : attrs.pattern,
cover: attrs.cover?.data ? { id: attrs.cover.data.id, name: attrs.cover.data.attributes?.name } : attrs.cover,
ruling: attrs.ruling?.data ? { id: attrs.ruling.data.id, name: attrs.ruling.data.attributes?.name } : attrs.ruling,
pages: attrs.pages?.data ? { id: attrs.pages.data.id, name: attrs.pages.data.attributes?.name } : attrs.pages,
images: attrs.images
};
}
// SEO
useSeoMeta({
title: "Alle Notizbücher | MUELLERPRINTS",
description: "Entdecken Sie unsere handgefertigten Notizbücher aus Stuttgart mit 100% Recyclingpapier.",
ogTitle: "Alle Notizbücher | MUELLERPRINTS",
ogDescription: "Entdecken Sie unsere handgefertigten Notizbücher aus Stuttgart mit 100% Recyclingpapier."
});
// Track category view on mount
onMounted(() => {
trackEvent("category-viewed", {
category: "all",
page: currentPage.value,
totalProducts: pagination.value?.total ?? 0
});
});
// Track pagination clicks
watch(currentPage, (newPage, oldPage) => {
if (newPage !== oldPage) {
trackEvent("category-pagination-clicked", {
category: "all",
page: newPage
});
}
});
</script>

54
pages/oeffnungszeiten.vue Normal file
View File

@@ -0,0 +1,54 @@
<template>
<main class="pb-20">
<!-- Hero -->
<PageHero title="Öffnungszeiten" subtitle="Besuchen Sie uns in unserer Werkstatt in Stuttgart" />
<!-- Opening Hours -->
<section class="py-12 lg:py-16">
<div class="xl:container mx-auto px-6">
<div class="max-w-lg mx-auto">
<div class="bg-white rounded-xl shadow-lg p-8">
<h2 class="text-xl font-bold mb-6">Unsere Öffnungszeiten</h2>
<OpeningHours :hours="OPENING_HOURS" />
</div>
<div class="mt-8 p-6 bg-gray-50 rounded-xl">
<p class="text-gray-600 text-sm">
<strong>Hinweis:</strong> Außerhalb der regulären Öffnungszeiten sind wir nach Vereinbarung erreichbar. Kontaktieren Sie uns gerne telefonisch oder per E-Mail.
</p>
</div>
</div>
</div>
</section>
<!-- Contact Info -->
<section class="py-12 lg:py-16 bg-gray-50">
<div class="xl:container mx-auto px-6">
<div class="max-w-lg mx-auto text-center">
<h2 class="text-2xl font-bold mb-6">Kontakt</h2>
<div class="bg-white rounded-xl shadow-lg p-8 text-left">
<ContactInfo :contact="CONTACT_INFO" />
</div>
<div class="mt-8">
<NuxtLink to="/anfahrt" class="inline-flex items-center gap-2 px-8 py-3 bg-gray-900 text-white font-semibold rounded-full hover:bg-gray-800 transition-colors">
<IconMapPin class="w-5 h-5" />
Anfahrt anzeigen
</NuxtLink>
</div>
</div>
</div>
</section>
</main>
</template>
<script setup lang="ts">
import { OPENING_HOURS, CONTACT_INFO } from "~/composables/usePageContent";
import IconMapPin from "~/components/icons/IconMapPin.vue";
useSeoMeta({
title: "Öffnungszeiten | MUELLERPRINTS",
description: "Besuchen Sie uns in Stuttgart. Montag bis Freitag geöffnet. Außerhalb der Öffnungszeiten nach Vereinbarung.",
ogTitle: "Öffnungszeiten | MUELLERPRINTS",
ogDescription: "Besuchen Sie uns in Stuttgart. Montag bis Freitag geöffnet.",
});
</script>

58
pages/versand.vue Normal file
View File

@@ -0,0 +1,58 @@
<template>
<main class="pb-20">
<PageHero title="Versand" subtitle="Lieferung und Versandkosten" />
<PageSection :content="VERSAND_CONTENT" />
<!-- Dynamic Delivery Methods from API -->
<section class="py-12 lg:py-16 bg-gray-50">
<div class="xl:container mx-auto px-6">
<h2 class="text-2xl font-bold mb-8">Verfügbare Versandarten</h2>
<div v-if="pending" class="text-center py-8">
<LoadingSpinner />
</div>
<div v-else-if="deliveryMethods.length > 0" class="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
<div v-for="method in deliveryMethods" :key="method.id" class="bg-white rounded-xl p-6 shadow-sm">
<div class="flex justify-between items-start mb-2">
<h3 class="text-lg font-semibold">{{ method.name }}</h3>
<span class="text-lg font-bold">{{ formatPrice(method.price) }}</span>
</div>
<p v-if="method.description" class="text-gray-600 text-sm">{{ method.description }}</p>
</div>
</div>
</div>
</section>
</main>
</template>
<script setup lang="ts">
import { VERSAND_CONTENT } from "~/composables/usePageContent";
import { numberFormatter } from "~/utils/numberFormatter";
const shopApi = useShopApi();
const { data: deliveryData, status } = await useAsyncData("delivery-methods", async () => {
const response = await shopApi.getDeliveryMethods();
// Flatten Strapi v4 attributes structure
return (response.data || []).map((item: any) => ({
id: item.id,
name: item.attributes?.name ?? item.name,
price: item.attributes?.price ?? item.price ?? 0,
description: item.attributes?.description ?? item.description
}));
});
const pending = computed(() => status.value === "pending");
const deliveryMethods = computed(() => deliveryData.value || []);
function formatPrice(price: number) {
if (price === 0) return "Kostenlos";
return numberFormatter(price, "EUR");
}
useSeoMeta({
title: "Versand | MUELLERPRINTS",
description: "Informationen zu Lieferung und Versandkosten bei MUELLERPRINTS. Schneller Versand mit DHL und Deutsche Post.",
});
</script>

68
pages/zahlung.vue Normal file
View File

@@ -0,0 +1,68 @@
<template>
<main class="pb-20">
<PageHero title="Zahlung" subtitle="Sichere Zahlungsmöglichkeiten für Ihren Einkauf" />
<!-- Dynamic Payment Methods from API -->
<section class="py-12 lg:py-16">
<div class="xl:container mx-auto px-6">
<div v-if="pending" class="text-center py-8">
<LoadingSpinner />
</div>
<div v-else-if="paymentMethods.length > 0" class="grid md:grid-cols-2 gap-8">
<div v-for="method in paymentMethods" :key="method.id" class="bg-gray-50 rounded-xl p-6">
<div class="flex justify-between items-start mb-3">
<h2 class="text-xl font-semibold">{{ method.name }}</h2>
<span v-if="method.price > 0" class="text-sm text-gray-500 bg-white px-2 py-1 rounded">+ {{ formatPrice(method.price) }}</span>
</div>
<p v-if="method.description" class="text-gray-600 leading-relaxed">{{ method.description }}</p>
</div>
</div>
</div>
</section>
<!-- Security Note -->
<section class="py-12 lg:py-16 bg-gray-50">
<div class="xl:container mx-auto px-6">
<div class="max-w-2xl mx-auto text-center">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-white mb-6">
<svg class="w-8 h-8 text-gray-700" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<h2 class="text-2xl font-bold mb-4">Sicher bezahlen</h2>
<p class="text-gray-600">Alle Zahlungen werden über verschlüsselte Verbindungen abgewickelt. Ihre Daten sind bei uns sicher.</p>
</div>
</div>
</section>
</main>
</template>
<script setup lang="ts">
import { numberFormatter } from "~/utils/numberFormatter";
const shopApi = useShopApi();
const { data: paymentData, status } = await useAsyncData("payment-methods", async () => {
const response = await shopApi.getPaymentMethods();
// Flatten Strapi v4 attributes structure
return (response.data || []).map((item: any) => ({
id: item.id,
name: item.attributes?.name ?? item.name,
price: item.attributes?.price ?? item.price ?? 0,
description: item.attributes?.description ?? item.description
}));
});
const pending = computed(() => status.value === "pending");
const paymentMethods = computed(() => paymentData.value || []);
function formatPrice(price: number) {
return numberFormatter(price, "EUR");
}
useSeoMeta({
title: "Zahlung | MUELLERPRINTS",
description: "Zahlungsmöglichkeiten bei MUELLERPRINTS: PayPal, Kreditkarte, Lastschrift, Barzahlung. Sichere und bequeme Bezahlung."
});
</script>

15
plugins/umami.client.ts Normal file
View File

@@ -0,0 +1,15 @@
export default defineNuxtPlugin(() => {
const config = useRuntimeConfig();
if (config.public.umamiScriptUrl && config.public.umamiWebsiteId) {
useHead({
script: [
{
src: config.public.umamiScriptUrl,
defer: true,
"data-website-id": config.public.umamiWebsiteId
}
]
});
}
});

BIN
public/favicon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 921 B

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 583 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 410 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 583 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 491 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 828 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 825 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 607 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 410 KiB

BIN
public/paperwork-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

2
public/robots.txt Normal file
View File

@@ -0,0 +1,2 @@
User-Agent: *
Disallow:

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More