13 Commits
Author SHA1 Message Date
libretech 0561f7ce94 ci: run the unit tests; build pull requests, publish only release tags
build / test (pull_request) Successful in 11s
build / build (pull_request) Successful in 1m1s
As libreshop/cms adopted: a test job pipes the source into the image's base
(the Dockerfile's first FROM, node:22-slim) and runs npm test there without
network. The image build needs it and runs for pull requests (build only)
and v* tags (published if PUBLISH_ENABLED). main no longer publishes :main
and :sha-* images; mp pins the shop image by version tag.

Refs libretech/mp#71
2026-10-09 02:33:17 +02:00
libretech 6c41faf36a fix(contact): send the contact form through the mail service's /v1/send/message
POST /api/contact posted { to, subject, body, replyTo } to ${mailApiUrl}/send,
which the mail service (libreshop/mail src/app.py) does not have, so every
message failed with a 500. It now posts { to_email, subject, message } to
POST /v1/send/message, still to paperwork@muellerprints.de, with the same
subject and text as before. The service sets no Reply-To; the sender's
address stays in the text. A line break in the subject is replaced by a
space, so it cannot start another mail header.

mailApiUrl was read from runtimeConfig without being declared there, so
NUXT_MAIL_API_URL could not set it and the fallback http://mail:2222 was
always used. It is declared now, with that default; mp's compose.yml sets
no NUXT_MAIL_API_URL.

contactMail and mailSendUrl (server/utils/contactMail.ts) are pure and tested.

Refs libretech/mp#71
2026-10-09 02:32:44 +02:00
libretech 6f434c21ba fix(checkout): take a repeated capture as a successful payment
The CMS answers a capture of an order already paid with this PayPal order
with { success: true, alreadyCaptured: true } instead of the order. Step 3
took that answer for a capture without authorisation and showed a payment
error. It now reloads the paid order (keeping the shown one if the reload
fails) and continues as after a first capture: confirmation, the
checkout-payment-completed event, and the redirect to the order's result
page. capturePayment is typed as Order | AlreadyCaptured, and the pure
isAlreadyCaptured (utils/captureResponse.ts) tells them apart.

Refs libretech/mp#71
2026-10-09 02:32:38 +02:00
libretech 8931afc670 feat(checkout): show a German message when a checkout step fails
checkoutErrorMessage (utils/checkoutError.ts) maps the status and body of a
failed request, the shop's answer or a raw CMS (Strapi) error, to one fixed
message for the customer, addressed with "du": an invalid e-mail address
(when data.email is among the rejected fields), other invalid input, an order
already paid, a product no longer available, an order not ready for checkout
(naming the missing steps), a payment that does not fit the order or an order
changed during the payment ("Bitte starte die Zahlung neu"), PayPal not
reachable ("versuche es in ein paar Minuten noch einmal"), a payment PayPal
may have taken that the CMS could not confirm or record ("Bitte bezahle nicht
noch einmal"), and a general fallback. It never shows the server's text.

Steps 1 and 2 show the message above their submit button, in the style of
step 3's payment error, instead of logging the error only. Step 3 shows it
for a failed PayPal order creation or capture. The e-mail input of step 1 is
type="email" (Input.vue takes a type).

Refs libretech/mp#71
2026-10-09 02:32:32 +02:00
libretech bfcbbfac39 fix(orders): forward only the checkout's fields on an order update
PUT /api/orders/:uuid forwarded the browser's body to the CMS unchanged. It
now forwards { data } with only the seven fields of the checkout's steps:
email, acceptedTermsAndConditionsAt, invoiceAddress, deliveryAddress,
invoiceAddressStructured, deliveryAddressStructured and delivery. Any other
field, a field beside data, or a body of another shape is answered 400
"Invalid order update" with the CMS's error format, without calling the
CMS. The values are left to the CMS, which checks them and stays the
authority; this is defence in depth.

pickCustomerUpdate (server/utils/customerUpdate.ts) is pure and tested with
the exact payloads of steps 1 and 2 and with every server-only attribute of
the order.

Refs libretech/mp#71
2026-10-09 02:32:20 +02:00
libretech 5c773a99e6 feat(orders): pass the CMS's status and safe error fields to the browser
The order routes (get, put, add-product, remove-product, checkout, capture)
call forwardToCms, which throws a CMS error on as
createError({ statusCode, statusMessage, data: { message, errors?, missing? } }),
built by the pure shopErrorFromCms (server/utils/cmsError.ts).

Before, the FetchError was thrown on as it was: the browser got the CMS's
status, but Nitro treated it as unhandled, answered "Server Error" without
data and logged every CMS 4xx as [unhandled]. Now the browser also gets the
CMS's message, the errors of a rejected update and the fields a checkout
misses, and no other field. A status that is the shop's own fault (401, 403,
...) is answered 500, a CMS that does not answer 503; 5xx are logged without
the query and the order uuid.

npm test runs tests/unit with Node's type stripping and no dependencies, as
in libreshop/cms. nuxt.config keeps tests/ out of the app's type check.

Refs libretech/mp#71
2026-10-09 02:32:14 +02:00
libretech 61a74e649b feat(selection): lock single-option boxes, drop the about link
build / build (push) Successful in 1m38s
Recovered work in progress that sat uncommitted since 2026-05: a
SelectionBox renders as a plain div instead of a NuxtLink when locked,
so a variant with only one choice reads as decided rather than
clickable, and it reports aria-checked in that state. useProductContent
gained the content lookups the details and index pages now use, the
cookie banner says Schließen instead of Akzeptieren, the Über uns
header entry is gone, and ProductCard's hover shadow no longer relies
on a short-circuit that could yield a non-array.
2026-09-23 22:09:52 +02:00
libretech e9cd3d9c20 ci: bump runner-image pin v0.1.0 → v0.2.0 (#15)
build / build (push) Successful in 1m18s
Co-authored-by: Michael Czechowski <mail@dailysh.it>
Co-committed-by: Michael Czechowski <mail@dailysh.it>
2026-05-04 23:46:04 +02:00
libretech cfe37519bd fix(ci): correct image digest separator
build / build (push) Successful in 1m6s
2026-04-30 12:20:40 +02:00
libretech 60ab9dcc23 ci: digest-pin runner-image v0.1.0
build / build (push) Failing after 0s
2026-04-30 12:18:04 +02:00
libretech 4fe5b9043d ci: update runner-image path to public/ namespace
build / build (push) Failing after 1s
2026-04-30 11:57:33 +02:00
libretech f8d4c6a47d chore: update action ref to public/actions
build / build (push) Successful in 1m28s
2026-04-29 22:51:08 +02:00
libretech ed6771e03a chore: slim build.yml to use shared docker-build action (#1)
build / build (push) Successful in 1m22s
2026-04-29 22:08:36 +02:00
36 changed files with 1285 additions and 177 deletions
+27 -38
View File
@@ -1,4 +1,4 @@
name: Build and publish
name: build
on:
push:
@@ -7,49 +7,38 @@ on:
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:
test:
runs-on: ubuntu-latest
container:
image: git.librete.ch/libretech/runner-image:v1
image: git.librete.ch/public/runner-image:v0.2.0@sha256:f60c587d3c0b0aac04a572db5349e27672bf76baec2ce547a3dcc28cebcf1b7e
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
# The tests need no dependencies. They run in the image's base (Dockerfile FROM), so on the runtime's Node,
# without network. The source is piped in because the job container's paths do not exist on the Docker host.
- name: npm test, on the Node of the image
run: |
base=$(sed -n 's/^FROM \([^ ]*\).*/\1/p' Dockerfile | head -1)
tar -c --exclude=.git . | docker run -i --rm --network none -e npm_config_update_notifier=false "$base" \
sh -c 'mkdir /w && cd /w && tar -x && npm test'
# The image is built to check a pull request, and built and published only for a release tag.
# Nothing pulls per-commit images, so main no longer publishes :main and :sha-* images.
build:
needs: test
if: github.event_name == 'pull_request' || startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
container:
image: git.librete.ch/public/runner-image:v0.2.0@sha256:f60c587d3c0b0aac04a572db5349e27672bf76baec2ce547a3dcc28cebcf1b7e
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
- uses: https://git.librete.ch/public/actions/.gitea/actions/docker-build@main
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 }}
registry_user: ${{ secrets.REGISTRY_USER }}
registry_pass: ${{ secrets.REGISTRY_PASS }}
publish: ${{ startsWith(github.ref, 'refs/tags/v') && vars.PUBLISH_ENABLED == 'true' }}
+7 -2
View File
@@ -3,8 +3,8 @@
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.
published at `git.librete.ch/libreshop/shop` on `v*` tags; pull requests
build it without publishing.
## Source
@@ -18,6 +18,11 @@ pulls `git.librete.ch/libreshop/shop:<pin>` instead of building locally.
docker build -t libreshop/shop:dev .
```
## Test
`npm test` runs the unit tests in `tests/unit/` on Node 22.6 or later, without
installing dependencies.
## Adapter contract
See `docker-entrypoint.sh` and `Dockerfile` for the runtime surface.
+1 -1
View File
@@ -16,7 +16,7 @@
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
Schließen
</Button>
</div>
</div>
-6
View File
@@ -45,12 +45,6 @@
: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"
+4 -1
View File
@@ -5,6 +5,7 @@
<span :class="spanClasses" class="absolute left-2 top-0 transition-all select-none">{{ label }}</span>
<input
:value="modelValue"
:type="type"
@input="update(($event.target as HTMLInputElement).value)"
:required="required"
class="outline-none w-full h-full rounded-sm pt-4 p-2 border-transparent"
@@ -40,9 +41,11 @@ const props = withDefaults(
labelClass?: string;
inputClass?: string;
autocomplete?: Autocomplete;
type?: "text" | "email";
}>(),
{
autocomplete: "off"
autocomplete: "off",
type: "text"
}
);
+3 -5
View File
@@ -64,11 +64,9 @@ const props = withDefaults(defineProps<{
});
const shadowColors = computed(() => {
return (
props.product?.pattern && [
randomTailwindColor(props.product.pattern.id, "group-hover:shadow", 200, "/60")
]
);
return props.product?.pattern
? [randomTailwindColor(props.product.pattern.id, "group-hover:shadow", 200, "/60")]
: [];
});
const hasProductOptions = computed(() => {
+13 -7
View File
@@ -1,12 +1,14 @@
<template>
<NuxtLink
:to="path"
<component
:is="locked ? 'div' : NuxtLink"
:to="locked ? undefined : path"
:title="ariaLabel"
:aria-label="ariaLabel"
:aria-checked="isActive"
:aria-checked="isActive || locked"
:class="{
'border-black bg-white': isActive,
'border-transparent cursor-pointer bg-gray-100 opacity-80 hover:opacity-100 hover:border-gray-400 pointer': !isActive,
'border-black bg-white': isActive || locked,
'cursor-default': locked,
'border-transparent cursor-pointer bg-gray-100 opacity-80 hover:opacity-100 hover:border-gray-400 pointer': !isActive && !locked,
'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"
@@ -17,20 +19,24 @@
<div
class="text-sm text-gray-800 pointer-events-none"
:class="{
'font-semibold': isActive
'font-semibold': isActive || locked
}"
>
{{ label }}
</div>
</NuxtLink>
</component>
</template>
<script setup lang="ts">
import { NuxtLink } from "#components";
defineProps<{
label: string;
path: string;
isActive?: boolean;
ariaLabel?: string;
ariaDisabled?: boolean;
// Pre-selected, non-interactive (single-option / fixed products)
locked?: boolean;
}>();
</script>
+53 -4
View File
@@ -1,7 +1,7 @@
// 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 type CoverType = "Hardcover" | "Softcover" | "Heft" | "Spiralbindung" | "Promo";
export interface FeatureModule {
eyebrow?: string;
@@ -199,6 +199,44 @@ export const FEATURE_MODULES: Record<CoverType, FeatureModule[]> = {
imageRight: true,
},
],
Promo: [
{
eyebrow: "12 Wochen · 3 Monate",
headline: "Struktur für deinen Alltag",
subtitle: "Planung, Intention & Reflexion",
body: `Dieses Journaling Booklet im Format A5 sorgt für mehr Produktivität im Alltag.
Es beinhaltet Monats-, Wochen- und Tagesplanungen sowie inspirierenden Zitaten
für insgesamt 12 Wochen bzw. 3 Monate. Jeder Tag bietet Platz für eine
Tagesintention, den täglichen Aufgaben, dem Notieren von Mahlzeiten und der
Fitnessroutine. Darüber hinaus bietet es die Möglichkeit der eigene
Tagesreflexion. Als tägliche Begleiter soll es helfen, deine Zeit effektiv zu
managen, um mit mehr Klarheit und Struktur deine Ziele anzugehen.`,
imageRight: true,
},
{
eyebrow: "Gestaltet von mk rietzl",
headline: "VIVUS 89 Recyclingpapier",
subtitle: "Matt, 120g/qm, fliederfarbener Umschlag",
body: `Das Journaling Booklet wurde konzipiert und gestaltet von Michèle Rietzl
(mk rietzl). Es ist auf VIVUS 89 Recyclingpapier gedruckt. 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. Der fliederfarbene
Umschlag sorgt für schlichte Farbakzente.`,
imageRight: false,
},
{
eyebrow: "Handgefertigt in Stuttgart",
headline: "Fadenbindung mit offenem Buchrücken",
subtitle: "Vollständiges Aufklappen zum Schreiben",
body: `Bei der Bindung handelt es sich um eine klassische Fadenbindung mit offenem
Buchrücken. Dies ermöglicht ein vollständiges Aufklappen, was sich ideal zum
Schreiben eignet. Die Booklets werden mit größter Sorgfalt in Stuttgart
produziert. Jedes Exemplar ist handgefertigt.`,
imageRight: true,
},
],
};
// =============================================================================
@@ -244,6 +282,15 @@ export const TECHNICAL_SPECS: Record<CoverType, Record<string, string>> = {
Zertifizierung: "FSC® C018175, Blauer Engel, EU Ecolabel",
Herstellung: "Stuttgart, Deutschland",
},
Promo: {
Format: "150 × 210 mm (A5)",
Umfang: "12 Wochen / 3 Monate",
Layout: "Vorgedruckt (Tages-, Wochen- & Monatsplanung)",
Papier: "VIVUS 89, 120g/qm, 1,2-faches Volumen, ungestrichen matt",
Einband: "Fliederfarbener Softcover",
Bindung: "Fadenbindung mit offenem Buchrücken",
Herstellung: "Handgefertigt in Stuttgart",
},
};
// =============================================================================
@@ -284,6 +331,8 @@ export function getCoverType(coverName?: string): CoverType {
if (name.includes("hardcover")) return "Hardcover";
if (name.includes("softcover")) return "Softcover";
if (name.includes("spiral") || name.includes("wire")) return "Spiralbindung";
// Standalone promo bucket (cover named "Promo").
if (name.includes("promo")) return "Promo";
return "Hardcover"; // fallback
}
@@ -314,10 +363,10 @@ export function useProductContent(
derivedSpecs["Format"] = prod.cover.copyText.format;
}
// Seitenanzahl from pages
if (prod.pages?.name) {
// Seitenanzahl from pages (skip for Promo/standalone — page count not relevant)
if (prod.pages?.name && coverType.value !== "Promo") {
derivedSpecs["Seitenanzahl"] = prod.pages.name;
} else if (prod.pages?.count) {
} else if (prod.pages?.count && coverType.value !== "Promo") {
const sheets = Math.floor(prod.pages.count / 2);
derivedSpecs["Seitenanzahl"] = `${prod.pages.count} Seiten (${sheets} Blatt)`;
}
+4 -2
View File
@@ -9,6 +9,7 @@ import type {
PaymentMethod,
ApiResponse
} from "~/types";
import type { AlreadyCaptured } from "~/utils/captureResponse";
/**
* Shop API composable.
@@ -116,8 +117,9 @@ export function useShopApi() {
});
},
async capturePayment(uuid: string, paypalOrderId: string): Promise<Order> {
return await $fetch<Order>(`/api/orders/${uuid}/capture/${paypalOrderId}`, {
/** The paid order, or { success: true, alreadyCaptured: true } when an earlier capture of this PayPal order went through. */
async capturePayment(uuid: string, paypalOrderId: string): Promise<Order | AlreadyCaptured> {
return await $fetch<Order | AlreadyCaptured>(`/api/orders/${uuid}/capture/${paypalOrderId}`, {
method: "POST"
});
},
+10 -2
View File
@@ -29,6 +29,7 @@ export default defineNuxtConfig({
// Server-only (private) - never exposed to client
shopApiToken: "", // NUXT_SHOP_API_TOKEN
cmsInternalUrl: "http://cms:5555", // NUXT_CMS_INTERNAL_URL - internal docker network URL
mailApiUrl: "http://mail:2222", // NUXT_MAIL_API_URL - mail service (libreshop/mail) on the internal docker network
siteUrl: "https://muellerprints-paperwork.com", // NUXT_SITE_URL - for sitemap
// Public (client + server)
@@ -45,7 +46,10 @@ export default defineNuxtConfig({
routeRules: {
// Cart & Checkout: Client-only (no SSR)
"/cart": { ssr: false },
"/checkout/**": { ssr: false }
"/checkout/**": { ssr: false },
// Local dev: proxy CMS media. In prod nginx routes /uploads → cms;
// without it the browser would hit the Nuxt origin and 404.
"/uploads/**": { proxy: "http://cms:5555/uploads/**" }
// All other pages use default SSR
},
@@ -69,7 +73,11 @@ export default defineNuxtConfig({
// TypeScript
typescript: {
strict: true
strict: true,
// The unit tests run on Node (npm test), not in the app: keep them out of the app's type check (.nuxt/tsconfig.app.json).
tsConfig: {
exclude: ["../tests/**/*"]
}
},
// Tailwind
+2 -1
View File
@@ -7,7 +7,8 @@
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare"
"postinstall": "nuxt prepare",
"test": "node --experimental-strip-types --import ./tests/unit/support/register.mjs --test 'tests/unit/**/*.test.ts'"
},
"dependencies": {
"@headlessui/vue": "^1.7.23",
+9 -1
View File
@@ -29,7 +29,7 @@
<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" />
<Input label="E-Mail-Adresse" v-model="emailAddress" type="email" :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" />
@@ -44,6 +44,10 @@
<hr />
<div v-if="errorMessage" class="p-4 lg:p-8 text-center mx-auto rounded-md bg-rose-100" role="alert" data-e2e="checkout-error">
<span class="text-rose-800">{{ errorMessage }}</span>
</div>
<Button type="submit" classes="w-full" :is-pending="formSubmitIsPending">Weiter zur Lieferadresse</Button>
</form>
</div>
@@ -60,6 +64,7 @@
<script setup lang="ts">
import { trackEvent } from "~/utils/trackEvent";
import { checkoutErrorMessageFor } from "~/utils/checkoutError";
const COOKIE_CONSENT_KEY = "shop:cookie-consent";
@@ -68,6 +73,7 @@ const cart = useCart();
const emailAddress = ref("");
const acceptedTermsAndConditions = ref(false);
const formSubmitIsPending = ref(false);
const errorMessage = ref("");
const uuid = computed(() => cart.uuid.value);
@@ -120,6 +126,7 @@ async function submit() {
try {
formSubmitIsPending.value = true;
errorMessage.value = "";
// Use cart.update() to sync local state after API call
await cart.update({
@@ -138,6 +145,7 @@ async function submit() {
navigateTo("/checkout/2");
} catch (error) {
console.error("Error submitting email address", error);
errorMessage.value = checkoutErrorMessageFor(error);
} finally {
formSubmitIsPending.value = false;
}
+8
View File
@@ -97,6 +97,10 @@
<hr class="my-12" />
<div v-if="errorMessage" class="p-4 lg:p-8 text-center mx-auto rounded-md bg-rose-100" role="alert" data-e2e="checkout-error">
<span class="text-rose-800">{{ errorMessage }}</span>
</div>
<div class="mt-12">
<Button classes="w-full" type="submit" :is-pending="formSubmitIsPending">Weiter zur Zahlung</Button>
</div>
@@ -110,6 +114,7 @@
import type { Address, StructuredAddress } from "~/types";
import { trackEvent } from "~/utils/trackEvent";
import { numberFormatter } from "~/utils/numberFormatter";
import { checkoutErrorMessageFor } from "~/utils/checkoutError";
const cart = useCart();
const shopApi = useShopApi();
@@ -134,6 +139,7 @@ const displayState = computed(() => {
});
const showOptionalDeliveryAddress = ref(false);
const formSubmitIsPending = ref(false);
const errorMessage = ref("");
const deliveryMethods = ref<any[]>([]);
const selectedDeliveryMethod = ref<number | null>(null);
@@ -243,6 +249,7 @@ async function submit() {
try {
formSubmitIsPending.value = true;
errorMessage.value = "";
// Use cart.update() to sync local state after API call
await cart.update({
@@ -262,6 +269,7 @@ async function submit() {
navigateTo("/checkout/3");
} catch (error) {
console.error("Error submitting address form:", error);
errorMessage.value = checkoutErrorMessageFor(error);
} finally {
formSubmitIsPending.value = false;
}
+13 -4
View File
@@ -64,7 +64,7 @@
<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>
<span class="text-rose-800">{{ paymentErrorMessage || "Es ist ein Fehler aufgetreten. Bitte versuche es erneut." }}</span>
</div>
<!-- Trust signals -->
@@ -190,6 +190,8 @@
<script setup lang="ts">
import { numberFormatter } from "~/utils/numberFormatter";
import { trackEvent } from "~/utils/trackEvent";
import { checkoutErrorMessageFor } from "~/utils/checkoutError";
import { isAlreadyCaptured } from "~/utils/captureResponse";
import type { Order } from "~/types";
const config = useRuntimeConfig();
@@ -197,6 +199,7 @@ const shopApi = useShopApi();
const CART_UUID_KEY = "shop:cart";
const hasPaymentError = ref(false);
const paymentErrorMessage = ref("");
const hasOrderError = ref(false);
const hasAuthorisedPayment = ref(false);
const orderData = ref<Order>({} as Order);
@@ -298,6 +301,7 @@ async function initializePayPalButtons() {
return response.id;
} catch (error) {
console.error("Error creating PayPal order:", error);
paymentErrorMessage.value = checkoutErrorMessageFor(error);
hasPaymentError.value = true;
trackEvent("checkout-payment-error", {
stage: "create-order",
@@ -309,10 +313,14 @@ async function initializePayPalButtons() {
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!);
const captured = await shopApi.capturePayment(orderData.value.uuid, data.orderID!);
if (capturedOrder.paymentAuthorised) {
orderData.value = capturedOrder;
if (isAlreadyCaptured(captured)) {
// An earlier capture of this PayPal order went through: reload the paid order, keeping the shown one if that fails.
orderData.value = await shopApi.getOrder(orderData.value.uuid).catch(() => orderData.value);
await handleSuccessfulPayment();
} else if (captured.paymentAuthorised) {
orderData.value = captured;
await handleSuccessfulPayment();
} else {
console.error("Payment capture did not result in authorisation");
@@ -324,6 +332,7 @@ async function initializePayPalButtons() {
}
} catch (error) {
console.error("Error capturing payment:", error);
paymentErrorMessage.value = checkoutErrorMessageFor(error);
hasPaymentError.value = true;
trackEvent("checkout-payment-error", {
stage: "capture-exception",
+41 -31
View File
@@ -77,48 +77,40 @@
{{ product?.cover?.copyText?.paper }}
</p>
<div class="flex flex-col-reverse lg:flex-row-reverse gap-4 xl:gap-12">
<div class="flex flex-col-reverse lg:flex-row-reverse lg:items-center 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">
<!-- Pages Variant Selection (locked chip when only one option) -->
<section v-if="productVariantsPages?.length" class="flex flex-col gap-3 py-6 px-6 xl:px-8">
<h2 class="tracking-tight opacity-70 text-lg font-semibold">Seiten</h2>
<div class="flex flex-wrap gap-3">
<SelectionBox
v-for="pages in productVariantsPages"
:key="pages.id"
:label="pages.name"
:path="pages.productSlug ? `/details/${pages.productSlug}` : ''"
:is-active="pages.active"
:locked="productVariantsPages.length === 1"
:aria-disabled="isAddingToCart || isChangingVariant"
@click="handleVariantClick('pages', pages)"
@click="productVariantsPages.length > 1 && 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">
<section v-if="productVariantsRuling?.length" class="flex flex-col gap-3 py-6 px-6 xl:px-8">
<h2 class="tracking-tight opacity-70 text-lg font-semibold">Layout</h2>
<div class="flex flex-wrap gap-3">
<SelectionBox
v-for="ruling in productVariantsRuling"
:key="ruling.id"
:label="ruling.name"
:path="ruling.productSlug ? `/details/${ruling.productSlug}` : ''"
:is-active="ruling.active"
:locked="productVariantsRuling.length === 1"
:aria-disabled="isAddingToCart || isChangingVariant"
@click="handleVariantClick('ruling', ruling)"
@click="productVariantsRuling.length > 1 && handleVariantClick('ruling', ruling)"
>
<img v-if="ruling.iconUrl" :alt="ruling.name" :src="ruling.iconUrl" />
</SelectionBox>
@@ -126,15 +118,10 @@
</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">
<!-- Cover Variant Selection (only when a real choice exists) -->
<section v-if="productVariantsCover?.length > 1" class="flex flex-col gap-3 py-6 px-6 xl:px-8">
<h2 class="tracking-tight opacity-70 text-lg font-semibold">Einband</h2>
<div class="flex flex-wrap gap-3">
<SelectionBox
v-for="cover in productVariantsCover"
:key="cover.id"
@@ -148,7 +135,18 @@
</SelectionBox>
</div>
</section>
<hr v-if="productVariantsCover?.length" class="border-t-[1px] border-black opacity-40" />
<hr v-if="productVariantsCover?.length > 1" class="border-t-[1px] border-black opacity-40" />
<!-- Fixed product facts (e.g. Journaling Booklet) as pre-selected locked chips -->
<template v-for="fact in buyBoxFacts" :key="fact.label">
<section class="flex flex-col gap-3 py-6 px-6 xl:px-8">
<h2 class="tracking-tight opacity-70 text-lg font-semibold">{{ fact.label }}</h2>
<div class="flex flex-wrap gap-3">
<SelectionBox :label="fact.value" :path="''" :locked="true" :aria-label="`${fact.label}: ${fact.value}`" />
</div>
</section>
<hr class="border-t-[1px] border-black opacity-40" />
</template>
<!-- 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">
@@ -186,7 +184,7 @@
</div>
</div>
<div class="flex flex-col items-center lg:w-1/2 xl:w-3/5 xl:pt-2 mx-auto">
<div class="flex flex-col items-center lg:w-1/2 xl:w-3/5 lg:py-12 xl:py-16 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>
@@ -421,6 +419,18 @@ const productVariantsPatterns = computed(() => {
}));
});
// Fixed/standalone products (no selectable pages & ruling, e.g. Journaling Booklet)
// surface their key specs as pre-selected locked chips in the buy box.
const buyBoxFacts = computed(() => {
if (productVariantsPages.value.length || productVariantsRuling.value.length) return [];
const s = (technicalSpecs.value ?? {}) as Record<string, string>;
const facts: { label: string; value: string }[] = [];
if (s.Umfang) facts.push({ label: "Umfang", value: s.Umfang });
if (s.Layout) facts.push({ label: "Layout", value: s.Layout });
if (s.Einband) facts.push({ label: "Einband", value: s.Bindung ? `${s.Einband} · ${s.Bindung}` : s.Einband });
return facts;
});
// Testimonials data
const testimonials: Testimonial[] = [
{
+56 -14
View File
@@ -49,23 +49,32 @@
</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
<!-- Promo bands for standalone products (e.g. Journaling Booklet) -->
<section
v-for="promo in promoCovers"
:key="promo.id"
class="py-16 lg:py-24 bg-gradient-to-br from-violet-50 via-white to-rose-50"
>
<div class="container mx-auto px-6 flex flex-col-reverse lg:flex-row items-center gap-10 lg:gap-16">
<div class="lg:w-1/2">
<p class="text-sm uppercase tracking-widest opacity-60 mb-3">Neu im Sortiment</p>
<Heading :level="2" html-tag="h2">{{ promo.name }}</Heading>
<p v-if="promo.details" class="mt-4 text-lg leading-relaxed opacity-80 xl:w-5/6" v-html="promo.details" />
<NuxtLink
:to="`/details/${promo.productSlug}`"
class="mt-8 inline-block px-8 py-3 rounded-full font-medium bg-gray-900 text-white hover:bg-gray-700 transition-all duration-200"
@click="trackEvent('promo-cta-clicked', { product: promo.productSlug })"
>
{{ promo.name }} entdecken
</NuxtLink>
</div>
<NuxtLink :to="`/details/${promo.productSlug}`" class="lg:w-1/2 block">
<img v-if="promo.image" :src="promo.image" :alt="promo.name" loading="lazy" class="w-full rounded-lg shadow-xl" />
</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">
@@ -99,8 +108,11 @@ const shopApi = useShopApi();
const { data: coversData } = await useAsyncData("landing-covers", async () => {
try {
const { data } = await shopApi.getProductCovers();
// Standalone covers (e.g. Journaling Booklet) are excluded from the landing
// grid — they get a dedicated promo placement / direct link, not a row here.
const sellable = data.filter((cover: any) => !cover.attributes?.standalone);
const covers = await Promise.all(
data.map(async (cover: any) => {
sellable.map(async (cover: any) => {
const productsResponse = await shopApi.getCheapestProducts(cover.id, 1, 10);
return {
id: cover.id,
@@ -122,6 +134,36 @@ const { data: coversData } = await useAsyncData("landing-covers", async () => {
const covers = computed(() => coversData.value ?? []);
// Standalone covers (e.g. Journaling Booklet) → dedicated promo band with a
// direct link to the first product of that category. Link resolves by SLUG
// (never hardcoded ids — prod ids differ from dev).
const { data: promoData } = await useAsyncData("landing-promos", async () => {
try {
const { data } = await shopApi.getProductCovers();
const standalone = data.filter((c: any) => c.attributes?.standalone);
const promos = await Promise.all(
standalone.map(async (cover: any) => {
const productsResponse = await shopApi.getCheapestProducts(cover.id, 1, 1);
const first = productsResponse?.data?.[0] as any;
const img = first?.images?.images?.[0];
return {
id: cover.id,
name: first?.name ?? cover.attributes?.name,
details: cover.attributes?.copyText?.details,
sort: cover.attributes?.sort ?? 0,
productSlug: first?.slug,
image: img?.formats?.medium?.url ?? img?.formats?.small?.url ?? img?.url
};
})
);
return promos.filter((p) => p.productSlug).sort((a, b) => a.sort - b.sort);
} catch (error) {
console.error("Error fetching promos:", error);
return [];
}
});
const promoCovers = computed(() => promoData.value ?? []);
// Testimonials
const testimonials: Testimonial[] = [
{
+7 -44
View File
@@ -1,55 +1,18 @@
import { contactMail, mailSendUrl } from "~/server/utils/contactMail";
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",
});
const result = contactMail(await readBody(event));
if (result.ok === false) {
throw createError({ statusCode: 400, message: result.message });
}
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,
},
});
await $fetch(mailSendUrl(config.mailApiUrl || "http://mail:2222"), { method: "POST", body: result.mail });
return { success: true };
} catch (error) {
console.error("Failed to send contact email:", error);
throw createError({
statusCode: 500,
message: "E-Mail konnte nicht gesendet werden",
});
throw createError({ statusCode: 500, message: "E-Mail konnte nicht gesendet werden" });
}
});
+2 -2
View File
@@ -1,4 +1,4 @@
import { fetchCms } from "~/server/utils/cmsApi";
import { forwardToCms } from "~/server/utils/cmsApi";
export default defineEventHandler(async (event) => {
const uuid = getRouterParam(event, "uuid");
@@ -6,5 +6,5 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 400, statusMessage: "Missing order UUID" });
}
return await fetchCms(`/orders/${uuid}/cart`);
return await forwardToCms(`/orders/${uuid}/cart`);
});
+9 -4
View File
@@ -1,4 +1,5 @@
import { fetchCms } from "~/server/utils/cmsApi";
import { forwardToCms } from "~/server/utils/cmsApi";
import { invalidOrderUpdate, pickCustomerUpdate } from "~/server/utils/customerUpdate";
export default defineEventHandler(async (event) => {
const uuid = getRouterParam(event, "uuid");
@@ -6,10 +7,14 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 400, statusMessage: "Missing order UUID" });
}
const body = await readBody(event);
// Only the fields of the checkout's steps reach the CMS, which checks their values. Any other field is answered 400 here.
const update = pickCustomerUpdate(await readBody(event));
if (update.ok === false) {
throw createError(invalidOrderUpdate(update.errors));
}
return await fetchCms(`/orders/${uuid}/cart`, {
return await forwardToCms(`/orders/${uuid}/cart`, {
method: "PUT",
body
body: { data: update.data }
});
});
@@ -1,4 +1,4 @@
import { fetchCms } from "~/server/utils/cmsApi";
import { forwardToCms } from "~/server/utils/cmsApi";
export default defineEventHandler(async (event) => {
const uuid = getRouterParam(event, "uuid");
@@ -14,7 +14,7 @@ export default defineEventHandler(async (event) => {
const count = query.count || 1;
return await fetchCms(`/orders/${uuid}/add-product/${productId}?count=${count}`, {
return await forwardToCms(`/orders/${uuid}/add-product/${productId}?count=${count}`, {
method: "PUT"
});
});
@@ -1,4 +1,4 @@
import { fetchCms } from "~/server/utils/cmsApi";
import { forwardToCms } from "~/server/utils/cmsApi";
export default defineEventHandler(async (event) => {
const uuid = getRouterParam(event, "uuid");
@@ -11,7 +11,7 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 400, statusMessage: "Missing PayPal order ID" });
}
return await fetchCms(`/orders/${uuid}/capture/${paypalOrderId}`, {
return await forwardToCms(`/orders/${uuid}/capture/${paypalOrderId}`, {
method: "POST"
});
});
+2 -2
View File
@@ -1,4 +1,4 @@
import { fetchCms } from "~/server/utils/cmsApi";
import { forwardToCms } from "~/server/utils/cmsApi";
export default defineEventHandler(async (event) => {
const uuid = getRouterParam(event, "uuid");
@@ -10,7 +10,7 @@ export default defineEventHandler(async (event) => {
const returnUrl = query.returnUrl || "";
return await fetchCms(`/orders/${uuid}/checkout?returnUrl=${encodeURIComponent(String(returnUrl))}`, {
return await forwardToCms(`/orders/${uuid}/checkout?returnUrl=${encodeURIComponent(String(returnUrl))}`, {
method: "POST"
});
});
@@ -1,4 +1,4 @@
import { fetchCms } from "~/server/utils/cmsApi";
import { forwardToCms } from "~/server/utils/cmsApi";
export default defineEventHandler(async (event) => {
const uuid = getRouterParam(event, "uuid");
@@ -14,7 +14,7 @@ export default defineEventHandler(async (event) => {
const count = query.count || 1;
return await fetchCms(`/orders/${uuid}/remove-product/${productId}?count=${count}`, {
return await forwardToCms(`/orders/${uuid}/remove-product/${productId}?count=${count}`, {
method: "PUT"
});
});
+17
View File
@@ -1,3 +1,5 @@
import { cmsErrorLogLine, shopErrorFromCms } from "./cmsError";
/**
* Server-side CMS API utility.
* Use this for direct CMS access during SSR.
@@ -15,3 +17,18 @@ export async function fetchCms<T>(endpoint: string, options: Parameters<typeof $
}
});
}
/**
* fetchCms for a route that forwards the browser's request to the CMS: a CMS error answers the browser with the CMS's status
* and the safe fields of its error (cmsError.ts), instead of an unhandled error, whose message and data Nitro hides.
*/
export async function forwardToCms<T>(endpoint: string, options: Parameters<typeof $fetch>[1] = {}): Promise<T> {
try {
return await fetchCms<T>(endpoint, options);
} catch (error) {
const shopError = shopErrorFromCms(error);
if (!shopError) throw error;
if (shopError.statusCode >= 500) console.error(cmsErrorLogLine(String(options.method ?? "GET"), endpoint, shopError));
throw createError(shopError);
}
}
+81
View File
@@ -0,0 +1,81 @@
// How a failed CMS request answers the browser: with the CMS's status and only the fields of its error that are safe to show.
// The CMS (Strapi) answers an error as { data: null, error: { status, name, message, details } }, which $fetch throws as ofetch's
// FetchError with the response's status and parsed body. Thrown on as it is, Nitro treats it as unhandled: it hides the message and
// the data from the browser and logs the CMS URL. forwardToCms (cmsApi.ts) throws createError(shopErrorFromCms(error)) instead.
// Pure: no Nuxt, Nitro or h3 imports, tested in tests/unit/cmsError.test.ts.
/** What the browser receives as the data of the error. errors and missing are the CMS's details.errors and details.missing. */
export type ShopErrorData = { message: string; errors?: string[]; missing?: string[] };
/** The argument for h3's createError. */
export type ShopError = { statusCode: number; statusMessage: string; data: ShopErrorData };
// The statuses passed on: the CMS's answers about the order and its payment. Any other status, such as 401 or 403 for a wrong
// API token, is the shop's own fault and answered 500.
const STATUS_TEXTS = new Map<number, string>([
[400, "Bad Request"],
[404, "Not Found"],
[409, "Conflict"],
[500, "Internal Server Error"],
[502, "Bad Gateway"],
[503, "Service Unavailable"],
[504, "Gateway Timeout"]
]);
const MAX_MESSAGE_LENGTH = 200;
const MAX_STATUS_MESSAGE_LENGTH = 100;
const MAX_FIELD_LENGTH = 64;
const MAX_LIST_LENGTH = 20;
const UUID = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi;
const isObject = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
const cut = (text: string, maxLength: number): string => (text.length > maxLength ? `${text.slice(0, maxLength)}…` : text);
// The strings of a list in the CMS's details, e.g. ["data.email: must be an email address of at most 254 characters"].
const stringList = (value: unknown, maxLength: number): string[] | undefined => {
if (!Array.isArray(value)) return undefined;
const list = value
.filter((item): item is string => typeof item === "string")
.slice(0, MAX_LIST_LENGTH)
.map((item) => cut(item, maxLength));
return list.length > 0 ? list : undefined;
};
// The status message is the HTTP reason phrase, which allows printable ASCII only.
const isReasonPhrase = (text: string): boolean => text.length <= MAX_STATUS_MESSAGE_LENGTH && /^[\x20-\x7e]+$/.test(text);
/**
* The error to throw to the browser for an error of $fetch to the CMS, or undefined if the error is not ofetch's FetchError
* (a bug, to rethrow as it is). The CMS's status is kept for 400, 404, 409, 500, 502, 503 and 504, any other is answered 500,
* and a CMS that does not answer 503. The data holds the CMS's message, details.errors and details.missing, and nothing else.
*/
export const shopErrorFromCms = (error: unknown): ShopError | undefined => {
if (!(error instanceof Error) || error.name !== "FetchError") return undefined;
const { status, data } = error as Error & { status?: unknown; data?: unknown };
if (typeof status !== "number") {
return { statusCode: 503, statusMessage: "Service Unavailable", data: { message: "The CMS did not answer" } };
}
const statusText = STATUS_TEXTS.get(status);
if (statusText === undefined) {
return { statusCode: 500, statusMessage: "Internal Server Error", data: { message: "Internal Server Error" } };
}
const cmsError: Record<string, unknown> = isObject(data) && isObject(data.error) ? data.error : {};
const details: Record<string, unknown> = isObject(cmsError.details) ? cmsError.details : {};
const message = typeof cmsError.message === "string" && cmsError.message !== "" ? cut(cmsError.message, MAX_MESSAGE_LENGTH) : statusText;
const errors = stringList(details.errors, MAX_MESSAGE_LENGTH);
const missing = stringList(details.missing, MAX_FIELD_LENGTH);
return {
statusCode: status,
statusMessage: isReasonPhrase(message) ? message : statusText,
data: { message, ...(errors ? { errors } : {}), ...(missing ? { missing } : {}) }
};
};
/** The log line for a failed CMS request: without its query, and with order uuids replaced, since a uuid opens its order. */
export const cmsErrorLogLine = (method: string, endpoint: string, error: ShopError): string =>
`[cms] ${method.toUpperCase()} ${endpoint.replace(/\?.*$/, "").replace(UUID, ":uuid")}: ${error.statusCode} ${error.data.message}`;
+56
View File
@@ -0,0 +1,56 @@
// The mail of the contact form (components/ContactForm.vue → POST /api/contact). The mail service (libreshop/mail src/app.py)
// sends it from POST /v1/send/message, whose JSON body is { to_email, subject, message, html? }. It sets no Reply-To, so the
// sender's address is in the text. Pure: no Nuxt, Nitro or h3 imports, tested in tests/unit/contactMail.test.ts.
export const CONTACT_RECIPIENT = "paperwork@muellerprints.de";
export const MAIL_SEND_PATH = "/v1/send/message";
/** The body of POST /v1/send/message. */
export type ContactMail = { to_email: string; subject: string; message: string };
/** The mail to send, or the German message of the 400 for the form. */
export type ContactMailResult = { ok: true; mail: ContactMail } | { ok: false; message: string };
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const isObject = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
const isFilled = (value: unknown): value is string => typeof value === "string" && value.trim() !== "";
// A line break in the subject would end its header and start another one.
const oneLine = (text: string): string => text.replace(/[\r\n]+/g, " ").trim();
/** The URL of the mail service's send endpoint, from its base URL (runtimeConfig.mailApiUrl). */
export const mailSendUrl = (mailApiUrl: string): string => `${mailApiUrl.replace(/\/+$/, "")}${MAIL_SEND_PATH}`;
/** Checks the form's body { name, email, subject?, message } and builds the mail to the shop. */
export const contactMail = (body: unknown): ContactMailResult => {
const fields: Record<string, unknown> = isObject(body) ? body : {};
const { name, email, subject, message } = fields;
if (!isFilled(name) || !isFilled(email) || !isFilled(message)) {
return { ok: false, message: "Name, E-Mail und Nachricht sind erforderlich" };
}
if (!EMAIL.test(email)) {
return { ok: false, message: "Ungültige E-Mail-Adresse" };
}
const topic = typeof subject === "string" ? oneLine(subject) : "";
return {
ok: true,
mail: {
to_email: CONTACT_RECIPIENT,
subject: `Kontaktanfrage: ${topic || "Anfrage über Website"}`,
message: [
`Name: ${name}`,
`E-Mail: ${email}`,
`Betreff: ${topic || "Kontaktanfrage über Website"}`,
"",
"Nachricht:",
message,
"",
"---",
"Diese Nachricht wurde über das Kontaktformular auf muellerprints.de gesendet."
].join("\n")
}
};
};
+79
View File
@@ -0,0 +1,79 @@
// What PUT /api/orders/:uuid forwards to the CMS: the fields of the checkout's steps, and nothing else.
// Step 1 (pages/checkout/1.vue) sends { data: { email, acceptedTermsAndConditionsAt } }, step 2 (pages/checkout/2.vue) sends
// { data: { invoiceAddress, deliveryAddress, invoiceAddressStructured, deliveryAddressStructured, delivery } }.
// The CMS checks the values and stays the authority (libreshop/cms src/checkout/customer-update.ts). This check is defence in depth:
// a body with any other field is answered 400 without calling the CMS, in the format of the CMS's own answer.
// Pure: no Nuxt, Nitro or h3 imports, tested in tests/unit/customerUpdate.test.ts.
import type { ShopError } from "./cmsError";
/** The order fields a customer may set, in the order of the checkout's steps. */
export const CUSTOMER_UPDATE_FIELDS = [
"email",
"acceptedTermsAndConditionsAt",
"invoiceAddress",
"deliveryAddress",
"invoiceAddressStructured",
"deliveryAddressStructured",
"delivery"
] as const;
export type CustomerUpdateField = (typeof CUSTOMER_UPDATE_FIELDS)[number];
/** The fields to forward, with the values the browser sent: the CMS checks them. */
export type CustomerUpdate = Partial<Record<CustomerUpdateField, unknown>>;
/**
* unknown: the names of the rejected fields, such as "data.paymentAuthorised", or "email" for a field sent beside data.
* errors: one message per rejected field or malformed part, in the CMS's format "name: reason".
*/
export type PickedCustomerUpdate = { ok: true; data: CustomerUpdate } | { ok: false; unknown: string[]; errors: string[] };
export const INVALID_ORDER_UPDATE = "Invalid order update";
// A field name is chosen by the client and ends up in the answer, so a long one is cut.
const MAX_NAME_LENGTH = 64;
const isObject = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
const isCustomerUpdateField = (key: string): key is CustomerUpdateField => (CUSTOMER_UPDATE_FIELDS as readonly string[]).includes(key);
const fieldName = (key: string): string => (key.length > MAX_NAME_LENGTH ? `${key.slice(0, MAX_NAME_LENGTH)}…` : key);
/** Picks the fields a customer may set from the body { data: { … } }. Any other field, or another shape, rejects the whole body. */
export const pickCustomerUpdate = (body: unknown): PickedCustomerUpdate => {
if (!isObject(body)) {
return { ok: false, unknown: [], errors: ["body: must be an object of the form { data: { … } }"] };
}
const unknown: string[] = [];
const errors: string[] = [];
for (const key of Object.keys(body)) {
if (key === "data") continue;
unknown.push(fieldName(key));
errors.push(`${fieldName(key)}: not accepted, the fields belong in data`);
}
const fields = body.data;
const data: CustomerUpdate = {};
if (!isObject(fields)) {
errors.push(fields === undefined ? "data: missing" : "data: must be an object");
} else {
for (const key of Object.keys(fields)) {
if (isCustomerUpdateField(key)) {
data[key] = fields[key];
} else {
unknown.push(`data.${fieldName(key)}`);
errors.push(`data.${fieldName(key)}: not a field the customer may set`);
}
}
}
return errors.length > 0 ? { ok: false, unknown, errors } : { ok: true, data };
};
/** The 400 for a rejected body: the shape the shop answers for the CMS's own 400 (cmsError.ts). */
export const invalidOrderUpdate = (errors: string[]): ShopError => ({
statusCode: 400,
statusMessage: INVALID_ORDER_UPDATE,
data: { message: INVALID_ORDER_UPDATE, errors }
});
+21
View File
@@ -0,0 +1,21 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { isAlreadyCaptured } from "../../utils/captureResponse.ts";
test("recognises the CMS's answer to a repeated capture", () => {
assert.equal(isAlreadyCaptured({ success: true, alreadyCaptured: true }), true);
});
test("takes the paid order of a first capture for no repeated capture", () => {
const order = { uuid: "11111111-2222-4333-8444-555555555555", total: 22.85, paymentAuthorised: true, email: "erika@example.org" };
assert.equal(isAlreadyCaptured(order), false);
assert.equal(isAlreadyCaptured({ ...order, alreadyCaptured: false }), false);
});
test("takes nothing but true for both fields as a repeated capture", () => {
assert.equal(isAlreadyCaptured({ alreadyCaptured: true }), false);
assert.equal(isAlreadyCaptured({ success: true, alreadyCaptured: "true" }), false);
assert.equal(isAlreadyCaptured({ success: false, alreadyCaptured: true }), false);
for (const response of [undefined, null, "alreadyCaptured", 1, [true]]) assert.equal(isAlreadyCaptured(response), false);
});
+185
View File
@@ -0,0 +1,185 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { CHECKOUT_ERROR_MESSAGES as M, checkoutErrorMessage, checkoutErrorMessageFor } from "../../utils/checkoutError.ts";
// The shop's answer for a CMS error (server/utils/cmsError.ts), as Nitro writes it for a handled error.
const shopError = (statusCode: number, data: { message: string; errors?: string[]; missing?: string[] }) => ({
error: true,
url: "/api/orders/11111111-2222-4333-8444-555555555555",
statusCode,
statusMessage: data.message,
message: data.message,
data
});
// The CMS's own answer, Strapi's error body.
const strapiError = (status: number, message: string, details: Record<string, unknown> = {}) => ({
data: null,
error: { status, name: "Error", message, details }
});
// Built like ofetch's FetchError in the browser: getters for the answer's status and parsed body.
const fetchError = (status: number | undefined, body: unknown): Error => {
const error = new Error(`[PUT] "/api/orders/11111111-2222-4333-8444-555555555555": ${status ?? "<no response>"}`);
error.name = "FetchError";
for (const [key, value] of [
["data", body],
["status", status],
["statusCode", status]
] as const) {
Object.defineProperty(error, key, { get: () => value });
}
return error;
};
const ALL_MESSAGES = new Set<string>(Object.values(M));
test("asks for a valid e-mail address when the CMS rejects data.email", () => {
const body = shopError(400, { message: "Invalid order update", errors: ["data.email: must be an email address of at most 254 characters"] });
assert.equal(checkoutErrorMessage(400, body), "Bitte gib eine gültige E-Mail-Adresse ein.");
});
test("asks to check the input when the CMS rejects another field", () => {
const body = shopError(400, {
message: "Invalid order update",
errors: ["data.invoiceAddressStructured.postalCode: must be a string of at most 200 characters, or null"]
});
assert.equal(checkoutErrorMessage(400, body), M.invalidInput);
assert.equal(
checkoutErrorMessage(400, shopError(400, { message: "Invalid order update", errors: ["data.delivery: no delivery method has this id"] })),
M.invalidInput
);
});
test("asks to check the input when the shop rejects a field before the CMS", () => {
const body = shopError(400, { message: "Invalid order update", errors: ["data.paymentAuthorised: not a field the customer may set"] });
assert.equal(checkoutErrorMessage(400, body), M.invalidInput);
});
test("mentions the e-mail address when it is among several rejected fields", () => {
const body = shopError(400, {
message: "Invalid order update",
errors: [
"data.acceptedTermsAndConditionsAt: must be sent only to accept the terms, as true or a timestamp",
"data.email: must be an email address"
]
});
assert.equal(checkoutErrorMessage(400, body), M.invalidEmail);
});
test("says that a paid order can no longer be changed", () => {
assert.equal(checkoutErrorMessage(409, shopError(409, { message: "Order can no longer be changed" })), M.alreadyPaid);
assert.equal(checkoutErrorMessage(409, shopError(409, { message: "Order is already paid" })), M.alreadyPaid);
assert.equal(M.alreadyPaid, "Diese Bestellung ist bereits bezahlt und kann nicht mehr geändert werden.");
});
test("says that a product is no longer available", () => {
assert.equal(checkoutErrorMessage(409, shopError(409, { message: "A product in the cart is no longer available" })), M.productUnavailable);
assert.equal(M.productUnavailable, "Ein Produkt in deinem Warenkorb ist nicht mehr verfügbar. Bitte prüfe deinen Warenkorb.");
});
test("names the fields an order that is not ready for checkout misses", () => {
const body = (missing: string[]) => shopError(400, { message: "Order is not ready for checkout", missing });
assert.equal(checkoutErrorMessage(400, body(["email"])), "Deine Bestellung ist noch nicht vollständig. Bitte ergänze: E-Mail-Adresse.");
assert.equal(
checkoutErrorMessage(400, body(["email", "invoiceAddress", "delivery"])),
"Deine Bestellung ist noch nicht vollständig. Bitte ergänze: E-Mail-Adresse, Rechnungsadresse, Versandart."
);
});
test("says that an order is not ready without naming a field the shop does not know", () => {
assert.equal(checkoutErrorMessage(400, shopError(400, { message: "Order is not ready for checkout" })), M.notReady);
assert.equal(checkoutErrorMessage(400, shopError(400, { message: "Order is not ready for checkout", missing: ["<b>phone</b>"] })), M.notReady);
assert.equal(
checkoutErrorMessage(400, shopError(400, { message: "Order is not ready for checkout", missing: ["phone", "delivery"] })),
"Deine Bestellung ist noch nicht vollständig. Bitte ergänze: Versandart."
);
});
test("asks to restart the payment when it does not fit the order or the order changed", () => {
for (const message of ["Payment does not belong to this order", "Payment does not match this order", "Order changed during the payment"]) {
assert.equal(checkoutErrorMessage(409, shopError(409, { message })), M.restartPayment, message);
}
assert.match(M.restartPayment, /Bitte starte die Zahlung neu\.$/);
});
test("asks to try again in a few minutes when PayPal is not reachable", () => {
for (const message of ["Could not create the PayPal order", "PayPal could not capture the payment"]) {
assert.equal(checkoutErrorMessage(502, shopError(502, { message })), M.paypalUnavailable, message);
}
assert.equal(checkoutErrorMessage(502, "<html>502 Bad Gateway</html>"), M.paypalUnavailable);
assert.match(M.paypalUnavailable, /versuche es in ein paar Minuten noch einmal\.$/);
});
test("asks not to pay again when PayPal may have taken the money but the order is not marked paid", () => {
assert.equal(checkoutErrorMessage(502, shopError(502, { message: "Payment could not be confirmed" })), M.paymentUnconfirmed);
assert.equal(checkoutErrorMessage(500, shopError(500, { message: "Payment could not be recorded" })), M.paymentUnconfirmed);
});
test("falls back to a general message for anything else", () => {
assert.equal(checkoutErrorMessage(404, shopError(404, { message: "Order not found" })), M.generic);
assert.equal(checkoutErrorMessage(400, shopError(400, { message: "Missing order UUID" })), M.generic);
assert.equal(checkoutErrorMessage(400, shopError(400, { message: "Product not found in cart" })), M.generic);
assert.equal(checkoutErrorMessage(409, shopError(409, { message: "Something else" })), M.generic);
assert.equal(checkoutErrorMessage(500, { error: true, statusCode: 500, statusMessage: "Server Error", message: "Server Error" }), M.generic);
assert.equal(checkoutErrorMessage(503, shopError(503, { message: "The CMS did not answer" })), M.generic);
assert.equal(checkoutErrorMessage(undefined, undefined), M.generic);
assert.equal(checkoutErrorMessage(409, null), M.generic);
assert.equal(M.generic, "Es ist ein Fehler aufgetreten. Bitte versuche es erneut.");
});
test("reads the CMS's own error body as well", () => {
assert.equal(
checkoutErrorMessage(400, strapiError(400, "Invalid order update", { errors: ["data.email: must be an email address"] })),
M.invalidEmail
);
assert.equal(
checkoutErrorMessage(400, strapiError(400, "Order is not ready for checkout", { missing: ["delivery"] })),
"Deine Bestellung ist noch nicht vollständig. Bitte ergänze: Versandart."
);
assert.equal(checkoutErrorMessage(409, strapiError(409, "Order can no longer be changed")), M.alreadyPaid);
assert.equal(checkoutErrorMessage(409, strapiError(409, "Order changed during the payment")), M.restartPayment);
});
test("never shows the server's text or personal data", () => {
const personal = "erika@example.org, Musterstraße 1, 70190 Stuttgart";
const bodies = [
shopError(400, { message: personal, errors: [`data.email: ${personal}`], missing: [personal] }),
shopError(409, { message: personal }),
shopError(502, { message: personal }),
strapiError(400, personal, { errors: [personal] }),
{ message: personal },
personal
];
for (const status of [undefined, 400, 404, 409, 500, 502, 503]) {
for (const body of bodies) {
const message = checkoutErrorMessage(status, body);
assert.ok(ALL_MESSAGES.has(message) || message.startsWith("Deine Bestellung ist noch nicht vollständig."), message);
assert.ok(!message.includes("erika") && !message.includes("Muster"), message);
}
}
});
test("reads the status and the body of the error $fetch throws", () => {
const body = shopError(409, { message: "Order can no longer be changed" });
assert.equal(checkoutErrorMessageFor(fetchError(409, body)), M.alreadyPaid);
assert.equal(checkoutErrorMessageFor(fetchError(502, shopError(502, { message: "Could not create the PayPal order" }))), M.paypalUnavailable);
assert.equal(
checkoutErrorMessageFor({ statusCode: 400, data: shopError(400, { message: "Invalid order update", errors: ["data.email: x"] }) }),
M.invalidEmail
);
});
test("falls back to the general message for an error without an answer", () => {
assert.equal(checkoutErrorMessageFor(fetchError(undefined, undefined)), M.generic);
assert.equal(checkoutErrorMessageFor(new TypeError("Failed to fetch")), M.generic);
assert.equal(checkoutErrorMessageFor(undefined), M.generic);
assert.equal(checkoutErrorMessageFor("error"), M.generic);
});
+201
View File
@@ -0,0 +1,201 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { cmsErrorLogLine, shopErrorFromCms } from "../../server/utils/cmsError.ts";
const UUID = "11111111-2222-4333-8444-555555555555";
const CART = `http://cms:5555/api/orders/${UUID}/cart`;
const CHECKOUT = `http://cms:5555/api/orders/${UUID}/checkout?returnUrl=https%3A%2F%2Fshop.example%2Fcheckout%2F3`;
const CAPTURE = `http://cms:5555/api/orders/${UUID}/capture/5O190127TN364715T`;
// Built like ofetch's createFetchError (ofetch 1.5): an Error named FetchError, with getters for the response's status and parsed body.
const fetchError = (method: string, url: string, response?: { status: number; statusText: string; body: unknown }): Error => {
const status = response ? `${response.status} ${response.statusText}` : "<no response> fetch failed";
const error = new Error(`[${method}] ${JSON.stringify(url)}: ${status}`);
error.name = "FetchError";
const fields: [string, unknown][] = [
["data", response?.body],
["status", response?.status],
["statusCode", response?.status],
["statusText", response?.statusText],
["statusMessage", response?.statusText]
];
for (const [key, value] of fields) Object.defineProperty(error, key, { get: () => value });
return error;
};
// Strapi's error body, as ctx.badRequest, ctx.notFound, ctx.conflict and ctx.badGateway write it.
const strapiError = (status: number, name: string, message: string, details: Record<string, unknown> = {}) => ({
data: null,
error: { status, name, message, details }
});
const cmsAnswer = (method: string, url: string, status: number, statusText: string, body: unknown) =>
shopErrorFromCms(fetchError(method, url, { status, statusText, body }));
test("passes a rejected order update on as 400 with the CMS's errors", () => {
const errors = ["data.email: must be an email address of at most 254 characters", "data.total: not a field the customer may set"];
assert.deepEqual(cmsAnswer("PUT", CART, 400, "Bad Request", strapiError(400, "BadRequestError", "Invalid order update", { errors })), {
statusCode: 400,
statusMessage: "Invalid order update",
data: { message: "Invalid order update", errors }
});
});
test("passes a checkout that is not ready on as 400 with the fields it misses", () => {
const body = strapiError(400, "BadRequestError", "Order is not ready for checkout", { missing: ["email", "delivery"] });
assert.deepEqual(cmsAnswer("POST", CHECKOUT, 400, "Bad Request", body), {
statusCode: 400,
statusMessage: "Order is not ready for checkout",
data: { message: "Order is not ready for checkout", missing: ["email", "delivery"] }
});
});
test("passes on no detail but errors and missing", () => {
const details = { reason: "deliveryAddress has no postal code", order: { email: "erika@example.org" }, errors: "data.email" };
const body = { ...strapiError(400, "BadRequestError", "Order is not ready for checkout", details), meta: { email: "erika@example.org" } };
assert.deepEqual(cmsAnswer("POST", CHECKOUT, 400, "Bad Request", body), {
statusCode: 400,
statusMessage: "Order is not ready for checkout",
data: { message: "Order is not ready for checkout" }
});
});
test("passes the conflicts of a paid order, an unavailable product and a payment that does not fit the order on as 409", () => {
for (const [url, message] of [
[CART, "Order can no longer be changed"],
[CHECKOUT, "A product in the cart is no longer available"],
[CAPTURE, "Order is already paid"],
[CAPTURE, "Payment does not belong to this order"],
[CAPTURE, "Payment does not match this order"],
[CAPTURE, "Order changed during the payment"]
] as const) {
assert.deepEqual(
cmsAnswer("POST", url, 409, "Conflict", strapiError(409, "ConflictError", message)),
{ statusCode: 409, statusMessage: message, data: { message } },
message
);
}
});
test("passes a missing order on as 404", () => {
assert.deepEqual(cmsAnswer("GET", CART, 404, "Not Found", strapiError(404, "NotFoundError", "Order not found")), {
statusCode: 404,
statusMessage: "Order not found",
data: { message: "Order not found" }
});
});
test("passes PayPal's errors on as 502", () => {
for (const [url, message] of [
[CHECKOUT, "Could not create the PayPal order"],
[CAPTURE, "PayPal could not capture the payment"],
[CAPTURE, "Payment could not be confirmed"]
] as const) {
assert.deepEqual(
cmsAnswer("POST", url, 502, "Bad Gateway", strapiError(502, "BadGatewayError", message)),
{ statusCode: 502, statusMessage: message, data: { message } },
message
);
}
});
test("passes a payment the CMS could not record on as 500 with its message", () => {
const body = strapiError(500, "InternalServerError", "Payment could not be recorded");
assert.deepEqual(cmsAnswer("POST", CAPTURE, 500, "Internal Server Error", body), {
statusCode: 500,
statusMessage: "Payment could not be recorded",
data: { message: "Payment could not be recorded" }
});
});
test("answers 500 without the CMS's message for a status that is the shop's own fault", () => {
for (const [status, statusText] of [
[401, "Unauthorized"],
[403, "Forbidden"],
[405, "Method Not Allowed"],
[413, "Payload Too Large"]
] as const) {
assert.deepEqual(
cmsAnswer("PUT", CART, status, statusText, strapiError(status, "Error", "Missing or invalid credentials")),
{ statusCode: 500, statusMessage: "Internal Server Error", data: { message: "Internal Server Error" } },
String(status)
);
}
});
test("answers 503 when the CMS does not answer", () => {
assert.deepEqual(shopErrorFromCms(fetchError("GET", CART)), {
statusCode: 503,
statusMessage: "Service Unavailable",
data: { message: "The CMS did not answer" }
});
});
test("leaves any other error to be rethrown as it is", () => {
assert.equal(shopErrorFromCms(new TypeError("Cannot read properties of undefined (reading 'uuid')")), undefined);
assert.equal(shopErrorFromCms({ name: "FetchError", status: 409, data: strapiError(409, "ConflictError", "Order is already paid") }), undefined);
assert.equal(shopErrorFromCms("FetchError"), undefined);
assert.equal(shopErrorFromCms(undefined), undefined);
});
test("keeps only the strings of the CMS's lists, at most 20, and cuts a long one", () => {
// Strapi's own validation errors are objects with the path and the message: they are dropped.
const objects = strapiError(400, "ValidationError", "Invalid order update", {
errors: [{ path: ["email"], message: "email must be a valid email" }]
});
assert.deepEqual(cmsAnswer("PUT", CART, 400, "Bad Request", objects)?.data, { message: "Invalid order update" });
const many = Array.from({ length: 25 }, (_, index) => `data.field${index}: not a field the customer may set`);
const long = `data.${"x".repeat(300)}: not a field the customer may set`;
const body = strapiError(400, "BadRequestError", "Invalid order update", { errors: [long, 7, ...many] });
const data = cmsAnswer("PUT", CART, 400, "Bad Request", body)?.data;
assert.equal(data?.errors?.length, 20);
assert.equal(data?.errors?.[0], `${long.slice(0, 200)}…`);
assert.equal(data?.errors?.[1], many[0]);
});
test("answers with the status's reason phrase when the body is no CMS error", () => {
assert.deepEqual(cmsAnswer("POST", CHECKOUT, 502, "Bad Gateway", "<html><body>502 Bad Gateway</body></html>"), {
statusCode: 502,
statusMessage: "Bad Gateway",
data: { message: "Bad Gateway" }
});
assert.deepEqual(cmsAnswer("GET", CART, 404, "Not Found", strapiError(404, "NotFoundError", "")), {
statusCode: 404,
statusMessage: "Not Found",
data: { message: "Not Found" }
});
});
test("keeps a message the status line cannot carry in the data only", () => {
const long = `Order ${"x".repeat(250)}`;
assert.deepEqual(cmsAnswer("PUT", CART, 409, "Conflict", strapiError(409, "ConflictError", "Bestellung gesperrt…")), {
statusCode: 409,
statusMessage: "Conflict",
data: { message: "Bestellung gesperrt…" }
});
assert.deepEqual(cmsAnswer("PUT", CART, 409, "Conflict", strapiError(409, "ConflictError", long)), {
statusCode: 409,
statusMessage: "Conflict",
data: { message: `${long.slice(0, 200)}…` }
});
});
test("logs a failed request without its query and without the order's uuid", () => {
const error = { statusCode: 502, statusMessage: "Bad Gateway", data: { message: "Could not create the PayPal order" } };
assert.equal(
cmsErrorLogLine("post", `/orders/${UUID}/checkout?returnUrl=https%3A%2F%2Fshop.example`, error),
"[cms] POST /orders/:uuid/checkout: 502 Could not create the PayPal order"
);
assert.equal(
cmsErrorLogLine("GET", `/orders/${UUID.toUpperCase()}/cart`, error),
"[cms] GET /orders/:uuid/cart: 502 Could not create the PayPal order"
);
});
+70
View File
@@ -0,0 +1,70 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { CONTACT_RECIPIENT, contactMail, mailSendUrl } from "../../server/utils/contactMail.ts";
// components/ContactForm.vue posts these fields; it sends "Kontaktanfrage über Website" when the subject is left empty.
const form = {
name: "Erika Mustermann",
email: "erika@example.org",
subject: "Notizbuch mit Firmenlogo",
message: "Hallo,\nkönnt ihr 50 Stück liefern?"
};
const required = { ok: false, message: "Name, E-Mail und Nachricht sind erforderlich" };
test("sends to the mail service's /v1/send/message", () => {
assert.equal(mailSendUrl("http://mail:2222"), "http://mail:2222/v1/send/message");
assert.equal(mailSendUrl("http://mail:2222/"), "http://mail:2222/v1/send/message");
});
test("builds the body the mail service reads: to_email, subject and message", () => {
assert.deepEqual(contactMail(form), {
ok: true,
mail: {
to_email: "paperwork@muellerprints.de",
subject: "Kontaktanfrage: Notizbuch mit Firmenlogo",
message: [
"Name: Erika Mustermann",
"E-Mail: erika@example.org",
"Betreff: Notizbuch mit Firmenlogo",
"",
"Nachricht:",
"Hallo,\nkönnt ihr 50 Stück liefern?",
"",
"---",
"Diese Nachricht wurde über das Kontaktformular auf muellerprints.de gesendet."
].join("\n")
}
});
assert.equal(CONTACT_RECIPIENT, "paperwork@muellerprints.de");
});
test("fills in the subject when the form sends none", () => {
for (const subject of [undefined, "", " "]) {
const result = contactMail({ ...form, subject });
assert.equal(result.ok && result.mail.subject, "Kontaktanfrage: Anfrage über Website");
assert.equal(result.ok && result.mail.message.split("\n")[2], "Betreff: Kontaktanfrage über Website");
}
});
test("keeps a line break in the subject from starting another mail header", () => {
const result = contactMail({ ...form, subject: "Frage\r\nBcc: someone@example.org\nX-Spam: yes" });
assert.equal(result.ok && result.mail.subject, "Kontaktanfrage: Frage Bcc: someone@example.org X-Spam: yes");
});
test("requires a name, an e-mail address and a message", () => {
for (const field of ["name", "email", "message"]) {
assert.deepEqual(contactMail({ ...form, [field]: undefined }), required, field);
assert.deepEqual(contactMail({ ...form, [field]: " " }), required, field);
assert.deepEqual(contactMail({ ...form, [field]: ["a"] }), required, field);
}
for (const body of [undefined, null, "name=Erika", [form]]) assert.deepEqual(contactMail(body), required);
});
test("rejects an invalid e-mail address", () => {
for (const email of ["erika", "erika@example", "erika @example.org", "@example.org"]) {
assert.deepEqual(contactMail({ ...form, email }), { ok: false, message: "Ungültige E-Mail-Adresse" }, email);
}
});
+168
View File
@@ -0,0 +1,168 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { CUSTOMER_UPDATE_FIELDS, invalidOrderUpdate, pickCustomerUpdate } from "../../server/utils/customerUpdate.ts";
// The body as readBody hands it to the route: sent by useShopApi().updateOrder as JSON.
const overTheWire = (body: unknown): unknown => JSON.parse(JSON.stringify(body));
const address = {
givenName: "Erika",
familyName: "Mustermann",
streetAddress: "Musterstraße 1",
postalCode: "70190",
addressLevel2: "Stuttgart",
country: "DE"
};
const otherAddress = {
givenName: "Max",
familyName: "Muster",
streetAddress: "Hauptstraße 5",
postalCode: "10115",
addressLevel2: "Berlin",
country: "DE"
};
// pages/checkout/1.vue: cart.update({ email, acceptedTermsAndConditionsAt }).
const step1 = { data: { email: "erika@example.org", acceptedTermsAndConditionsAt: "2026-10-09T08:15:00.000Z" } };
// pages/checkout/2.vue without a separate delivery address: the invoice address is sent as the delivery address too.
const step2 = {
data: {
invoiceAddress: "Erika Mustermann\nMusterstraße 1\n70190 Stuttgart",
deliveryAddress: "Erika Mustermann\nMusterstraße 1\n70190 Stuttgart",
invoiceAddressStructured: address,
deliveryAddressStructured: address,
delivery: 1
}
};
// The order's attributes in the CMS (src/api/order/content-types/order/schema.json) that only the server writes, and Strapi's own.
const SERVER_FIELDS = [
"id",
"uuid",
"date",
"customer",
"invoice",
"deliveryNote",
"hash",
"payment",
"VAT",
"subtotal",
"total",
"cart",
"paymentAuthorised",
"paymentStatus",
"paypalOrderId",
"paypalCaptureId",
"paymentCapturedAt",
"emailSent",
"invoiceSent",
"deliveryNoteSent",
"invoiceNumber",
"deliveryNoteNumber",
"deliveryTrackingNumber",
"createdAt",
"updatedAt",
"publishedAt"
];
test("lists the seven fields of the checkout's steps", () => {
assert.deepEqual(
[...CUSTOMER_UPDATE_FIELDS],
[
"email",
"acceptedTermsAndConditionsAt",
"invoiceAddress",
"deliveryAddress",
"invoiceAddressStructured",
"deliveryAddressStructured",
"delivery"
]
);
});
test("passes the payload of checkout step 1 unchanged", () => {
assert.deepEqual(pickCustomerUpdate(overTheWire(step1)), { ok: true, data: step1.data });
});
test("passes the payload of checkout step 2 unchanged", () => {
assert.deepEqual(pickCustomerUpdate(overTheWire(step2)), { ok: true, data: step2.data });
});
test("passes the payload of checkout step 2 with a separate delivery address unchanged", () => {
const body = {
data: {
...step2.data,
deliveryAddress: "Max Muster\nHauptstraße 5\n10115 Berlin",
deliveryAddressStructured: otherAddress,
delivery: 2
}
};
assert.deepEqual(pickCustomerUpdate(overTheWire(body)), { ok: true, data: body.data });
});
test("leaves the values to the CMS, which checks them", () => {
const body = { data: { email: "no address", delivery: null, invoiceAddressStructured: { street: "x" } } };
assert.deepEqual(pickCustomerUpdate(body), { ok: true, data: body.data });
});
test("rejects each field only the server writes", () => {
for (const field of SERVER_FIELDS) {
assert.deepEqual(
pickCustomerUpdate({ data: { [field]: 1 } }),
{ ok: false, unknown: [`data.${field}`], errors: [`data.${field}: not a field the customer may set`] },
field
);
}
});
test("rejects the whole body instead of dropping the field, and names every rejected field", () => {
const body = { data: { ...step1.data, paymentAuthorised: true, total: 0.01 } };
assert.deepEqual(pickCustomerUpdate(body), {
ok: false,
unknown: ["data.paymentAuthorised", "data.total"],
errors: ["data.paymentAuthorised: not a field the customer may set", "data.total: not a field the customer may set"]
});
});
test("rejects a field sent beside data", () => {
assert.deepEqual(pickCustomerUpdate({ email: "erika@example.org", data: {} }), {
ok: false,
unknown: ["email"],
errors: ["email: not accepted, the fields belong in data"]
});
});
test("rejects a body without data, with data that is not an object, and a body that is not an object", () => {
assert.deepEqual(pickCustomerUpdate({}), { ok: false, unknown: [], errors: ["data: missing"] });
assert.deepEqual(pickCustomerUpdate({ data: [step1.data] }), { ok: false, unknown: [], errors: ["data: must be an object"] });
assert.deepEqual(pickCustomerUpdate({ data: null }), { ok: false, unknown: [], errors: ["data: must be an object"] });
for (const body of [undefined, null, "data", [step1]]) {
assert.deepEqual(pickCustomerUpdate(body), { ok: false, unknown: [], errors: ["body: must be an object of the form { data: { … } }"] });
}
});
test("rejects __proto__ and constructor without touching any prototype", () => {
const result = pickCustomerUpdate(JSON.parse('{"data":{"__proto__":{"paymentAuthorised":true},"constructor":{"prototype":{}}}}'));
assert.equal(result.ok, false);
assert.deepEqual(result.ok === false && result.unknown, ["data.__proto__", "data.constructor"]);
assert.equal(({} as Record<string, unknown>).paymentAuthorised, undefined);
});
test("cuts a long field name in the answer", () => {
const result = pickCustomerUpdate({ data: { ["x".repeat(100)]: 1 } });
assert.deepEqual(result.ok === false && result.unknown, [`data.${"x".repeat(64)}…`]);
});
test("answers a rejected body with a 400 in the shape of the CMS's own 400", () => {
assert.deepEqual(invalidOrderUpdate(["data.total: not a field the customer may set"]), {
statusCode: 400,
statusMessage: "Invalid order update",
data: { message: "Invalid order update", errors: ["data.total: not a field the customer may set"] }
});
});
+6
View File
@@ -0,0 +1,6 @@
// Loaded by `npm test` through `node --import`. Node runs the .ts sources directly (type stripping), but unlike Vite and Nitro it does
// not resolve extensionless relative imports such as `import { … } from "./cmsError"` in a server/utils module.
// The hook below retries such an import from a .ts file with ".ts" appended. Test files import sources with explicit .ts paths.
import { register } from "node:module";
register("./resolve-ts.mjs", import.meta.url);
+14
View File
@@ -0,0 +1,14 @@
// Module resolve hook, registered by ./register.mjs: `./cmsError` imported from a .ts file resolves to `./cmsError.ts`.
export async function resolve(specifier, context, nextResolve) {
try {
return await nextResolve(specifier, context);
} catch (error) {
const relative = specifier.startsWith("./") || specifier.startsWith("../");
if (error?.code !== "ERR_MODULE_NOT_FOUND" || !relative || !context.parentURL?.endsWith(".ts")) throw error;
try {
return await nextResolve(`${specifier}.ts`, context);
} catch {
throw error;
}
}
}
+12
View File
@@ -0,0 +1,12 @@
// The answer of POST /api/orders/:uuid/capture/:paypalOrderId: the paid order, or, when the order is already paid with this
// PayPal order (a repeated capture), { success: true, alreadyCaptured: true } (libreshop/cms src/api/order/controllers/order.ts).
// Pure: no Nuxt or Vue imports, tested in tests/unit/captureResponse.test.ts.
/** The CMS's answer to a repeated capture: the payment went through before, and the order is paid. */
export type AlreadyCaptured = { success: true; alreadyCaptured: true };
export const isAlreadyCaptured = (response: unknown): response is AlreadyCaptured =>
typeof response === "object" &&
response !== null &&
(response as Record<string, unknown>).success === true &&
(response as Record<string, unknown>).alreadyCaptured === true;
+98
View File
@@ -0,0 +1,98 @@
// The message a customer sees when a checkout request fails: German, addressed with "du" like the rest of the shop, chosen from the
// constants below, so it never shows the server's text or personal data. The shop's order routes answer a CMS error with its status
// and { …, data: { message, errors?, missing? } } (server/utils/cmsError.ts); a raw CMS (Strapi) error body,
// { data: null, error: { status, name, message, details } }, is read as well.
// Pure: no Nuxt or Vue imports, tested in tests/unit/checkoutError.test.ts.
// The CMS's messages (libreshop/cms src/api/order/controllers/order.ts and src/checkout/), matched but never shown.
const INVALID_ORDER_UPDATE = "Invalid order update";
const ORDER_LOCKED = "Order can no longer be changed";
const ALREADY_PAID = "Order is already paid";
const CHECKOUT_NOT_READY = "Order is not ready for checkout";
const PRODUCT_UNAVAILABLE = "A product in the cart is no longer available";
const PAYMENT_NOT_THIS_ORDER = "Payment does not belong to this order";
const PAYMENT_NOT_MATCHING = "Payment does not match this order";
const ORDER_CHANGED = "Order changed during the payment";
// PayPal answered the capture, or captured the payment, but the CMS could not confirm or record it: the money may be taken.
const PAYMENT_NOT_CONFIRMED = "Payment could not be confirmed";
const PAYMENT_NOT_RECORDED = "Payment could not be recorded";
export const CHECKOUT_ERROR_MESSAGES = {
invalidEmail: "Bitte gib eine gültige E-Mail-Adresse ein.",
invalidInput: "Deine Angaben konnten nicht gespeichert werden. Bitte prüfe sie und versuche es noch einmal.",
alreadyPaid: "Diese Bestellung ist bereits bezahlt und kann nicht mehr geändert werden.",
productUnavailable: "Ein Produkt in deinem Warenkorb ist nicht mehr verfügbar. Bitte prüfe deinen Warenkorb.",
notReady: "Deine Bestellung ist noch nicht vollständig. Bitte prüfe deine Angaben in den vorherigen Schritten.",
restartPayment: "Die Zahlung passt nicht zu deiner Bestellung, oder deine Bestellung wurde während der Zahlung geändert. Bitte starte die Zahlung neu.",
paypalUnavailable: "PayPal ist gerade nicht erreichbar. Bitte versuche es in ein paar Minuten noch einmal.",
paymentUnconfirmed: "Deine Zahlung konnte nicht bestätigt werden. Bitte bezahle nicht noch einmal, sondern melde dich bei uns.",
generic: "Es ist ein Fehler aufgetreten. Bitte versuche es erneut."
} as const;
// The fields a checkout can miss (the CMS's missingCheckoutFields), as the customer knows them from the checkout's steps.
const MISSING_FIELD_LABELS = new Map<string, string>([
["email", "E-Mail-Adresse"],
["invoiceAddress", "Rechnungsadresse"],
["delivery", "Versandart"]
]);
type CheckoutErrorFields = { message: string | undefined; errors: string[]; missing: string[] };
const isObject = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
const text = (value: unknown): string | undefined => (typeof value === "string" ? value : undefined);
const strings = (value: unknown): string[] => (Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []);
const readCheckoutError = (body: unknown): CheckoutErrorFields => {
if (!isObject(body)) return { message: undefined, errors: [], missing: [] };
// The shop's answer: { error: true, url, statusCode, statusMessage, message, data: { message, errors?, missing? } }.
if (isObject(body.data)) {
return { message: text(body.data.message) ?? text(body.message), errors: strings(body.data.errors), missing: strings(body.data.missing) };
}
// The CMS's answer: { data: null, error: { status, name, message, details: { errors?, missing? } } }.
if (isObject(body.error)) {
const details: Record<string, unknown> = isObject(body.error.details) ? body.error.details : {};
return { message: text(body.error.message), errors: strings(details.errors), missing: strings(details.missing) };
}
return { message: text(body.message), errors: [], missing: [] };
};
// The CMS names a rejected field first, as in "data.email: must be an email address of at most 254 characters".
const isEmailError = (error: string): boolean => error === "data.email" || error.startsWith("data.email:");
const notReadyMessage = (missing: string[]): string => {
const labels = [...new Set(missing.map((field) => MISSING_FIELD_LABELS.get(field)).filter((label) => label !== undefined))];
return labels.length > 0 ? `Deine Bestellung ist noch nicht vollständig. Bitte ergänze: ${labels.join(", ")}.` : CHECKOUT_ERROR_MESSAGES.notReady;
};
/** The customer's message for a failed checkout request, from the status and the parsed body of its answer. */
export const checkoutErrorMessage = (status: number | undefined, body: unknown): string => {
const { message, errors, missing } = readCheckoutError(body);
if (status === 400) {
if (message === INVALID_ORDER_UPDATE || errors.length > 0) {
return errors.some(isEmailError) ? CHECKOUT_ERROR_MESSAGES.invalidEmail : CHECKOUT_ERROR_MESSAGES.invalidInput;
}
if (message === CHECKOUT_NOT_READY || missing.length > 0) return notReadyMessage(missing);
return CHECKOUT_ERROR_MESSAGES.generic;
}
if (status === 409) {
if (message === ORDER_LOCKED || message === ALREADY_PAID) return CHECKOUT_ERROR_MESSAGES.alreadyPaid;
if (message === PRODUCT_UNAVAILABLE) return CHECKOUT_ERROR_MESSAGES.productUnavailable;
if (message === PAYMENT_NOT_THIS_ORDER || message === PAYMENT_NOT_MATCHING || message === ORDER_CHANGED) {
return CHECKOUT_ERROR_MESSAGES.restartPayment;
}
return CHECKOUT_ERROR_MESSAGES.generic;
}
if (message === PAYMENT_NOT_CONFIRMED || message === PAYMENT_NOT_RECORDED) return CHECKOUT_ERROR_MESSAGES.paymentUnconfirmed;
if (status === 502) return CHECKOUT_ERROR_MESSAGES.paypalUnavailable;
return CHECKOUT_ERROR_MESSAGES.generic;
};
/** The customer's message for an error thrown by $fetch: ofetch's FetchError carries the answer's status and parsed body. */
export const checkoutErrorMessageFor = (error: unknown): string => {
if (!isObject(error)) return CHECKOUT_ERROR_MESSAGES.generic;
const status = typeof error.status === "number" ? error.status : typeof error.statusCode === "number" ? error.statusCode : undefined;
return checkoutErrorMessage(status, error.data);
};