Webshop und Buchungen im Frontend anbinden
Wie eine Website den Laden der E-Commerce-App und die Buchungsstrecken der Termine-App anbindet: Adapter, eigene Proxy-Routen, die drei Buchungsschritte, die Übergabe zur Kasse und die Rückkehr vom Zahlungsanbieter.
Überblick
Bis zum 22. September 2026 beschrieb dieser Artikel die CMS-eigenen Delivery-Endpunkte /shop/* und /bookings/*. Die gibt es nicht mehr. Der Laden lebt in der E-Commerce-App, die Buchungen in der Termine-App; warum, steht im Artikel „Formulare, Webshop und Buchungen".
Für das Frontend ändert sich dadurch ein Grundsatz, und der ist wichtig:
Der Zugangsschlüssel zu einer anderen App gehört nicht in den Browser — auch nicht als sichtbarer Präfix.
Der alte Weg arbeitete mit ?site=<tokenPrefix>: dem nicht geheimen Teil des Tokens, an dem das CMS den Aufrufer erkannte. Die neuen Apps kennen das nicht. Stattdessen führt jede Website eigene Routen, die den Schlüssel serverseitig dazulegen.
Grundregeln, die bleiben:
- Preise rechnet immer die App, nicht die Website. Gesendet werden Produktschlüssel und Mengen, nie Beträge.
- Beträge sind in Rappen/Cents (ganzzahlig), z.B.
2900= CHF 29.00. - Zahlungen laufen über den gemeinsamen Anbieter-Adapter (Datatrans/Nexi, Stripe, Payrex). Die Website nimmt keine Webhooks entgegen — das tut die App, der das Geschäft gehört.
Die zwei Adapter
Jede Vorlage bringt sie mit; in einem eigenen Projekt legt man sie genauso an.
| Datei | Wofür | Liest |
|---|---|---|
cms/laden.ts |
Katalog der E-Commerce-App | EC_URL, EC_TOKEN |
cms/termine.ts |
Buchungsstrecken der Termine-App | TERMINE_URL, TERMINE_TOKEN |
cms/termine-client.ts |
dieselben Typen, browsersicher | nichts |
Die dritte Datei ist kein Schönheitsfehler, sondern der Punkt: termine.ts liest ein Geheimnis, und eine Client-Insel darf nicht einmal einen import type von dort halten. Das Schlüsselwort type fällt bei einer späteren Änderung lautlos weg, und dann ist aus der harmlosen Zeile ein Wert-Import geworden, den niemand bemerkt.
EC_URL=https://<org>.scorebase.ch # ohne /ecommerce
EC_TOKEN=<ksk_… mit „ecommerce.laden.read", für die Kasse zusätzlich „ecommerce.laden.kaufen">
NEXT_PUBLIC_EC_KANAL=web
TERMINE_URL=https://<org>.scorebase.ch # ohne /termine
TERMINE_TOKEN=<tmk_… mit „lesen und buchen">
NEXT_PUBLIC_TERMINE_STRECKE=<Kennung>Beide Schlüssel legst du in der jeweiligen App unter Einstellungen → Schnittstelle an. hcms init schreibt die sechs Variablen als leere Platzhalter in die .env.local, sobald die Vorlage cms/laden.ts bzw. cms/termine.ts mitbringt.
Kanal und Strecke stehen absichtlich unter NEXT_PUBLIC_. Der Warenkorb im Browser legt sich unter dem Kanal ab, und die Serverseite liest denselben Wert; zwei Variablen für dieselbe Sache wären eine Falle — eine gesetzt, die andere vergessen, und der Warenkorb gehört zu einem anderen Kanal als der Katalog.
Die zwei eigenen Routen
POST /api/laden/kasse?kanal=<kanal> → Übergabe an die Kasse des Ladens
GET|POST /api/termine/<vorgang> → die erlaubten Buchungsvorgänge
Beide sind Routen Ihrer Website. Sie sind die einzige Stelle, an der Anfrage und Schlüssel zusammenkommen.
Die Termine-Route führt eine Liste erlaubter Vorgänge, keinen durchgereichten Pfad:
export const VORGAENGE = {
strecke: { pfad: "/strecke", methode: "GET" },
zeiten: { pfad: "/zeiten", methode: "GET" },
reservieren: { pfad: "/reservieren", methode: "POST" },
buchen: { pfad: "/buchen", methode: "POST" },
zahlung: { pfad: "/zahlung", methode: "POST" },
"zahlung-pruefen": { pfad: "/zahlung-pruefen", methode: "POST" },
} as constEin Proxy, der weitergibt, was in der Adresse steht, reicht auch das weiter, woran beim Bauen niemand gedacht hat — und legt den Schlüssel dazu. Die Methode steht mit in der Liste: ein GET, das als POST kommt, wird abgewiesen, statt still etwas zu schreiben.
Fehlerwortlaut und Status kommen unverändert aus der Ziel-App durch. Eine eigene Fehlersprache in der Route wäre eine zweite Wahrheit darüber, was schiefging, und sie veraltet beim ersten neuen Fall.
Webshop: Produkte laden
Serverseitig, in einer Server-Komponente:
import { EC_KANAL, ladeProdukte, ladeProdukt } from "../../cms/laden"
const seite = await ladeProdukte({ kanal: EC_KANAL, limit: 60 })
const produkt = await ladeProdukt("leinen-hemd", EC_KANAL)Der Adapter bildet die Feldnamen der Schnittstelle an einer Stelle auf die Form der Bausteine ab. Ändert sich die API, ändert sich eine Datei — nicht jeder Baustein.
Der Warenkorb lebt im Browser (localStorage, einer je Kanal). Er reserviert nichts, kostet nichts und enthält keine Person.
Webshop: die Übergabe zur Kasse
const antwort = await fetch(`/api/laden/kasse?kanal=${kanal}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
zeilen: lines.map((l) => ({
schluessel: l.productKey,
menge: l.quantity,
ausfuehrung: l.variantKey ?? null,
})),
}),
})
const { uebergabe } = await antwort.json()
window.location.href = uebergabeDie Antwort ist eine signierte Adresse: HMAC-signiert, Organisation und Kanal mitsigniert, mit Frist. Sie lässt sich nicht raten und nicht verändern, und sie öffnet nichts ausser einem Korb. Der Gast klickt dort weiter — Adresse, Versandart, Rabattcode, Steuer, Bestandsreservierung, Zahlung und Bestätigung gehören ab da der E-Commerce-App.
Bauen Sie hier kein Adressformular. Es wäre eine zweite Kasse, und die seltener benutzte driftet: bei der Steuer, beim Nachlass, bei der Reservierung. Eine Kartenzahlung endet ohnehin beim Anbieter; Rechtstexte gehören auf Ihre Seite.
Bei einem Fehlschlag den Warenkorb stehen lassen. Wer hier scheitert, soll es noch einmal versuchen können, ohne alles neu zu suchen.
Buchungen: drei Schritte, nicht einer
// 1 — freie Zeiten. Was ein Event oder eine Firmenanfrage belegt,
// steht gar nicht erst in der Liste.
const zeiten = await holeZeiten(strecke, von, bis)
// 2 — reservieren. Hält den Platz für die eingestellte Frist.
const res = await reserviere({ strecke, start, gruppengroesse })
// 3 — buchen. Ist etwas sofort fällig, kommt die Zahladresse mit.
const buchung = await buche({ strecke, reservierung: res.marke, name, email, … })Wer Schritt 1 und 2 zusammenlegt, zwingt den Gast, das Formular auszufüllen, bevor klar ist, ob die Zeit überhaupt noch frei ist.
Die Belegung gilt in beide Richtungen: was hier gebucht wird, sperrt den Ort auch für ein Event — und umgekehrt. Dahinter steht eine EXCLUDE-Bedingung in der Datenbank, kein Code, den jemand vergessen kann. Genau das konnte das CMS-eigene Buchungssystem nicht, und deshalb gibt es es nicht mehr.
Buchungen: die Rückkehr vom Anbieter
const befund = await pruefeZahlung({ storno, vorgang })
if (befund?.zustand === "bezahlt") { /* … */ }Bezahlt ist nicht, was der Browser sagt. Eine Rückkehradresse kann jeder aufrufen. zahlung-pruefen fragt den Anbieter; erst dessen Antwort zählt. Die verbindliche Buchung entsteht ohnehin über den Webhook der Termine-App, nicht über den Browser — die Prüfung sagt dem Gast nur, was er gerade sehen soll.
Die Fallen gegen Maschinen
Eine Buchungsstrecke kann ein Honigtopf-Feld führen: für Menschen unsichtbar, für Sprachausgaben ausgeblendet, für Tastaturen übersprungen. Ist es gefüllt, wird die Buchung verworfen — mit demselben Wortlaut wie eine abgelaufene Reservierung, damit ein Skript nicht lernt, was es verraten hat.
Die öffentlichen Vorgänge sind zusätzlich ratenlimitiert, getrennt nach Lesen und Schreiben, und die Begrenzung greift vor der ersten Datenbankabfrage.
Fertige Bausteine
Die Vorlagen bringen product-grid, product-detail, cart, mini-cart, checkout und booking mit — ausdrücklich zum Umgestalten. Sie lesen ausschliesslich über die beiden Adapter und die beiden Routen; solange das so bleibt, kann die Oberfläche aussehen, wie sie will.
| Vorlage | Stil |
|---|---|
component-library |
der vollständige Katalog, Tailwind mit Marken-Tokens |
shop-storefront |
fertiger Laden |
restaurant-alpenblick |
Restaurant mit Tischreservierung |
frontend-starter |
schlank, semantische Klassennamen statt Utility-Klassen |
Die Feldnamen heissen kanal und strecke. Die alten Namen (shop_key, resource_key) werden weiterhin gelesen, damit bestehende Seiten nicht plötzlich leer rendern.