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.
55
.gitea/workflows/build.yml
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
After Width: | Height: | Size: 1.1 MiB |
BIN
assets/landingpage/02.png
Normal file
|
After Width: | Height: | Size: 828 KiB |
BIN
assets/landingpage/03.png
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
assets/landingpage/04.jpg
Normal file
|
After Width: | Height: | Size: 658 KiB |
BIN
assets/production/01.png
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
assets/production/02.png
Normal file
|
After Width: | Height: | Size: 825 KiB |
BIN
assets/production/03.png
Normal file
|
After Width: | Height: | Size: 607 KiB |
BIN
assets/production/04.png
Normal file
|
After Width: | Height: | Size: 410 KiB |
213
assets/visa-mastercard-paypal.svg
Normal 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
@@ -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
@@ -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
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<div class="carousel__wrapper">
|
||||
<Carousel v-bind="carouselSettings">
|
||||
<slot />
|
||||
<template #addons>
|
||||
<Navigation />
|
||||
</template>
|
||||
</Carousel>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import "vue3-carousel/dist/carousel.css";
|
||||
import { Carousel, Navigation } from "vue3-carousel";
|
||||
|
||||
const carouselSettings = {
|
||||
itemsToShow: 1,
|
||||
wrapAround: false,
|
||||
transition: 360,
|
||||
autoplay: false,
|
||||
gap: 20,
|
||||
mouseDrag: false,
|
||||
touchDrag: true,
|
||||
breakpoints: {
|
||||
990: {
|
||||
mouseDrag: false,
|
||||
touchDrag: true,
|
||||
itemsToShow: 4,
|
||||
snapAlign: "start",
|
||||
gap: 40,
|
||||
pauseAutoplayOnHover: true
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.carousel__prev,
|
||||
.carousel__next {
|
||||
@apply w-24 h-24 absolute right-auto bottom-0 top-auto rounded-full hover:shadow-sm hover:bg-gray-100 focus:bg-gray-100 focus:ring-4 ring-gray-500 focus:ring-offset-2 bg-gray-200 bg-opacity-70 text-gray-400 text-sm transition-colors scale-75 font-bold;
|
||||
left: min(7rem, calc(100% - 6rem));
|
||||
}
|
||||
|
||||
.carousel__prev {
|
||||
@apply left-0;
|
||||
}
|
||||
|
||||
.carousel__viewport {
|
||||
@apply overflow-hidden;
|
||||
}
|
||||
|
||||
.carousel__wrapper {
|
||||
.carousel__viewport {
|
||||
@apply pb-28;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
45
components/CheckoutError.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<div class="bg-red-50 border border-red-200 rounded-xl p-6 text-center max-w-md mx-auto">
|
||||
<div class="inline-flex items-center justify-center w-12 h-12 rounded-full bg-red-100 mb-4">
|
||||
<svg class="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-red-800 mb-2">{{ title }}</h3>
|
||||
<p class="text-red-600 mb-6">{{ message }}</p>
|
||||
<div class="flex flex-col sm:flex-row gap-3 justify-center">
|
||||
<button
|
||||
v-if="showRetry"
|
||||
@click="$emit('retry')"
|
||||
class="px-6 py-2.5 bg-red-600 text-white rounded-full font-medium hover:bg-red-700 transition-colors"
|
||||
>
|
||||
Erneut versuchen
|
||||
</button>
|
||||
<NuxtLink
|
||||
to="/"
|
||||
class="px-6 py-2.5 bg-white border border-red-200 text-red-700 rounded-full font-medium hover:bg-red-50 transition-colors"
|
||||
>
|
||||
Zur Startseite
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
title?: string;
|
||||
message?: string;
|
||||
showRetry?: boolean;
|
||||
}>(),
|
||||
{
|
||||
title: "Ein Fehler ist aufgetreten",
|
||||
message: "Dein Warenkorb konnte nicht geladen werden. Bitte versuche es erneut.",
|
||||
showRetry: true
|
||||
}
|
||||
);
|
||||
|
||||
defineEmits<{
|
||||
retry: [];
|
||||
}>();
|
||||
</script>
|
||||
66
components/CheckoutSkeleton.vue
Normal file
@@ -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
@@ -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
@@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<form @submit.prevent="handleSubmit" class="space-y-6">
|
||||
<div>
|
||||
<label for="name" class="block text-sm font-medium text-gray-700 mb-1">Name *</label>
|
||||
<input
|
||||
id="name"
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
required
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-900 focus:border-transparent"
|
||||
placeholder="Ihr Name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-gray-700 mb-1">E-Mail *</label>
|
||||
<input
|
||||
id="email"
|
||||
v-model="form.email"
|
||||
type="email"
|
||||
required
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-900 focus:border-transparent"
|
||||
placeholder="ihre@email.de"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="subject" class="block text-sm font-medium text-gray-700 mb-1">Betreff</label>
|
||||
<input
|
||||
id="subject"
|
||||
v-model="form.subject"
|
||||
type="text"
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-900 focus:border-transparent"
|
||||
placeholder="Betreff Ihrer Nachricht"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="message" class="block text-sm font-medium text-gray-700 mb-1">Nachricht *</label>
|
||||
<textarea
|
||||
id="message"
|
||||
v-model="form.message"
|
||||
required
|
||||
rows="5"
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-900 focus:border-transparent resize-none"
|
||||
placeholder="Ihre Nachricht an uns..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Honeypot field for spam protection -->
|
||||
<div class="hidden" aria-hidden="true">
|
||||
<input v-model="form.honeypot" type="text" name="website" tabindex="-1" autocomplete="off" />
|
||||
</div>
|
||||
|
||||
<div v-if="status === 'success'" class="p-4 bg-green-50 text-green-800 rounded-lg">Vielen Dank für Ihre Nachricht! Wir werden uns so schnell wie möglich bei Ihnen melden.</div>
|
||||
|
||||
<div v-if="status === 'error'" class="p-4 bg-red-50 text-red-800 rounded-lg">Es ist ein Fehler aufgetreten. Bitte versuchen Sie es später erneut oder kontaktieren Sie uns direkt per E-Mail.</div>
|
||||
|
||||
<button type="submit" :disabled="isSubmitting" class="w-full sm:w-auto px-8 py-3 bg-gray-900 text-white font-semibold rounded-full hover:bg-gray-800 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
|
||||
<span v-if="isSubmitting">Wird gesendet...</span>
|
||||
<span v-else>Nachricht senden</span>
|
||||
</button>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const form = reactive({
|
||||
name: "",
|
||||
email: "",
|
||||
subject: "",
|
||||
message: "",
|
||||
honeypot: "", // spam protection
|
||||
});
|
||||
|
||||
const isSubmitting = ref(false);
|
||||
const status = ref<"idle" | "success" | "error">("idle");
|
||||
|
||||
async function handleSubmit() {
|
||||
// Check honeypot
|
||||
if (form.honeypot) {
|
||||
status.value = "success"; // Fake success for bots
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting.value = true;
|
||||
status.value = "idle";
|
||||
|
||||
try {
|
||||
await $fetch("/api/contact", {
|
||||
method: "POST",
|
||||
body: {
|
||||
name: form.name,
|
||||
email: form.email,
|
||||
subject: form.subject || "Kontaktanfrage über Website",
|
||||
message: form.message,
|
||||
},
|
||||
});
|
||||
|
||||
status.value = "success";
|
||||
// Reset form
|
||||
form.name = "";
|
||||
form.email = "";
|
||||
form.subject = "";
|
||||
form.message = "";
|
||||
} catch (error) {
|
||||
console.error("Contact form error:", error);
|
||||
status.value = "error";
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
55
components/ContactInfo.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<IconMapPin class="w-5 h-5 mt-1 text-gray-600 flex-shrink-0" />
|
||||
<div>
|
||||
<p class="font-semibold">{{ contact.name }}</p>
|
||||
<p>{{ contact.street }}</p>
|
||||
<p>{{ contact.postalCode }} {{ contact.city }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-gray-600 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"
|
||||
/>
|
||||
</svg>
|
||||
<a :href="`tel:${contact.phone.replace(/\s/g, '')}`" class="hover:underline">{{ contact.phone }}</a>
|
||||
</div>
|
||||
|
||||
<div v-if="contact.fax" class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-gray-600 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z" />
|
||||
</svg>
|
||||
<span>{{ contact.fax }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-gray-600 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<a :href="`mailto:${contact.email}`" class="hover:underline">{{ contact.email }}</a>
|
||||
</div>
|
||||
|
||||
<div v-if="showVatId" class="flex items-center gap-3 text-sm text-gray-600">
|
||||
<svg class="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<span>USt-IdNr.: {{ contact.vatId }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ContactInfo } from "~/composables/usePageContent";
|
||||
import IconMapPin from "~/components/icons/IconMapPin.vue";
|
||||
|
||||
defineProps<{
|
||||
contact: ContactInfo;
|
||||
showVatId?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
55
components/CookieBanner.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="consentGiven === false"
|
||||
class="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 shadow-lg px-4 py-3 text-gray-800 z-50 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-4"
|
||||
>
|
||||
<p class="text-xs text-gray-600 sm:flex-1">
|
||||
{{ consentText }}
|
||||
</p>
|
||||
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<Button
|
||||
href="/datenschutz"
|
||||
data-cy="cookie-banner-privacy"
|
||||
classes="bg-transparent border-transparent py-1.5 px-3 !text-xs !text-black hover:!bg-gray-100 shadow-none"
|
||||
>
|
||||
Datenschutzerklärung
|
||||
</Button>
|
||||
<Button @click="acceptCookies" data-cy="cookie-banner-accept" classes="bg-gray-800 py-1.5 px-4 !text-xs whitespace-nowrap">
|
||||
Akzeptieren
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const COOKIE_CONSENT_KEY = "shop:cookie-consent";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
consentText?: string;
|
||||
}>(),
|
||||
{
|
||||
consentText:
|
||||
"Wir verwenden notwendige Cookies für die sichere Zahlungsabwicklung. Weitere Informationen finden Sie in unserer Datenschutzerklärung:"
|
||||
}
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
"consent-given": [];
|
||||
}>();
|
||||
|
||||
// Start with null to prevent SSR flash - banner only renders after client check
|
||||
const consentGiven = ref<boolean | null>(null);
|
||||
|
||||
// Check localStorage only on client
|
||||
onMounted(() => {
|
||||
consentGiven.value = !!localStorage.getItem(COOKIE_CONSENT_KEY);
|
||||
});
|
||||
|
||||
function acceptCookies() {
|
||||
localStorage.setItem(COOKIE_CONSENT_KEY, new Date().toISOString());
|
||||
consentGiven.value = true;
|
||||
emit("consent-given");
|
||||
}
|
||||
</script>
|
||||
62
components/FeatureModule.vue
Normal file
@@ -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
@@ -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
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<header class="text-gray-900 z-50" :class="{ 'bg-white bg-opacity-95 absolute shadow-xl top-0 left-0 right-0 z-[99999]': isDarkMode }">
|
||||
<div class="xl:container mx-auto lg:text-lg">
|
||||
<div class="flex flex-col lg:flex-row lg:gap-12 relative">
|
||||
<div class="flex lg:w-1/4 justify-between">
|
||||
<NuxtLink to="/" class="max-sm:sticky max-sm:top-0 p-6 py-6 lg:py-7 hover:text-red-600" @click="trackEvent('header-logo-clicked')">
|
||||
<span class="tracking-wide text-3xl font-bebas leading-none block">
|
||||
<span class="!text-black">MUELLERPRINTS</span>.<br />Paperwork
|
||||
</span>
|
||||
</NuxtLink>
|
||||
|
||||
<button @click="toggleMobileMenu()" class="p-6 py-6 lg:py-7 lg:hidden" :class="{ 'bg-white': !isDarkMode }" aria-label="Menü öffnen" :aria-expanded="isMobileMenuOpen">
|
||||
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16m-7 6h7"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="route.name"
|
||||
role="navigation"
|
||||
class="grow lg:w-3/4 p-6 py-6 lg:py-7 hidden lg:block bg-transparent"
|
||||
:class="{ '!block bg-white': isMobileMenuOpen, 'text-gray-900': isMobileMenuOpen && isDarkMode }"
|
||||
>
|
||||
<nav class="flex justify-end" v-if="isCheckoutRoute" aria-label="Navigation">
|
||||
<HeaderNavLink v-if="!isPaymentRoute" label="Zurück zum Warenkorb" path="/cart" @click="trackEvent('header-back-to-cart-clicked')" />
|
||||
</nav>
|
||||
<nav
|
||||
class="flex flex-col lg:flex-row justify-between gap-4 lg:gap-28 lg:h-full"
|
||||
v-else
|
||||
aria-label="Navigation"
|
||||
@click="closeMobileMenu()"
|
||||
>
|
||||
<div class="flex flex-col lg:flex-row gap-4 lg:gap-2">
|
||||
<HeaderNavLink
|
||||
label="Shop"
|
||||
description="Alle Notizbücher"
|
||||
path="/notebooks"
|
||||
@click="trackEvent('header-item-clicked', { label: 'Shop' })"
|
||||
/>
|
||||
<HeaderNavLink
|
||||
v-for="item in sortedCovers"
|
||||
:key="item.id"
|
||||
:label="item.label"
|
||||
:description="item.description"
|
||||
:path="`/notebooks/${item.slug}`"
|
||||
@click="trackEvent('header-item-clicked', { label: item.label })"
|
||||
/>
|
||||
<HeaderNavLink
|
||||
label="Über uns"
|
||||
description="Über MUELLERPRINTS"
|
||||
path="/about"
|
||||
@click="trackEvent('header-item-clicked', { label: 'Über uns' })"
|
||||
/>
|
||||
</div>
|
||||
<NuxtLink
|
||||
to="/cart"
|
||||
class="px-4 py-0.5 rounded-full font-medium text-lg text-black transition-all duration-200 flex items-center gap-2 hover:bg-gray-900 hover:text-white"
|
||||
active-class="!bg-gray-900 !text-white"
|
||||
title="Warenkorb"
|
||||
@click="trackEvent('header-cart-clicked')"
|
||||
>
|
||||
Warenkorb
|
||||
<span
|
||||
v-if="productsCount > 0"
|
||||
class="inline-flex items-center justify-center w-5 h-5 text-xs font-semibold rounded-full bg-gray-900 text-white group-hover:bg-white group-hover:text-gray-900"
|
||||
:class="{ '!bg-white !text-gray-900': $route.path === '/cart' }"
|
||||
>
|
||||
{{ productsCount }}
|
||||
</span>
|
||||
</NuxtLink>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { CoverNavItem } from "~/types";
|
||||
|
||||
const props = defineProps<{
|
||||
covers: CoverNavItem[];
|
||||
isDarkMode?: boolean;
|
||||
}>();
|
||||
|
||||
const route = useRoute();
|
||||
const { productsCount } = useCart();
|
||||
|
||||
const isMobileMenuOpen = ref(false);
|
||||
|
||||
const sortedCovers = computed(() => [...props.covers].sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0)));
|
||||
|
||||
const isCheckoutRoute = computed(() => String(route.name).startsWith("checkout-"));
|
||||
|
||||
const isPaymentRoute = computed(() => route.name === "checkout-3");
|
||||
|
||||
function toggleMobileMenu() {
|
||||
isMobileMenuOpen.value = !isMobileMenuOpen.value;
|
||||
}
|
||||
|
||||
function closeMobileMenu() {
|
||||
isMobileMenuOpen.value = false;
|
||||
}
|
||||
</script>
|
||||
21
components/HeaderNavLink.vue
Normal file
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<label
|
||||
class="focus-within:ring-4 focus-within:ring-offset-2 ring-gray-500 relative w-full border border-gray-500 rounded-md cursor-text h-12 overflow-hidden"
|
||||
>
|
||||
<span :class="spanClasses" class="absolute left-2 top-0 transition-all select-none">{{ label }}</span>
|
||||
<input
|
||||
:value="modelValue"
|
||||
@input="update(($event.target as HTMLInputElement).value)"
|
||||
:required="required"
|
||||
class="outline-none w-full h-full rounded-sm pt-4 p-2 border-transparent"
|
||||
placeholder=""
|
||||
:autocomplete="autocomplete"
|
||||
:aria-label="label"
|
||||
@focus="inputFocused = true"
|
||||
@blur="inputFocused = false"
|
||||
/>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
type Autocomplete =
|
||||
| "given-name"
|
||||
| "family-name"
|
||||
| "email"
|
||||
| "tel"
|
||||
| "street-address"
|
||||
| "postal-code"
|
||||
| "country-name"
|
||||
| "off"
|
||||
| "on"
|
||||
| "name"
|
||||
| "address-level2"
|
||||
| "country";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue?: string;
|
||||
label?: string;
|
||||
required?: boolean;
|
||||
labelClass?: string;
|
||||
inputClass?: string;
|
||||
autocomplete?: Autocomplete;
|
||||
}>(),
|
||||
{
|
||||
autocomplete: "off"
|
||||
}
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [value: string];
|
||||
}>();
|
||||
|
||||
const inputFocused = ref(false);
|
||||
|
||||
const spanClasses = computed(() => ({
|
||||
"text-md pt-3": !inputFocused.value && !props.modelValue,
|
||||
"text-xs pt-[3px]": inputFocused.value || props.modelValue,
|
||||
"text-gray-500": !inputFocused.value && !props.modelValue,
|
||||
"text-gray-400": inputFocused.value || props.modelValue
|
||||
}));
|
||||
|
||||
function update(newValue?: string) {
|
||||
if (newValue !== undefined) {
|
||||
emit("update:modelValue", newValue);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
9
components/LoadingSpinner.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<div class="flex justify-center items-center">
|
||||
<div class="w-20 h-20">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 50" class="animate-spin">
|
||||
<circle cx="25" cy="25" r="20" fill="none" stroke="currentColor" stroke-width="4" stroke-dasharray="60 100" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
59
components/LocationMap.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Address -->
|
||||
<div v-if="showAddress" class="flex items-start gap-3">
|
||||
<IconMapPin class="w-6 h-6 mt-1 text-gray-600 flex-shrink-0" />
|
||||
<div>
|
||||
<p class="font-semibold text-lg">{{ address.name }}</p>
|
||||
<p>{{ address.street }}</p>
|
||||
<p>{{ address.postalCode }} {{ address.city }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OpenStreetMap Embed -->
|
||||
<div class="relative w-full aspect-video rounded-xl overflow-hidden shadow-lg">
|
||||
<iframe
|
||||
:src="mapUrl"
|
||||
width="100%"
|
||||
height="100%"
|
||||
style="border: 0"
|
||||
allowfullscreen=""
|
||||
loading="lazy"
|
||||
class="absolute inset-0"
|
||||
></iframe>
|
||||
</div>
|
||||
|
||||
<!-- Directions Link -->
|
||||
<a :href="directionsUrl" target="_blank" rel="noopener noreferrer" class="inline-flex items-center gap-2 text-gray-900 font-medium hover:underline">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
Route planen
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconMapPin from "~/components/icons/IconMapPin.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
address: {
|
||||
name: string;
|
||||
street: string;
|
||||
postalCode: string;
|
||||
city: string;
|
||||
};
|
||||
showAddress?: boolean;
|
||||
}>();
|
||||
|
||||
// OpenStreetMap embed — hardcoded coordinates for Rotenbergstraße 39, 70190 Stuttgart
|
||||
const lat = 48.7862;
|
||||
const lon = 9.2;
|
||||
const bbox = `${lon - 0.005},${lat - 0.0025},${lon + 0.005},${lat + 0.0025}`;
|
||||
|
||||
const mapUrl = `https://www.openstreetmap.org/export/embed.html?bbox=${bbox}&layer=mapnik&marker=${lat},${lon}`;
|
||||
|
||||
const encodedAddress = computed(() => encodeURIComponent(`${props.address.street}, ${props.address.postalCode} ${props.address.city}`));
|
||||
const directionsUrl = computed(() => `https://www.openstreetmap.org/directions?to=${lat},${lon}#map=16/${lat}/${lon}`);
|
||||
</script>
|
||||
16
components/OpeningHours.vue
Normal file
@@ -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
@@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<section class="relative bg-gray-900 text-white py-20 lg:py-32">
|
||||
<div v-if="image" class="absolute inset-0 opacity-30">
|
||||
<img :src="image" :alt="title" class="w-full h-full object-cover" />
|
||||
</div>
|
||||
<div class="relative xl:container mx-auto px-6">
|
||||
<h1 class="text-4xl lg:text-5xl font-bold mb-4">{{ title }}</h1>
|
||||
<p v-if="subtitle" class="text-xl lg:text-2xl text-gray-300 max-w-2xl">{{ subtitle }}</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
image?: string;
|
||||
}>();
|
||||
</script>
|
||||
51
components/PageSection.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<section class="py-12 lg:py-16" :class="bgClass">
|
||||
<div class="xl:container mx-auto px-6">
|
||||
<h2 v-if="title" class="text-2xl lg:text-3xl font-bold mb-6">{{ title }}</h2>
|
||||
<div class="prose prose-lg max-w-none" v-html="renderedContent"></div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { marked } from "marked";
|
||||
|
||||
const props = defineProps<{
|
||||
title?: string;
|
||||
content: string;
|
||||
variant?: "white" | "gray";
|
||||
}>();
|
||||
|
||||
const renderedContent = computed(() => marked(props.content));
|
||||
const bgClass = computed(() => (props.variant === "gray" ? "bg-gray-50" : "bg-white"));
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.prose :deep(p) {
|
||||
@apply mb-4;
|
||||
}
|
||||
|
||||
.prose :deep(strong) {
|
||||
@apply font-semibold;
|
||||
}
|
||||
|
||||
.prose :deep(a) {
|
||||
@apply text-gray-900 underline underline-offset-2 hover:text-gray-600;
|
||||
}
|
||||
|
||||
.prose :deep(ul) {
|
||||
@apply list-disc pl-6 mb-4;
|
||||
}
|
||||
|
||||
.prose :deep(ol) {
|
||||
@apply list-decimal pl-6 mb-4;
|
||||
}
|
||||
|
||||
.prose :deep(h2) {
|
||||
@apply text-2xl font-bold mt-8 mb-4;
|
||||
}
|
||||
|
||||
.prose :deep(h3) {
|
||||
@apply text-xl font-semibold mt-6 mb-3;
|
||||
}
|
||||
</style>
|
||||
98
components/ProductCard.vue
Normal file
@@ -0,0 +1,98 @@
|
||||
<template>
|
||||
<a
|
||||
:href="product.slug ? `/details/${product.slug}` : undefined"
|
||||
class="cursor-pointer w-full gap-2 flex flex-col items-stretch group hover:scale-105 transition-all z-100"
|
||||
@click="handleProductClick"
|
||||
>
|
||||
<div
|
||||
v-if="variant === 'neutral'"
|
||||
class="p-12 flex items-center justify-center rounded-lg relative"
|
||||
>
|
||||
<div class="absolute bottom-5 drop-shadow-sm text-gray-800 text-md">{{ product.pattern?.name }}</div>
|
||||
<img
|
||||
v-if="product.images?.images"
|
||||
:src="product.images?.images[0].formats.small.url"
|
||||
:alt="product.name"
|
||||
class="lg:w-full object-cover max-h-64 lg:max-h-fit" loading="lazy"
|
||||
/>
|
||||
<div v-else class="bg-black opacity-5 shadow-sm lg:w-full object-cover min-h-48 lg:max-h-fit"></div>
|
||||
</div>
|
||||
<Background
|
||||
v-else
|
||||
:coverId="product.cover?.id"
|
||||
:patternId="product.pattern?.id"
|
||||
:shade="200"
|
||||
:gradient="true"
|
||||
:class="[...shadowColors]"
|
||||
class="p-12 flex items-center justify-center rounded-lg shadow-none group-hover:shadow-2xl transition-all duration-300 relative"
|
||||
>
|
||||
<div class="absolute bottom-5 drop-shadow-sm text-gray-800 text-md">{{ product.pattern?.name }}</div>
|
||||
<img
|
||||
v-if="product.images?.images"
|
||||
:src="product.images?.images[0].formats.small.url"
|
||||
:alt="product.name"
|
||||
class="lg:w-full object-cover max-h-64 lg:max-h-fit" loading="lazy"
|
||||
/>
|
||||
<div v-else class="bg-black opacity-5 shadow-sm lg:w-full object-cover min-h-48 lg:max-h-fit"></div>
|
||||
</Background>
|
||||
<div class="flex flex-col justify-between">
|
||||
<!-- Product Options Pills -->
|
||||
<div v-if="hasProductOptions" class="pt-4 pb-1 flex flex-wrap gap-1">
|
||||
<span v-if="product.cover?.name" class="text-xs px-2 py-1 bg-gray-100 rounded-full text-gray-600">{{ product.cover.name }}</span>
|
||||
<span v-if="product.ruling?.name" class="text-xs px-2 py-1 bg-gray-100 rounded-full text-gray-600">{{ product.ruling.name }}</span>
|
||||
<span v-if="product.pages?.name" class="text-xs px-2 py-1 bg-gray-100 rounded-full text-gray-600">{{ product.pages.name }}</span>
|
||||
</div>
|
||||
<!-- Price -->
|
||||
<div v-if="product.totalProductPrice" class="px-2 py-2">
|
||||
<p class="text-gray-600 text-xs text-left">ab {{ numberFormatter(product.totalProductPrice, "€") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { randomTailwindColor } from "~/utils/randomTailwindColor";
|
||||
import { numberFormatter } from "~/utils/numberFormatter";
|
||||
import { trackEvent } from "~/utils/trackEvent";
|
||||
import type { Product } from "~/types";
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
product: Product;
|
||||
variant?: "default" | "neutral";
|
||||
}>(), {
|
||||
variant: "default",
|
||||
});
|
||||
|
||||
const shadowColors = computed(() => {
|
||||
return (
|
||||
props.product?.pattern && [
|
||||
randomTailwindColor(props.product.pattern.id, "group-hover:shadow", 200, "/60")
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
const hasProductOptions = computed(() => {
|
||||
return props.product?.cover || props.product?.ruling || props.product?.pages;
|
||||
});
|
||||
|
||||
function handlePersonalizedProductClick() {
|
||||
const subject = `Anfrage für personalisiertes Produkt: ${props.product.cover?.name}`;
|
||||
const body = `Hallo,\n\nich interessiere mich für ein personalisiertes Produkt: ${props.product.cover?.name}\n\nBitte kontaktieren Sie mich für weitere Details.\n\nVielen Dank!`;
|
||||
window.location.href = `mailto:paperwork@muellerprints.de?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
|
||||
}
|
||||
|
||||
function handleProductClick() {
|
||||
trackEvent("product-card-clicked", {
|
||||
productId: props.product.id,
|
||||
productSlug: props.product.slug,
|
||||
productName: props.product.name,
|
||||
price: props.product.totalProductPrice
|
||||
});
|
||||
|
||||
if (props.product.slug) {
|
||||
navigateTo(`/details/${props.product.slug}`);
|
||||
} else {
|
||||
handlePersonalizedProductClick();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
29
components/ProductCarousel.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<Carousel
|
||||
:gap="40"
|
||||
:items-to-show="1"
|
||||
:wrap-around="true"
|
||||
:autoplay="4200"
|
||||
:transition="600"
|
||||
:mouse-drag="false"
|
||||
:touch-drag="true"
|
||||
>
|
||||
<Slide v-for="slide in slides" :key="slide.id">
|
||||
<img :src="slide.formats?.medium?.url || slide.url" alt="Product Image" class="rounded-xl shadow-xl" />
|
||||
</Slide>
|
||||
|
||||
<template #addons>
|
||||
<Pagination />
|
||||
</template>
|
||||
</Carousel>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import "vue3-carousel/dist/carousel.css";
|
||||
import { Carousel, Slide, Pagination } from "vue3-carousel";
|
||||
import type { MediaData } from "~/types";
|
||||
|
||||
defineProps<{
|
||||
slides: MediaData[];
|
||||
}>();
|
||||
</script>
|
||||
36
components/SelectionBox.vue
Normal file
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<section class="w-full relative overflow-hidden min-h-[16rem]" :style="backgroundStyle">
|
||||
<div class="absolute inset-0 bg-gradient-to-b from-black/40 to-black/60"></div>
|
||||
|
||||
<div class="container mx-auto px-6 h-full flex flex-col items-center justify-between py-16 relative z-10">
|
||||
<!-- Top content with heading and optional subheading -->
|
||||
<div class="text-center mt-12 mb-8">
|
||||
<h2 class="font-bebas text-4xl md:text-5xl lg:text-6xl text-white tracking-tight leading-none">
|
||||
{{ title }}
|
||||
</h2>
|
||||
<p v-if="subtitle" class="mt-4 text-white/90 text-lg md:text-xl max-w-2xl mx-auto">
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
|
||||
<!-- Button after title option -->
|
||||
<div v-if="buttonPosition === 'after-title'" class="mt-8">
|
||||
<NuxtLink
|
||||
v-if="href"
|
||||
:to="href"
|
||||
class="bg-white text-gray-900 hover:bg-gray-200 hover:text-red-600 transition-colors px-8 py-3 rounded-full font-medium"
|
||||
>
|
||||
{{ buttonText }}
|
||||
</NuxtLink>
|
||||
<button
|
||||
v-else
|
||||
@click="$emit('button-click')"
|
||||
class="bg-white text-gray-900 hover:bg-gray-200 hover:text-red-600 transition-colors px-8 py-3 rounded-full font-medium"
|
||||
>
|
||||
{{ buttonText }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom button option -->
|
||||
<div v-if="buttonPosition === 'bottom'" class="mb-12">
|
||||
<NuxtLink
|
||||
v-if="href"
|
||||
:to="href"
|
||||
class="bg-white text-gray-900 hover:bg-gray-200 hover:text-red-600 transition-colors px-8 py-3 rounded-full font-medium"
|
||||
>
|
||||
{{ buttonText }}
|
||||
</NuxtLink>
|
||||
<button
|
||||
v-else
|
||||
@click="$emit('button-click')"
|
||||
class="bg-white text-gray-900 hover:bg-gray-200 hover:text-red-600 transition-colors px-8 py-3 rounded-full font-medium"
|
||||
>
|
||||
{{ buttonText }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
backgroundImage: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
buttonText?: string;
|
||||
buttonPosition?: "bottom" | "after-title";
|
||||
height?: string;
|
||||
href?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(["button-click"]);
|
||||
|
||||
const backgroundStyle = computed(() => ({
|
||||
backgroundImage: `url('${props.backgroundImage}')`,
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
height: props.height || "70vh"
|
||||
}));
|
||||
</script>
|
||||
31
components/TechnicalSpecs.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<section class="py-16 px-6 xl:container mx-auto">
|
||||
<details open class="group bg-white rounded-xl shadow-md p-6">
|
||||
<summary class="cursor-pointer flex justify-between items-center">
|
||||
<h3 class="text-2xl font-bold">Technische Details</h3>
|
||||
<svg
|
||||
class="w-5 h-5 transition-transform duration-200 group-open:rotate-180"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</summary>
|
||||
<div class="pt-6">
|
||||
<dl class="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
<div v-for="(value, label) in specs" :key="label" class="border-b border-gray-100 pb-3">
|
||||
<dt class="text-gray-500 text-sm">{{ label }}</dt>
|
||||
<dd class="font-medium text-gray-900 mt-1">{{ value }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
specs: Record<string, string>;
|
||||
}>();
|
||||
</script>
|
||||
95
components/Testimonial.vue
Normal file
@@ -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
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<section
|
||||
class="py-8"
|
||||
:class="variant === 'light' ? 'bg-white text-gray-700 border-y border-gray-200' : 'bg-gray-900 text-white'"
|
||||
>
|
||||
<div class="xl:container mx-auto px-6">
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-6 lg:gap-0">
|
||||
<div
|
||||
v-for="(pillar, index) in pillars"
|
||||
:key="pillar.title"
|
||||
class="flex items-start gap-3 lg:px-6 lg:border-l lg:first:border-l-0"
|
||||
:class="variant === 'light' ? 'lg:border-gray-300' : 'lg:border-gray-400'"
|
||||
>
|
||||
<component :is="pillar.icon" class="w-6 h-6 flex-shrink-0 opacity-60" />
|
||||
<div>
|
||||
<h4 class="font-semibold text-sm lg:text-base">{{ pillar.title }}</h4>
|
||||
<p class="text-xs lg:text-sm opacity-70 mt-1">{{ pillar.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{
|
||||
variant?: "dark" | "light";
|
||||
}>(), {
|
||||
variant: "dark",
|
||||
});
|
||||
|
||||
import IconMapPin from "~/components/icons/IconMapPin.vue";
|
||||
import IconRecycle from "~/components/icons/IconRecycle.vue";
|
||||
import IconTruck from "~/components/icons/IconTruck.vue";
|
||||
import IconShield from "~/components/icons/IconShield.vue";
|
||||
|
||||
const pillars = [
|
||||
{
|
||||
icon: IconMapPin,
|
||||
title: "Made in Stuttgart",
|
||||
description: "Handgebunden in unserer Werkstatt – keine langen Transportwege.",
|
||||
},
|
||||
{
|
||||
icon: IconRecycle,
|
||||
title: "100% Recyclingpapier",
|
||||
description: "FSC-zertifiziert, Blauer Engel, CO₂-neutral produziert.",
|
||||
},
|
||||
{
|
||||
icon: IconTruck,
|
||||
title: "Schneller Versand",
|
||||
description: "Deine Bestellung ist in 2-3 Werktagen bei Dir.",
|
||||
},
|
||||
{
|
||||
icon: IconShield,
|
||||
title: "Sicher bezahlen",
|
||||
description: "BAR, PayPal, Kreditkarte, Lastschrift – verschlüsselt & geschützt.",
|
||||
},
|
||||
];
|
||||
</script>
|
||||
41
components/UseCaseGrid.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<section class="py-16">
|
||||
<div class="xl:container mx-auto px-6">
|
||||
<div class="text-center mb-10">
|
||||
<h3 class="text-2xl font-bold mb-3">Anwendungsbereiche</h3>
|
||||
<p class="text-gray-600 max-w-2xl mx-auto">Vielseitig einsetzbar für Kreative, Studenten und Berufstätige.</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 lg:gap-6">
|
||||
<div v-for="useCase in useCases" :key="useCase.title" class="text-center p-4 lg:p-6">
|
||||
<component :is="iconComponents[useCase.icon]" class="w-8 h-8 lg:w-10 lg:h-10 mx-auto mb-3 text-gray-700" />
|
||||
<h4 class="font-semibold mb-1 lg:mb-2 text-sm lg:text-base">{{ useCase.title }}</h4>
|
||||
<p class="text-xs lg:text-sm text-gray-600">{{ useCase.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconPen from "~/components/icons/IconPen.vue";
|
||||
import IconPalette from "~/components/icons/IconPalette.vue";
|
||||
import IconClipboard from "~/components/icons/IconClipboard.vue";
|
||||
import IconBriefcase from "~/components/icons/IconBriefcase.vue";
|
||||
|
||||
export interface UseCase {
|
||||
icon: "pen" | "palette" | "clipboard" | "briefcase";
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
useCases: UseCase[];
|
||||
}>();
|
||||
|
||||
const iconComponents = {
|
||||
pen: IconPen,
|
||||
palette: IconPalette,
|
||||
clipboard: IconClipboard,
|
||||
briefcase: IconBriefcase,
|
||||
};
|
||||
</script>
|
||||
9
components/icons/IconBriefcase.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M20.25 14.15v4.25c0 1.094-.787 2.036-1.872 2.18-2.087.277-4.216.42-6.378.42s-4.291-.143-6.378-.42c-1.085-.144-1.872-1.086-1.872-2.18v-4.25m16.5 0a2.18 2.18 0 0 0 .75-1.661V8.706c0-1.081-.768-2.015-1.837-2.175a48.114 48.114 0 0 0-3.413-.387m4.5 8.006c-.194.165-.42.295-.673.38A23.978 23.978 0 0 1 12 15.75c-2.648 0-5.195-.429-7.577-1.22a2.016 2.016 0 0 1-.673-.38m0 0A2.18 2.18 0 0 1 3 12.489V8.706c0-1.081.768-2.015 1.837-2.175a48.111 48.111 0 0 1 3.413-.387m7.5 0V5.25A2.25 2.25 0 0 0 13.5 3h-3a2.25 2.25 0 0 0-2.25 2.25v.894m7.5 0a48.667 48.667 0 0 0-7.5 0M12 12.75h.008v.008H12v-.008Z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
9
components/icons/IconClipboard.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 0 0 2.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 0 0-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75 2.25 2.25 0 0 0-.1-.664m-5.8 0A2.251 2.251 0 0 1 13.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25ZM6.75 12h.008v.008H6.75V12Zm0 3h.008v.008H6.75V15Zm0 3h.008v.008H6.75V18Z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
14
components/icons/IconMapPin.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1 1 15 0Z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
9
components/icons/IconPalette.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M4.098 19.902a3.75 3.75 0 0 0 5.304 0l6.401-6.402M6.75 21A3.75 3.75 0 0 1 3 17.25V4.125C3 3.504 3.504 3 4.125 3h5.25c.621 0 1.125.504 1.125 1.125v4.072M6.75 21a3.75 3.75 0 0 0 3.75-3.75V8.197M6.75 21h13.125c.621 0 1.125-.504 1.125-1.125v-5.25c0-.621-.504-1.125-1.125-1.125h-4.072M10.5 8.197l2.88-2.88c.438-.439 1.15-.439 1.59 0l3.712 3.713c.44.44.44 1.152 0 1.59l-2.879 2.88M6.75 17.25h.008v.008H6.75v-.008Z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
9
components/icons/IconPen.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
9
components/icons/IconRecycle.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M19.5 12c0-1.232-.046-2.453-.138-3.662a4.006 4.006 0 0 0-3.7-3.7 48.678 48.678 0 0 0-7.324 0 4.006 4.006 0 0 0-3.7 3.7c-.017.22-.032.441-.046.662M19.5 12l3-3m-3 3-3-3m-12 3c0 1.232.046 2.453.138 3.662a4.006 4.006 0 0 0 3.7 3.7 48.656 48.656 0 0 0 7.324 0 4.006 4.006 0 0 0 3.7-3.7c.017-.22.032-.441.046-.662M4.5 12l3 3m-3-3-3 3"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
9
components/icons/IconShield.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M9 12.75 11.25 15 15 9.75m-3-7.036A11.959 11.959 0 0 1 3.598 6 11.99 11.99 0 0 0 3 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285Z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
9
components/icons/IconTruck.vue
Normal file
@@ -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
@@ -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
|
||||
};
|
||||
}
|
||||
287
composables/usePageContent.ts
Normal 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";
|
||||
369
composables/useProductContent.ts
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
34
package.json
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
// Redirect /checkout to /checkout/1
|
||||
definePageMeta({
|
||||
redirect: "/checkout/1"
|
||||
});
|
||||
|
||||
navigateTo("/checkout/1", { replace: true });
|
||||
</script>
|
||||
236
pages/checkout/result/[uuid].vue
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
After Width: | Height: | Size: 921 B |
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
public/images/features/hardcover/cover-pattern.jpg
Normal file
|
After Width: | Height: | Size: 583 KiB |
BIN
public/images/features/softcover/workshop.png
Normal file
|
After Width: | Height: | Size: 410 KiB |
BIN
public/images/features/spiral/paper-texture.jpg
Normal file
|
After Width: | Height: | Size: 583 KiB |
BIN
public/images/features/spiral/tear-out.jpg
Normal file
|
After Width: | Height: | Size: 491 KiB |
BIN
public/images/features/spiral/workshop.jpg
Normal file
|
After Width: | Height: | Size: 828 KiB |
BIN
public/images/production/01.png
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
public/images/production/02.png
Normal file
|
After Width: | Height: | Size: 825 KiB |
BIN
public/images/production/03.png
Normal file
|
After Width: | Height: | Size: 607 KiB |
BIN
public/images/production/04.png
Normal file
|
After Width: | Height: | Size: 410 KiB |
BIN
public/paperwork-logo.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
2
public/robots.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
User-Agent: *
|
||||
Disallow:
|
||||
172
server/api/__sitemap__/urls.ts
Normal 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;
|
||||
});
|
||||
55
server/api/contact.post.ts
Normal 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",
|
||||
});
|
||||
}
|
||||
});
|
||||