---
name: site-building
description: Baue für Spaicely eine statische Website, die sich leicht finden, teilen und benutzen lässt, mit sitemap.xml, robots.txt, llms.txt, Linkvorschauen für soziale Medien, strukturierten Daten und Icons. Verwende das immer, wenn du eine Website baust oder überarbeitest, die auf Spaicely gehostet wird, auch wenn du eine bestehende Website (WordPress, Wix, Squarespace) zu Spaicely umziehst.
---

# Eine Website bauen, die Menschen und Maschinen finden

Spaicely hostet, was du hochlädst, so wie es ist. Was eine gute Website über ihre Seiten hinaus braucht, machst du selbst. Diese Liste erwarten wir von jeder Website; nach jeder Veröffentlichung zeigt `sites.versions` einen Bericht, dessen `hints` sagen, was noch fehlt, jeweils mit einem `fix`.

## Frag zuerst

- **Die Domain.** Sitemaps, kanonische Links und Linkvorschauen brauchen vollständige Adressen wie `https://bakkerijjansen.nl/over/`. Nimm die Domain, die mit der Website verbunden ist (`account.whoami`, `domains.status`). Noch keine Domain? Frag, welche es wird, und nimm diese.
- **Sprachen.** Eine Sprache, oder mehrere in Ordnern (`/`, `/en/`, `/de/`).
- **Die Fakten.** Name, was das Unternehmen macht, Adresse, Telefon, Öffnungszeiten, Konten in sozialen Medien. Sie kommen in die Seiten und in die strukturierten Daten. Erfinde nie eine Adresse, Öffnungszeiten oder Bewertungen.

## Dateien im Stammverzeichnis

| Datei | Wofür sie da ist |
| --- | --- |
| `index.html` | Die Startseite. Jede andere Seite ist ein Ordner mit eigener `index.html` (`/over/index.html`), verlinkt als `/over/`. |
| `404.html` | Für Adressen, die es nicht gibt, im Stil und in der Sprache der Website, mit einem Link zur Startseite. |
| `robots.txt` | Lässt alle herein und nennt die Sitemap. |
| `sitemap.xml` | Jede Seite, die gefunden werden soll, mit vollständiger URL. |
| `llms.txt` | Eine kurze Zusammenfassung der Website in Markdown für KIs, nach https://llmstxt.org. |
| `favicon.ico`, `icon.svg`, `apple-touch-icon.png` | Das Icon in Tabs, Lesezeichen und auf Handys (apple-touch-icon ist 180x180). |
| `site.webmanifest` | Name, Farben und Icons (192 und 512 px), wenn jemand die Website zum Startbildschirm hinzufügt. |
| `og.jpg` | Das Bild für Linkvorschauen, 1200x630, unter 300 KB. Eine Seite kann ein eigenes haben. |

Fehlt `robots.txt`, `sitemap.xml` oder `llms.txt`, erstellt Spaicely beim Veröffentlichen eine einfache Version aus den Seiten (die Sitemap erst, wenn eine Domain verbunden ist), und `sites.versions` meldet `made_robots_txt`, `made_sitemap` oder `made_llms_txt`. Das ist ein Sicherheitsnetz: Erstelle sie selbst, dann gilt immer deine Datei.

Für Teile, die viele Websites brauchen (ein Cookie-Hinweis, Formulare, die auf der Seite bleiben, Videos und Karten, die erst nach einem Klick laden), gibt es Bausteine: `blocks.list` nennt sie, `blocks.get` gibt einen mit Dateien, einem HTML-Snippet und Hinweisen. Verwende sie, statt dasselbe selbst zu schreiben. Gib die `deploy_files` eines Bausteins in `sites.deploy` mit: Spaicely holt die Dateien, und die Website bekommt eine eigene Kopie auf ihrer eigenen Domain. Ohne MCP stehen sie unter https://spaicely.com/blocks/index.json.

`robots.txt`:

```
User-agent: *
Allow: /

Sitemap: https://bakkerijjansen.nl/sitemap.xml
```

`sitemap.xml`, mit `lastmod` als dem Datum, an dem sich die Seite zuletzt wirklich geändert hat:

```xml
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <url><loc>https://bakkerijjansen.nl/</loc><lastmod>2026-10-26</lastmod></url>
  <url><loc>https://bakkerijjansen.nl/over/</loc><lastmod>2026-10-26</lastmod></url>
</urlset>
```

Lass `404.html` und Seiten mit `noindex` weg. Aktualisiere die Sitemap, sobald du eine Seite hinzufügst oder entfernst.

`llms.txt`:

```markdown
# Bakkerij Jansen

> Bakery in Haarlem since 1952: bread, pastry and cakes to order. Open Tuesday to Saturday.

- [About us](https://bakkerijjansen.nl/over/): the family and how we bake
- [Cakes to order](https://bakkerijjansen.nl/taarten/): prices and how to order
- [Contact](https://bakkerijjansen.nl/contact/): address, opening hours, phone
```

## Der Head jeder Seite

```html
<!doctype html>
<html lang="nl">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Taarten op bestelling | Bakkerij Jansen</title>
  <meta name="description" content="Verjaardag, bruiloft of zomaar: bestel uw taart twee dagen vooraf. Ophalen in Haarlem.">
  <link rel="canonical" href="https://bakkerijjansen.nl/taarten/">

  <meta property="og:type" content="website">
  <meta property="og:site_name" content="Bakkerij Jansen">
  <meta property="og:title" content="Taarten op bestelling">
  <meta property="og:description" content="Bestel uw taart twee dagen vooraf.">
  <meta property="og:url" content="https://bakkerijjansen.nl/taarten/">
  <meta property="og:image" content="https://bakkerijjansen.nl/og.jpg">
  <meta property="og:image:width" content="1200">
  <meta property="og:image:height" content="630">
  <meta property="og:locale" content="nl_NL">
  <meta name="twitter:card" content="summary_large_image">

  <link rel="icon" href="/favicon.ico" sizes="32x32">
  <link rel="icon" href="/icon.svg" type="image/svg+xml">
  <link rel="apple-touch-icon" href="/apple-touch-icon.png">
  <link rel="manifest" href="/site.webmanifest">
  <meta name="theme-color" content="#7a3e1d">
  <link rel="alternate" type="text/markdown" href="/llms.txt">
</head>
```

- Jede Seite hat einen eigenen Titel (etwa 50 bis 60 Zeichen, das Thema zuerst, der Name der Website zuletzt) und eine eigene Beschreibung (etwa 120 bis 160 Zeichen, geschrieben für einen Menschen, der überlegt, ob er klickt).
- `canonical`, `og:url`, `og:image` und die Einträge in der Sitemap verwenden immer die vollständige `https://`-Adresse auf der Domain des Kunden, mit demselben abschließenden Schrägstrich wie die Links.
- Eine Seite, die nicht in Suchergebnissen erscheinen soll (eine Dankeseite nach einem Formular), bekommt `<meta name="robots" content="noindex">` und bleibt aus der Sitemap.

## Strukturierte Daten

Beschreib das Unternehmen auf der Startseite in JSON-LD, damit Suchmaschinen Adresse, Öffnungszeiten und Logo zeigen können. Wähl den genauesten Typ von https://schema.org (`Bakery`, `Restaurant`, `Dentist`, `LocalBusiness`, oder `Organization` ohne Ladengeschäft) und füg `WebSite` hinzu:

```html
<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@graph": [
    {
      "@type": "Bakery",
      "@id": "https://bakkerijjansen.nl/#business",
      "name": "Bakkerij Jansen",
      "url": "https://bakkerijjansen.nl/",
      "logo": "https://bakkerijjansen.nl/icon-512.png",
      "image": "https://bakkerijjansen.nl/og.jpg",
      "telephone": "+31 23 123 4567",
      "address": { "@type": "PostalAddress", "streetAddress": "Grote Markt 1", "postalCode": "2011 RD", "addressLocality": "Haarlem", "addressCountry": "NL" },
      "openingHoursSpecification": [
        { "@type": "OpeningHoursSpecification", "dayOfWeek": ["Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"], "opens": "08:00", "closes": "17:00" }
      ],
      "sameAs": ["https://www.instagram.com/bakkerijjansen", "https://www.facebook.com/bakkerijjansen"]
    },
    { "@type": "WebSite", "@id": "https://bakkerijjansen.nl/#website", "url": "https://bakkerijjansen.nl/", "name": "Bakkerij Jansen", "inLanguage": "nl" }
  ]
}
</script>
```

Blogbeiträge bekommen `Article` (mit `datePublished` und `author`), Produkte `Product` mit `offers`, Veranstaltungen `Event`. Zeichne nur aus, was der Besucher auch auf der Seite lesen kann. Der Baustein `faq` hat FAQPage-Daten in seinem Snippet, und `hours` liefert eine Tabelle mit Öffnungszeiten, die auch sagt, ob gerade geöffnet ist; halte die Tabelle und `openingHoursSpecification` gleich.

## Weiterleitungen

Kommt die Website von einer anderen Plattform oder ändert sich eine Adresse, leite die alten Adressen in einer Datei `_redirects` im Hauptordner weiter. Eine Zeile pro Weiterleitung:

```
# alt              neu                                Status (Standard 301)
/ueber-uns.php     /ueber-uns/
/blog/*            /neuigkeiten/
/shop              https://shop.example.de/           302
```

- Ein Pfad, der auf `/*` endet, leitet alles darunter zum selben Ziel weiter.
- Das Ziel ist ein Pfad auf derselben Website oder eine vollständige https-Adresse.
- 301 für dauerhaft (Suchmaschinen übernehmen dann die neue Adresse), 302 oder 307 für vorübergehend.
- Höchstens 50 Zeilen; die Weiterleitung geht vor einer Datei mit demselben Pfad.
- Die Datei selbst wird nicht veröffentlicht. Eine fehlerhafte Zeile steht mit ihrer Nummer in den Tipps der Version.

## Mehr als eine Sprache

- Ein Ordner pro Sprache, jede Seite mit gesetztem `lang` und derselben Struktur.
- Jede Version einer Seite nennt alle Versionen, sich selbst eingeschlossen, dazu `x-default`:

```html
<link rel="alternate" hreflang="nl" href="https://bakkerijjansen.nl/taarten/">
<link rel="alternate" hreflang="en" href="https://bakkerijjansen.nl/en/cakes/">
<link rel="alternate" hreflang="x-default" href="https://bakkerijjansen.nl/taarten/">
```

- Ein sichtbarer Sprachwechsel mit Links, nie eine automatische Weiterleitung nach der Browsersprache. Der Baustein `language` macht das und bietet die Sprache des Besuchers einmal an.
- `og:locale` pro Sprache; die Sitemap nennt jede Version.

## Schnell, lesbar, benutzbar

- Semantisches HTML: `header`, `nav`, `main`, `footer`, ein `h1` pro Seite, Überschriften in der richtigen Reihenfolge.
- Jedes `img` hat `alt` (leer, `alt=""`, bei Dekoration) sowie `width` und `height`. Bilder unterhalb des ersten Bildschirms bekommen `loading="lazy"`. Nimm WebP oder AVIF und bring Fotos auf die Größe, in der sie gezeigt werden, für scharfe Bildschirme höchstens doppelt so groß.
- Wenig JavaScript; die Website soll ohne funktionieren. Keine Frameworks für eine Website mit ein paar Seiten.
- Ein Menü, das sich auf dem Handy hinter einem Knopf einklappt: nimm den Baustein `nav`.
- Schriften auf der Website selbst (`.woff2`, `font-display: swap`), nicht von den Servern von Google Fonts: Wer sie von Google lädt, schickt die IP-Adresse des Besuchers an Google, wofür ein deutsches Gericht 2022 Schadensersatz zugesprochen hat.
- Genug Kontrast, sichtbarer Fokus, Links, die sagen, wohin sie führen, eine Telefonnummer als `tel:`-Link, eine Adresse mit Link zu einer Karte.
- Spaicely minimiert HTML, CSS und JavaScript schon und komprimiert JPEG und PNG beim Veröffentlichen neu. Bilder wandelt es nicht in andere Formate um.
- Browser behalten CSS, JavaScript, Bilder und Schriften einen Tag lang. Wenn du so eine Datei änderst, gib ihr einen neuen Namen (`style.v2.css`) und pass die Links an, damit Besucher die Änderung sofort sehen. HTML wird immer frisch geladen.

## Formulare

Auch eine statische Website kann Nachrichten und Anmeldungen empfangen. Spaicely verarbeitet sie auf `https://scripts.spaicely.com`; nimm keinen Formulardienst anderswo.

1. **Wähle die Art.** `contact` schickt jede Nachricht per Mail an eine Adresse; `signup` führt eine Liste (Newsletter, Warteliste) mit Double-Opt-in: Der Besucher bekommt eine Mail und steht erst nach einem Klick auf den Link darin auf der Liste.
2. **Schalte es ein** mit `scripts.enable`: `site_id`, `kind`, `name` (wie die Person es nennt), und wo es passt `locale` (Sprache der Mails und unserer Seiten), `subject`, `recipient`, `success_url` und bei signup `confirmed_url`. Ohne `recipient` mailt ein Kontaktformular an die Person, die es eingerichtet hat. Jede andere Adresse bekommt zuerst eine Mail zur Bestätigung; bis dahin hat das Formular den Status `waiting_for_recipient` und verschickt nichts, also sag der Person, dass sie in dieses Postfach schauen soll.
3. **Setze das `html` aus der Antwort unverändert in eine Seite** und gestalte es danach mit dem CSS der Website. Es sieht so aus:

```html
<form action="https://scripts.spaicely.com/f/k3v9q2x7m1c8b4n6r0td" method="post">
  <input type="text" name="_hp" tabindex="-1" autocomplete="off" aria-hidden="true" style="position:absolute;left:-9999px">
  <label>Naam <input name="name" required></label>
  <label>E-mailadres <input type="email" name="email" required></label>
  <label>Bericht <textarea name="message" rows="6" required></textarea></label>
  <button type="submit">Versturen</button>
</form>
```

   Lass `action`, `method` und das versteckte Feld `_hp` stehen (es fängt Bots). Du darfst Felder hinzufügen, etwa `telefon` oder `betreff`, mit jedem Namen, der nicht mit `_` beginnt; alle landen in der Mail. Das Feld `email` wird die Antwortadresse, eine Antwort geht also direkt an den Besucher. Behalte für jedes Feld ein sichtbares `label`.
4. **Erstelle die Dankeseiten.** `success_url` (nach dem Absenden) und `confirmed_url` (nach dem Bestätigen einer Anmeldung) sind Seiten der Website selbst, vollständige `https://`-Adressen auf der eigenen Domain, mit `noindex` und nicht in der Sitemap. Ohne sie sieht der Besucher eine schlichte Seite von uns in der Sprache des Formulars, mit einem Link zurück.
5. **Oder bleib auf der Seite** mit dem Baustein `forms` (`blocks.get`, `name: "forms"`): `forms.js` und `forms.css` in die Website, `<script src="/forms.js" defer></script>` vor `</body>`. Er sendet jedes Formular, das an Spaicely geht, ohne die Seite zu verlassen, und zeigt die Texte, die du ans Formular schreibst: `data-success`, `data-error`, `data-sending` und `data-code` vor einem Rabattcode, in der Sprache der Seite. Oder `data-success-url` für eine Dankeseite. Ein Download mit `delivery: "direct"` startet sofort, ein bezahltes Formular oder eine Spende geht weiter zu Stripe. Selbst schreiben geht auch: sende mit `fetch` und `Accept: application/json`; die Antwort ist `{"ok": true}` oder `{"ok": false, "error": ...}` mit `origin_not_allowed`, `form_inactive`, `invalid`, `rate_limited` oder `failed`. Ohne JavaScript funktioniert das normale Formular weiter.

**Ein Download gegen Kontaktdaten** (ein Leitfaden, Whitepaper oder eine Preisliste): nimm `kind: "download"`.

- Lege die Datei in der Website unter `_private/` ab, etwa `_private/leitfaden.pdf`, und veröffentliche sie mit dem Rest. Alles unter `_private/` bleibt außerhalb des CDN und ist über die Website nicht abrufbar; nur das Formular gibt es heraus.
- `scripts.enable` mit `kind: "download"`, `file_path: "_private/leitfaden.pdf"` und optional `recipient`. Was der Besucher ausfüllt, geht wie bei `contact` per Mail an diesen Empfänger. Das HTML hat Name, E-Mail-Adresse und Firma; füge Felder hinzu, wenn die Person mehr wissen will.
- `delivery: "mail"` (Standard) schickt den Download-Link an die eingegebene Adresse, damit die Angaben stimmen; sag auf der Dankesseite, dass der Link in der Mail steht. `delivery: "direct"` startet den Download sofort; mit `fetch` bekommst du `{"ok": true, "download_url": ...}`, dann setze `location.href = answer.download_url`.
- Ein Link funktioniert 7 Tage (direct: eine Stunde) und höchstens 5-mal. Solange die Datei nicht in der Live-Version ist, ist der Status `waiting_for_file` und das Formular gibt einen Fehler.

**Ein Rabattcode gegen eine E-Mail-Adresse**: nimm `kind: "coupon"`.

- Ein Code für alle: `coupon_code: "WILLKOMMEN10"`. Ein eigener Code pro Besucher: `coupon_codes: ["JANSEN-001", ...]` (lege sie vorher im Shop oder in der Kasse an; bis zu 1000 pro Aufruf, später mehr mit `scripts.configure`, `clear_coupons: true` entfernt die noch nicht vergebenen Codes).
- Der Empfänger bekommt per Mail, was der Besucher ausgefüllt hat und welchen Code er bekam, und eine Nachricht, wenn noch 10 Codes übrig sind und wenn sie aufgebraucht sind. Ohne Codes ist der Status `waiting_for_codes`.
- `delivery: "mail"` (Standard) schickt den Code an die eingegebene Adresse. `delivery: "direct"` geht nur mit Codes, die alle bekommen; mit `fetch` bekommst du `{"ok": true, "code": ...}`, um ihn auf der Seite zu zeigen.
- Codes mit Zeitraum: `dated_codes: [{"code": "WEEK45", "valid_from": "2026-11-03", "valid_until": "2026-11-09", "shared": true}]`. Mit `shared: true` bekommen alle, die in diesem Zeitraum fragen, denselben Code (ein Code pro Woche oder Aktion); ohne wird jeder Code einmal vergeben, und die Mail nennt, bis wann er gilt. Daten ohne Uhrzeit sind ganze Tage in niederländischer Zeit. Reihenfolge: zuerst ein gemeinsamer Code, der jetzt gilt, dann die einmaligen Codes, dann `coupon_code`.

**Für einen Download oder Code bezahlen**: gib einem Download- oder Rabattformular `price_cents` (inklusive MwSt., mindestens 50). Der Besucher bezahlt über Stripe an den Inhaber der Website, wie im Webshop: Tarif Shop, ein verbundenes Stripe-Konto und 2 % Provision. Nach dem Bezahlen gehen Link oder Code per Mail an den Käufer und die Angaben an den Empfänger.

- Das Formular sendet dann an `https://app.spaicely.com/forms/<form_id>/checkout`; nimm das neue `html` aus der Antwort. Ein normales Formular geht weiter zu Stripe; mit `fetch` (JSON) bekommst du `{"url": ...}` zum Öffnen.
- Das Pflicht-Häkchen `start_now` muss bleiben: Der Käufer möchte es sofort und verzichtet damit auf das Widerrufsrecht für digitale Inhalte. Ohne lehnt der Checkout ab.
- Solange etwas fehlt, nennt der Status es: `waiting_for_plan`, `waiting_for_billing_details` oder `waiting_for_stripe` (gib der Person dann `connect_url`). `price_cents: 0` macht das Formular wieder kostenlos.
- Der Inhaber ist der Verkäufer. Setze Firmenangaben, den Preis inkl. MwSt. und eine Seite zu Lieferung und Widerruf auf die Website, wie beim Webshop.

**Spenden**: `kind: "donation"` funktioniert in jedem Tarif. Der Besucher wählt einen Betragsknopf oder gibt einen Betrag ein und bezahlt über Stripe auf das eigene Konto des Eigentümers (einmal verbinden, 2 % Provision). Der Spender bekommt eine Dankesmail, der Empfänger eine Mail pro Spende.

- `donation_amounts` in Cent für die Knöpfe (Standard 10, 25 und 50 Euro), `donation_min_cents` und `donation_max_cents` für die Grenzen, `goal_cents` für ein Ziel und `ends_at` für den letzten Tag (danach nimmt das Formular nichts mehr an). Es gibt kein Häkchen zum Widerruf: Es wird nichts geliefert.
- Das Formular sendet `amount` (ein Knopf) oder `amount_other` (eingegeben), `name`, `email`, `message` und `show_name`. Nur mit `show_name` erscheinen Name und Nachricht im Fortschritt.
- Für einen Fortschrittsbalken: `fetch(progress_url)` liefert `{"raised_cents", "donors", "goal_cents", "ends_at", "open", "donations": [{"name", "message", "at"}]}`, offen für jede Origin und binnen einer Minute aktuell. Zeig den Balken als Prozent von `goal_cents` und schreib deutlich auf die Seite, wofür das Geld ist und dass es auch an den Eigentümer geht, wenn das Ziel nicht erreicht wird. Der Baustein `donation` macht das schon: setze die `progress_url` in `data-donation` und die Texte in der Sprache der Seite.
- Nicht für Anlagen, Darlehen oder Belohnungen in Geld; dafür braucht es eine Zulassung. Etwas zurückgeben (eine Nennung, eine Karte) ist in Ordnung.

Gut zu wissen:

- Ein Formular nimmt nur Sendungen von der verbundenen Domain der Website an (und deren www). In einer Vorschau oder einer lokalen Kopie antwortet es `origin_not_allowed`; teste es, wenn die Domain online ist.
- Grenzen: 10 Sendungen pro Besucher in 10 Minuten, 100 pro Formular pro Stunde und 3 Anmeldungen pro Adresse pro Stunde.
- Mails kommen von `no-reply@spaicely.com` mit dem Namen der Website als Absender.
- `scripts.list` zeigt die Formulare einer Website mit ihrem Status und noch einmal das HTML; `scripts.configure` ändert Texte, Empfänger oder Seiten oder schaltet ein Formular mit `enabled: false` aus.
- `scripts.subscribers` liefert die bestätigten Anmeldungen, jede mit einer `unsubscribe_url`. Jede Mail an die Liste muss diesen Link enthalten.
- Erwähne das Formular in der Datenschutzerklärung: was erhoben wird, dass Spaicely es verarbeitet und wie lange es aufbewahrt wird.
- Sitzungen und ein Warenkorb brauchen keinen Server: Speichere sie im Browser mit `localStorage`.

## Webshop

Mit dem Tarif Shop verkauft eine Website selbst. Der Käufer bezahlt über Stripe Checkout direkt an den Inhaber; Spaicely behält 2 % Provision ein. Ein Shopsystem ist nicht nötig: Die Produkte stehen in einer Datei.

1. **Stripe verbinden.** Der Inhaber des Kontos verbindet einmal ein eigenes Stripe-Konto. `shop.setup` gibt dafür die `connect_url`; schicke die Person dorthin. Bis dahin ist der Status `waiting_for_stripe`.
2. **Produkte in `data/products.json`.** Preise in Cent, inklusive MwSt. `id` ist eindeutig und ändert sich nicht; `active: false` nimmt ein Produkt aus dem Verkauf, ohne es zu löschen. Ohne `shipping` fragt Checkout keine Adresse ab (Dienstleistungen, digitale Produkte). Spaicely liest nur `id`, `name`, `price`, `image`, `active` und `variants`; jeder andere Schlüssel (Beschreibung, Farben, Material, verwandte Produkte, Kategorien) gehört der Website selbst, also schreib hinein, was die Seiten brauchen.

```json
{
  "products": [
    { "id": "mok-blauw", "name": "Blaue Tasse", "price": 1495, "image": "/img/mok-blauw.jpg", "description": "Handgedreht, 300 ml" },
    { "id": "bord", "name": "Teller", "price": 2000 }
  ],
  "shipping": [
    { "id": "nl", "name": "Versand in die Niederlande und nach Belgien", "price": 495, "countries": ["NL", "BE"] }
  ]
}
```

Größen und Farben sind **Varianten**: Jede Variante hat eine eigene `id`, und die kauft der Käufer; `options` beschreiben sie, `price`, `image` oder `name` nur, wenn sie vom Produkt abweichen (sonst heißt sie "Shirt (M / blue)"). Ein Produkt mit Varianten kauft man nicht über seine eigene id. Ein großer Katalog kommt in **Teile**: `parts` in `data/products.json` nennt weitere Dateien, jede `{ "products": [...] }` und höchstens 20 MB, und eine id kommt in allen Dateien zusammen nur einmal vor. Ein Shop verkauft bis zu 500 Produkte (Varianten zählen nicht mit). Seiten laden den Katalog dann nie ganz: `items_url` aus `shop.setup` liefert Name, Preis und Bestand nur der ids, die eine Seite braucht.

```json
{
  "parts": ["data/products/shirts.json", "data/products/mugs.json"],
  "shipping": [ { "id": "nl", "name": "...", "price": 495, "countries": ["NL", "BE"] } ]
}
```

```json
{ "products": [
  { "id": "shirt", "name": "Shirt", "price": 2500, "image": "/img/shirt.jpg", "material": "...", "related": ["mug-blue"],
    "variants": [
      { "id": "shirt-m-blue", "options": { "size": "M", "colour": "blue" } },
      { "id": "shirt-xl-blue", "options": { "size": "XL", "colour": "blue" }, "price": 2700 }
    ] }
] }
```

3. **Veröffentlichen und einschalten** mit `shop.setup` (`site_id`, und wo es passt `success_url`, `cancel_url` auf der eigenen Domain, `notify_email`). Die Antwort nennt den Status, was noch fehlt (`problems`), den `endpoint` und ein `snippet`: einen Kaufknopf als einfaches Formular. **Erst ausprobieren** geht mit `test_mode: true`: Der Shop funktioniert dann schon ohne Stripe. Der Checkout führt zu einer Testseite von Spaicely, auf der die testende Person wählt, ob die Zahlung gelingt, abgebrochen wird oder fehlschlägt; Bestand, Bestellmails (Betreff TEST) und Dankesseiten funktionieren echt, es wird nichts bezahlt und Testbestellungen sind nach 7 Tagen weg. Setze `test_mode: false` für echte Bestellungen.
4. **Ein Warenkorb**: nimm den Baustein `cart` (Knöpfe mit `data-cart-add`, ein Zähler, der Warenkorb mit Versandwahl und Bezahlen; Namen, Preise und Bestand über `items_url`, nur für die ids auf der Seite, und die Zahlen aktualisieren sich von selbst, sobald jemand anderes bezahlt oder der Eigentümer den Bestand ändert; mit `cart_hold` in `shop.setup` (`low` nur bei wenig Bestand, oder `always`) hält der Warenkorb die Stücke `cart_hold_minutes` lang fest, sobald sie hineinkommen, und der Baustein nimmt sie heraus, wenn die Zeit um ist; bei Größe oder Farbe nimmt der Knopf die id der Variante aus einem Select mit `data-cart-from`). Selbst schreiben geht auch: führe ihn im Browser (`localStorage`) und sende ihn mit `fetch`; die Antwort ist `{"url": ...}` für den Weg zu Stripe, oder `{"error": ..., "message": ...}` mit einem Text für den Besucher:

```html
<script>
  async function checkout(items, shipping) {
    const response = await fetch("https://app.spaicely.com/shop/SHOP_ID/checkout", {
      method: "POST", headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ items, shipping })
    });
    const answer = await response.json();
    if (answer.url) location.href = answer.url; else alert(answer.message);
  }
  // checkout([{ id: "mok-blauw", quantity: 2 }], "nl")
</script>
```

Gut zu wissen:

- Preise kommen immer aus der veröffentlichten `data/products.json`, nie von der Seite. Änderst du einen Preis, veröffentliche auch die Seiten, die ihn zeigen.
- **Bestand** führt Spaicely, nicht die Produktdatei: `shop.stock` setzt ihn pro id (`set` mit `quantity` und optional `alert_at`, `add` für eine Lieferung oder eine Zählung), und der Eigentümer kann dasselbe auf der Shopseite oder mit einer CSV. Eine id ohne Bestand geht nie aus. Der Bezahlvorgang hält die Stücke 30 Minuten fest; bezahlt zieht sie ab, ein abgelaufener Bezahlvorgang gibt sie zurück. Ist etwas aus, lehnt der Bezahlvorgang mit `out_of_stock` und `available` ab; der Baustein `cart` zeigt das an. Der Eigentümer bekommt eine Mail, wenn eine id bei `alert_at` ankommt und wenn sie ausverkauft ist.
- Bestellen geht nur von der verbundenen Domain (und www) oder der Adresse der Website; bis zu 50 Zeilen, 1 bis 99 Stück pro Produkt.
- Der Inhaber bekommt bei jeder bezahlten Bestellung eine Mail mit den Produkten und der Lieferadresse; `shop.orders` listet die Bestellungen. Spaicely schickt dem Käufer im Namen des Shops eine Bestellbestätigung in der Sprache des Shops: was gekauft wurde, die Summe, den Verkäufer (Name und Adresse des Kontos des Inhabers) und die Seiten in `terms_url` und `returns_url`. Ein Verkäufer muss eine Bestellung mit den AGB und der Widerrufsbelehrung bestätigen, also leg diese Seiten an und setz sie mit `shop.setup`; `notes` nennt `no_terms_url` oder `no_returns_url`, solange sie fehlen. Ein Shop, der selbst bestätigt, setzt `confirm_to_buyer: false`. Verschickt? `shop.orders.ship` markiert eine Bestellung als verschickt, mit Versanddienst und Sendungsnummer, und mailt dem Käufer im Namen des Shops; `shop.orders` filtert auf `to_ship`. Erstattungen macht der Inhaber in seinem Stripe-Dashboard: die Bestellung zeigt sie, und der Anteil der Provision geht zurück. `invoice_buyers: true` lässt Stripe dem Käufer eine Rechnung im Namen des Verkäufers schicken, `promotion_codes: true` bringt ein Feld für Rabattcodes aus dem Stripe-Dashboard des Inhabers in die Kasse. Der Prompt `open_shop` geht die ganze Einrichtung durch.
- Lege eine Dankeseite an (`success_url`, `noindex`) und erwähne in der Datenschutzerklärung, dass Stripe die Zahlung abwickelt.
- Der Inhaber ist der Verkäufer. Erstelle mit der Person eine Seite zu Lieferung, Versandkosten, Rücksendung und dem 14-tägigen Widerrufsrecht für Verbraucher (mit Muster-Widerrufsformular) sowie das Impressum (Name, Anschrift, Register- und USt-IdNr.), und verlinke sie im Footer und beim Bezahlen. Erstattungen macht der Inhaber in seinem Stripe-Dashboard; die Provision von Spaicely wird dann nicht erstattet.

## Eine bestehende Website umziehen (WordPress und andere)

Wer anderswo eine Website hat, kann sie von dir hier nachbauen lassen: dieselben Seiten und Adressen, als schlichtes schnelles HTML, ohne Plugins und Updates. Zieh nur eine Website um, die der Person gehört oder die sie umziehen darf.

1. **Bestand aufnehmen.** Lies die Sitemap (`/wp-sitemap.xml`, `/sitemap_index.xml` oder `/sitemap.xml`). Bei WordPress liefert die REST-API meist alles: `/wp-json/wp/v2/pages?per_page=100`, `/posts`, `/media` und `/categories` (weiterblättern mit `&page=2`). Pro Seite bekommst du `link`, `title`, `content.rendered` und, mit Yoast oder Rank Math, `yoast_head_json` mit Titel und Beschreibung für Suchmaschinen. Ist die API abgeschaltet, lies die Seiten selbst oder bitte die Person um einen Export (WordPress: Werkzeuge > Daten exportieren, eine XML-Datei). Erstelle eine Liste aller Adressen und zeig sie, bevor du baust.
2. **Nachbauen, nicht kopieren.** Übernimm pro Seite Text, Überschriften, Bilder und Links und schreib sauberes HTML in einem Theme im Stil der alten Website (oder in einem neuen Stil, wenn die Person das möchte). Lass Shortcodes, Pagebuilder-Markup, Plugin-Skripte und Tracking weg. Behalte pro Seite Titel und Beschreibung.
3. **Bilder.** Gib die Dateien aus `wp-content/uploads/` als `source_url` in `sites.deploy` mit (höchstens 20 pro Aufruf); Spaicely holt sie und verkleinert sie. Nimm das Original, kein Vorschaubild, und behalte die Alt-Texte.
4. **Adressen behalten.** `/ueber-uns/` wird `ueber-uns/index.html`, damit Links und Suchergebnisse weiter funktionieren. Was sich doch ändert, kommt mit 301 in `_redirects` (siehe Weiterleitungen). WordPress-Adressen, die es nicht mehr gibt (`/category/*`, `/tag/*`, `/author/*`, `/feed/`, `/?p=123`), gehen zur nächstliegenden Seite.
5. **Was ein Plugin tat.** Kontaktformulare (Contact Form 7, WPForms, Gravity Forms) werden ein Kontaktformular, ein Newsletter wird ein Anmeldeformular (siehe Formulare). WooCommerce wird ein Webshop im Shop-Tarif (siehe Webshop). Ein Blog wird eine Seite pro Beitrag plus eine Übersichtsseite. Kommentare, Suche und Logins kommen nicht mit: sag das, und übernimm bestehende Kommentare als Text, wenn die Person das möchte.
6. **Zeigen, bevor umgestellt wird.** Veröffentliche und lass die Person die Website unter ihrer eigenen Adresse prüfen (`https://<label>.spaicely.site`, sobald das Hosting bezahlt ist). Geh sie mit dem Prompt `launch_check` durch.
7. **Dann die Domain.** `sites.domains.connect` liest die aktuellen DNS-Einträge der Domain und übernimmt, was die Person behält, vor allem Mail (MX, SPF, DKIM), damit die Mail weiter funktioniert. Erst wenn die Website auf der eigenen Domain läuft, kann das alte Hosting gekündigt werden. Die Domain selbst kann mit `domains.transfer` auch zu Spaicely umziehen, muss aber nicht.

## Datenschutz und Cookies

- Eine Datenschutzseite, sobald die Website etwas erhebt: was, warum, wie lange, an wen man sich wendet. Geschäftliche Websites in der EU zeigen auch Firmenname, Adresse, Handelsregister und USt-IdNr. (in Deutschland und Österreich auf einer Seite `Impressum`).
- Kein Tracking ohne Einwilligung. Analytics, Werbepixel und eingebettete Videos, Karten und Social-Media-Beiträge, die Cookies setzen, dürfen erst laden, wenn der Besucher zustimmt, und Ablehnen muss so einfach sein wie Annehmen. Vermeide es, wo du kannst: ein Videobild, das den Player erst beim Klick lädt, ein Link zur Karte statt einer eingebetteten Karte.

### Analytics und andere Tags

Frag zuerst, ob die Person sie braucht. `sites.stats` zählt schon ohne Cookies die Anfragen pro Tag; das ist keine Besucheranalyse, reicht aber vielen kleinen Websites. Will jemand Google Analytics, Google Tag Manager, ein Pixel von Meta, LinkedIn oder TikTok, Hotjar oder Clarity, frag nach der ID (`G-...`, `GTM-...`, die Pixelnummer); erfinde nie eine.

Spaicely fügt deinen Seiten nichts hinzu. Eine statische Website hat keine gemeinsame Vorlage, also:

- Ein Tag gehört auf **jede** Seite, auch auf `404.html`, an dieselbe Stelle. Kommt später eine Seite dazu, nimm es mit.
- Jedes Tag wartet auf die Einwilligung: schreib es als `<script type="text/plain" data-consent>`. Solche Skripte überspringt der Browser; `consent.js` unten schaltet sie ein, sobald der Besucher annimmt. Lass das `<noscript>`-Bild oder -iframe weg, das manche Codes mitbringen: Es lädt ohne Einwilligung.
- Beim Google Tag Manager wartet nur der Container auf die Einwilligung; die Tags darin laden danach.

Google Analytics, im `<head>` jeder Seite:

```html
<script type="text/plain" data-consent async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"></script>
<script type="text/plain" data-consent>
  window.dataLayer = window.dataLayer || [];
  function gtag(){dataLayer.push(arguments);}
  gtag('js', new Date());
  gtag('config', 'G-XXXXXXXXXX');
</script>
```

Ein Pixel von Meta, LinkedIn oder anderen: der eigene Code, mit dem öffnenden `<script>` geändert in `<script type="text/plain" data-consent>`.

Vor `</body>` jeder Seite, mit Texten in der Sprache der Seite:

```html
<script src="/consent.js" defer
  data-message="Wir verwenden Cookies, um zu sehen, wie die Website genutzt wird."
  data-accept="Annehmen" data-reject="Ablehnen"
  data-privacy="/datenschutz/" data-privacy-label="Datenschutz"></script>
```

In der Fußzeile, damit Besucher ihre Meinung ändern können: `<button type="button" data-consent-open>Cookie-Einstellungen</button>`.

`consent.js` und `consent.css` sind der Baustein `consent`; schreib sie nicht selbst. Hol ihn mit `blocks.get` (`name: "consent"`) und gib seine `deploy_files` in `sites.deploy` mit, oder setz `{ "path": "consent.js", "source_url": "https://spaicely.com/blocks/consent/v1/consent.js" }` in die Dateiliste, ebenso `consent.css`. Setz `<link rel="stylesheet" href="/consent.css">` in den `<head>` jeder Seite. Der Baustein speichert die Wahl im Browser des Besuchers, lädt die wartenden Tags nach Annehmen, lädt nichts nach Ablehnen und lädt die Seite neu, wenn jemand die Einwilligung widerruft. Beide Schaltflächen wirken gleich wichtig; die Farben änderst du mit `--consent-bg` und `--consent-ink` im CSS der Website.

Nenne jedes Tag auf der Datenschutzseite: welcher Dienst, wofür, und dass es erst nach Einwilligung lädt. Nach dem Veröffentlichen meldet `sites.versions` `tracking_without_consent` bei einem Tracker, der sofort lädt, und `tag_missing_on_page` bei einer Seite, der ein Tag fehlt, das die anderen Seiten haben.

### Videos und Karten

Ein einfaches iframe von YouTube oder Google Maps lädt Google, sobald die Seite öffnet, ohne Einwilligung. Verwende den Baustein `embeds`: Video oder Karte laden erst, wenn der Besucher auf die Schaltfläche klickt, und bis dahin geht nichts an YouTube, Vimeo, Google oder OpenStreetMap. `data-embed` ist `youtube`, `vimeo`, `map` (OpenStreetMap, mit `data-lat`, `data-lon`, `data-zoom`) oder `google-map` (mit `data-query`). Setz `data-title`, `data-button` und `data-note` (woher es kommt) in der Sprache der Seite, und lass einen normalen Link im Feld für Besucher ohne JavaScript. Nimm kein Vorschaubild von YouTube: Das lädt Google auch; `data-poster` mit einem eigenen Bild geht.

## Wenn sie online ist

- Jede bezahlte Website ist auch unter einer eigenen Adresse erreichbar, `https://<label>.spaicely.site` (`address` in `account.whoami` und `sites.versions`; `address_live` sagt, ob sie funktioniert). Sie funktioniert, sobald das Hosting bezahlt ist und eine Version online ist, auch ohne verbundene Domain: praktisch, um die Website zu zeigen, bevor die Domain bereit ist. Suchmaschinen bekommen dort noindex, also zeigen Canonical-Links und Sitemap weiter auf die eigene Domain.
- Lies die `hints` in `sites.versions` und behebe, was zutrifft.
- Schlag vor, dass die Person die Domain in der Google Search Console und den Bing Webmaster Tools hinzufügt und dort die Sitemap einreicht. Beide bestätigen eine Domain mit einem TXT-Eintrag, den du mit `domains.dns.update` setzen kannst.
- Aktualisiere nach einer großen Änderung `lastmod` in der Sitemap für die geänderten Seiten.

## Mehr

- Hochladen, veröffentlichen und zurückgehen, einen Block einer Seite ändern, die Website auf ihre Domain bringen, DNS, Mail-Weiterleitung, Personen und Rollen, der Hosting-Tarif: der Skill `site-management`, https://spaicely.com/skills/de/site-management/SKILL.md.
- Jedes Tool, jeder Parameter und jeder HTTP-Endpunkt: https://spaicely.com/developers/, und für KIs https://spaicely.com/llms-full.txt.
- Steht ein dort genanntes Tool nicht in deiner Liste, ist deine Liste veraltet: Bitte die Person, den Spaicely-Connector neu zu verbinden, das lädt die aktuelle.
