diff --git a/.env b/.env
index 02222bb..a8df2a9 100644
--- a/.env
+++ b/.env
@@ -22,3 +22,4 @@ STAGING_URL=https://dev-__PROJECT_NAME__.staging.testversion.online
CODING_URL=https://__PROJECT_NAME__.code.testversion.online
#START_SCRIPT=:ssr
+MOCK=1
diff --git a/.gitignore b/.gitignore
index 0981d04..d6c6041 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,6 +12,7 @@ playwright-report/
playwright/.cache/
visual-review/
video-tours/output/
+.playwright-mcp/
.yarn/*
!.yarn/cache
!.yarn/patches
diff --git a/.yarn/install-state.gz b/.yarn/install-state.gz
index 61d48af..340afc2 100644
Binary files a/.yarn/install-state.gz and b/.yarn/install-state.gz differ
diff --git a/AGENTS.md b/AGENTS.md
index 0737045..493cea2 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -67,3 +67,23 @@ Tibi CMS starter template — Svelte 5 SPA with esbuild, SSR via goja, and Playw
- Avoid introducing new dependencies unless absolutely necessary.
- Respect a11y and localization best practices; optimize for WCAG AA standards.
- Check the problems tab for any errors or warnings in the code.
+- **Zero warnings policy**: After making changes, always run `yarn validate` and check the IDE problems tab. Fix all TypeScript, Svelte, and Tailwind warnings — the codebase must stay warning-free.
+- When Tailwind `suggestCanonicalClasses` warnings appear, always fix the **source** `.svelte`/`.ts`/`.css` file — never the compiled output.
+
+### Tailwind CSS 4 canonical classes
+
+This project uses Tailwind CSS 4. Always use the canonical v4 syntax:
+
+| Legacy (v3) | Canonical (v4) |
+| ---------------------- | ---------------------- |
+| `bg-gradient-to-*` | `bg-linear-to-*` |
+| `aspect-[4/3]` | `aspect-4/3` |
+| `!bg-brand-600` | `bg-brand-600!` |
+| `hover:!bg-brand-700` | `hover:bg-brand-700!` |
+| `!rounded-xl` | `rounded-xl!` |
+
+Rules:
+
+- Gradient directions use `bg-linear-to-{direction}` instead of `bg-gradient-to-{direction}`.
+- Bare-ratio aspect values like `aspect-4/3` replace arbitrary `aspect-[4/3]`.
+- The `!important` modifier is a **suffix** (`class!`) instead of a **prefix** (`!class`).
diff --git a/README.md b/README.md
index f76ca27..42c2b21 100644
--- a/README.md
+++ b/README.md
@@ -2,6 +2,33 @@
Starter Kit für SPAs(s) `;)` mit Svelte und TibiCMS inkl. SSR
+## Neues Projekt starten
+
+Nachdem du dieses Repository als Vorlage geklont hast, passe die Platzhalter an dein Projekt an:
+
+1. **`.env`**: Ersetze `__PROJECT_NAME__` mit deinem Projektnamen (z.B. `mein-projekt`).
+ Die folgenden URLs werden automatisch abgeleitet:
+ - `CODING_URL=https://mein-projekt.code.testversion.online`
+ - `STAGING_URL=https://dev-mein-projekt.staging.testversion.online`
+2. **`frontend/spa.html`** / **`api/templates/spa.html`**: Ersetze `__PROJECT_TITLE__` mit dem Seitentitel.
+3. **`api/config.yml.env`**: Passe `ADMIN_TOKEN`, Datenbank-Name und weitere Secrets an.
+4. **`docker-compose-local.yml`**: Suche nach `project_name__` und ersetze mit deinem Projektnamen (Container-Benennung).
+5. **Demo-Inhalte entfernen**: Die Demo-Seite besteht aus diesen Dateien, die für ein echtes Projekt entfernt/ersetzt werden können:
+ - `frontend/src/blocks/` — Block-Komponenten (HeroBlock, FeaturesBlock, RichtextBlock, AccordionBlock, ContactFormBlock, BlockRenderer, NotFound)
+ - `frontend/mocking/content.json` — Demo-Mockdaten für Content
+ - `frontend/mocking/navigation.json` — Demo-Mockdaten für Navigation
+ - `api/collections/content.yml` — Content-Collection-Konfiguration
+ - `api/collections/navigation.yml` — Navigation-Collection-Konfiguration
+ - `tests/e2e/demo.spec.ts` — Demo-E2E-Tests
+ - `video-tours/tours/demo-showcase.tour.ts` — Demo-Video-Tour
+6. **`frontend/src/App.svelte`**: Passe Header, Footer und Content-Loading an dein Datenmodell an.
+
+```sh
+# Platzhalter ersetzen (Beispiel für Linux/Mac):
+sed -i 's/__PROJECT_NAME__/mein-projekt/g' .env
+sed -i 's/__PROJECT_TITLE__/Mein Projekt/g' frontend/spa.html api/templates/spa.html
+```
+
## Wozu?
Via Svelte wird eine SPA (Single-Page-App) programmiert. Dazu wird der Code einmal für den Browser aufgebreitet und außerdem für den Server kompiliert und transpiliert. Der Server-Code wird in einem tibi-server SSR-Hook (server side rendering) eingebunden und generiert dort fertiges HTML anhand der aktuelle Route für SEO und optimierte Ladezeiten.
diff --git a/api/collections/content.yml b/api/collections/content.yml
new file mode 100644
index 0000000..b08adc5
--- /dev/null
+++ b/api/collections/content.yml
@@ -0,0 +1,171 @@
+########################################################################
+# Content collection — CMS-managed pages with pagebuilder blocks
+########################################################################
+
+name: content
+meta:
+ label: { de: "Inhalte", en: "Content" }
+ muiIcon: article
+ rowIdentTpl: { twig: "{{ name }}" }
+
+ views:
+ - type: simpleList
+ mediaQuery: "(max-width: 600px)"
+ primaryText: name
+ secondaryText: lang
+ tertiaryText: path
+ - type: table
+ columns:
+ - name
+ - source: lang
+ filter: true
+ - source: type
+ filter: true
+ - source: path
+ - source: active
+ filter: true
+
+permissions:
+ public:
+ methods:
+ get: true
+ user:
+ methods:
+ get: true
+ post: true
+ put: true
+ delete: true
+
+fields:
+ - name: active
+ type: boolean
+ meta:
+ label: { de: "Aktiv", en: "Active" }
+ - name: type
+ type: string
+ meta:
+ label: { de: "Typ", en: "Type" }
+ - name: lang
+ type: string
+ meta:
+ label: { de: "Sprache", en: "Language" }
+ - name: translationKey
+ type: string
+ meta:
+ label: { de: "Übersetzungsschlüssel", en: "Translation Key" }
+ - name: name
+ type: string
+ meta:
+ label: { de: "Name", en: "Name" }
+ - name: path
+ type: string
+ meta:
+ label: { de: "Pfad", en: "Path" }
+ - name: alternativePaths
+ type: object[]
+ meta:
+ label: { de: "Alternative Pfade", en: "Alternative Paths" }
+ subFields:
+ - name: path
+ type: string
+ - name: thumbnail
+ type: string
+ meta:
+ label: { de: "Vorschaubild", en: "Thumbnail" }
+ - name: teaserText
+ type: string
+ meta:
+ label: { de: "Teasertext", en: "Teaser Text" }
+ - name: blocks
+ type: object[]
+ meta:
+ label: { de: "Inhaltsblöcke", en: "Content Blocks" }
+ subFields:
+ - name: hide
+ type: boolean
+ - name: type
+ type: string
+ - name: headline
+ type: string
+ - name: headlineH1
+ type: boolean
+ - name: subline
+ type: string
+ - name: tagline
+ type: string
+ - name: anchorId
+ type: string
+ - name: containerWidth
+ type: string
+ - name: background
+ type: object
+ subFields:
+ - name: color
+ type: string
+ - name: image
+ type: string
+ - name: padding
+ type: object
+ subFields:
+ - name: top
+ type: string
+ - name: bottom
+ type: string
+ - name: callToAction
+ type: object
+ subFields:
+ - name: buttonText
+ type: string
+ - name: buttonLink
+ type: string
+ - name: buttonTarget
+ type: string
+ - name: heroImage
+ type: object
+ subFields:
+ - name: image
+ type: string
+ - name: text
+ type: string
+ - name: imagePosition
+ type: string
+ - name: imageRounded
+ type: string
+ - name: image
+ type: string
+ - name: accordionItems
+ type: object[]
+ subFields:
+ - name: question
+ type: string
+ - name: answer
+ type: string
+ - name: open
+ type: boolean
+ - name: imageGallery
+ type: object
+ subFields:
+ - name: images
+ type: object[]
+ subFields:
+ - name: image
+ type: string
+ - name: caption
+ type: string
+ - name: showCaption
+ type: boolean
+ - name: showImageCaption
+ type: boolean
+ - name: imageCaption
+ type: string
+ - name: meta
+ type: object
+ meta:
+ label: { de: "SEO", en: "SEO" }
+ subFields:
+ - name: title
+ type: string
+ - name: description
+ type: string
+ - name: keywords
+ type: string
diff --git a/api/collections/navigation.yml b/api/collections/navigation.yml
new file mode 100644
index 0000000..5cc588b
--- /dev/null
+++ b/api/collections/navigation.yml
@@ -0,0 +1,68 @@
+########################################################################
+# Navigation collection — header and footer navigation entries
+########################################################################
+
+name: navigation
+meta:
+ label: { de: "Navigation", en: "Navigation" }
+ muiIcon: menu
+ rowIdentTpl: { twig: "{{ type }} ({{ language }})" }
+
+ views:
+ - type: simpleList
+ mediaQuery: "(max-width: 600px)"
+ primaryText: type
+ secondaryText: language
+ - type: table
+ columns:
+ - source: type
+ filter: true
+ - source: language
+ filter: true
+
+permissions:
+ public:
+ methods:
+ get: true
+ user:
+ methods:
+ get: true
+ post: true
+ put: true
+ delete: true
+
+fields:
+ - name: language
+ type: string
+ meta:
+ label: { de: "Sprache", en: "Language" }
+ - name: type
+ type: string
+ meta:
+ label: { de: "Typ", en: "Type" }
+ helperText: { de: "header oder footer", en: "header or footer" }
+ - name: elements
+ type: object[]
+ meta:
+ label: { de: "Elemente", en: "Elements" }
+ subFields:
+ - name: name
+ type: string
+ meta:
+ label: { de: "Bezeichnung", en: "Label" }
+ - name: page
+ type: string
+ meta:
+ label: { de: "Seite (Content-ID)", en: "Page (Content ID)" }
+ - name: external
+ type: boolean
+ meta:
+ label: { de: "Externer Link", en: "External Link" }
+ - name: externalUrl
+ type: string
+ meta:
+ label: { de: "Externe URL", en: "External URL" }
+ - name: hash
+ type: string
+ meta:
+ label: { de: "Anker", en: "Anchor" }
diff --git a/api/config.yml b/api/config.yml
index bb0d213..1c61b94 100644
--- a/api/config.yml
+++ b/api/config.yml
@@ -9,6 +9,8 @@ meta:
" "
collections:
+ - !include collections/content.yml
+ - !include collections/navigation.yml
- !include collections/ssr.yml
assets:
diff --git a/frontend/mocking/content.json b/frontend/mocking/content.json
index 813386f..8ce1d5e 100644
--- a/frontend/mocking/content.json
+++ b/frontend/mocking/content.json
@@ -1,7 +1,7 @@
[
{
- "id": "home",
- "_id": "home",
+ "id": "home-de",
+ "_id": "home-de",
"active": true,
"type": "page",
"lang": "de",
@@ -11,49 +11,231 @@
"blocks": [
{
"type": "hero",
- "headline": "Willkommen",
+ "headline": "Moderne Webprojekte. Blitzschnell umgesetzt.",
"headlineH1": true,
- "subline": "Demo-Startseite des Starter-Templates",
+ "subline": "Tibi CMS Starter — Svelte 5, Tailwind CSS 4, SSR und eine API, die einfach funktioniert.",
+ "containerWidth": "full",
+ "callToAction": {
+ "buttonText": "Features entdecken",
+ "buttonLink": "#features",
+ "buttonTarget": ""
+ },
"heroImage": {
- "image": "demo-hero"
+ "externalUrl": "https://images.unsplash.com/photo-1451187580459-43490279c0fa?w=1920&q=80"
}
},
+ {
+ "type": "features",
+ "headline": "Was dieses Template kann",
+ "tagline": "Features",
+ "anchorId": "features",
+ "padding": { "top": "lg", "bottom": "lg" },
+ "text": "
⚡
Svelte 5 Runes Reaktives UI mit $state, $derived und $effect — kein Boilerplate, maximale Performance.
🎨
Tailwind CSS 4 Utility-first Styling mit Custom-Theme, Dark-Mode-ready und blitzschnellen Builds.
🔌
Tibi CMS API Collections, Hooks, Medialib — alles über eine REST-API. Mit Mock-Modus für offline-Entwicklung.
🌍
i18n Built-in Mehrsprachigkeit aus der Box: URL-basierte Sprachauswahl, Lazy-Loaded Locales, SSR-kompatibel.
🖥️
SSR via goja Server-Side Rendering in Go — schnelle Erstauslieferung, SEO-freundlich, mit Cache-Invalidierung.
🧪
Playwright Tests E2E, API, Visual Regression und Video-Tours — alles vorkonfiguriert und ready to go.
"
+ },
{
"type": "richtext",
- "headline": "Über uns",
- "text": "Lorem ipsum dolor sit amet, consectetur adipiscing elit.
"
+ "headline": "So funktioniert's",
+ "tagline": "Workflow",
+ "anchorId": "workflow",
+ "padding": { "top": "lg", "bottom": "sm" },
+ "externalImageUrl": "https://images.unsplash.com/photo-1555066931-4365d14bab8c?w=800&q=80",
+ "imagePosition": "right",
+ "text": "Starte die Entwicklungsumgebung mit make docker-up && make docker-start. Der esbuild-Watcher kompiliert Änderungen in Echtzeit, BrowserSync lädt den Browser automatisch neu.
Für Offline-Entwicklung aktiviere den Mock-Modus mit MOCK=1 in der .env. Content wird über die Tibi-API geladen und mit dem BlockRenderer dargestellt.
Jeder Block-Typ (Hero, Richtext, Accordion, Features) ist eine eigene Svelte-Komponente — erweiterbar und austauschbar .
"
+ },
+ {
+ "type": "accordion",
+ "headline": "Häufige Fragen",
+ "tagline": "FAQ",
+ "anchorId": "faq",
+ "padding": { "top": "sm", "bottom": "lg" },
+ "accordionItems": [
+ {
+ "question": "Wie starte ich ein neues Projekt mit diesem Template?",
+ "answer": "Klone das Repository, passe .env an und starte mit make docker-up && make docker-start. Die Demo-Inhalte kannst du einfach durch echte Inhalte ersetzen.
",
+ "open": true
+ },
+ {
+ "question": "Brauche ich einen laufenden Tibi-Server für die Entwicklung?",
+ "answer": "Nein! Mit MOCK=1 in der .env werden API-Aufrufe gegen lokale JSON-Dateien aufgelöst. So kannst du Frontend-Features ohne Backend entwickeln.
"
+ },
+ {
+ "question": "Wie füge ich eine neue Seite hinzu?",
+ "answer": "Erstelle einen neuen Content-Eintrag in der Collection (oder in mocking/content.json für Mock-Modus) mit dem gewünschten Pfad und Blöcken. Die App rendert ihn automatisch über den BlockRenderer.
"
+ },
+ {
+ "question": "Kann ich eigene Block-Typen erstellen?",
+ "answer": "Ja! Erstelle eine neue Svelte-Komponente und registriere den Typ im BlockRenderer. Das Type-Interface ContentBlockEntry in global.d.ts kannst du entsprechend erweitern.
"
+ }
+ ]
}
],
"meta": {
- "title": "Startseite",
- "description": "Demo-Startseite"
+ "title": "Tibi Svelte Starter — Modernes CMS-Template",
+ "description": "Svelte 5, Tailwind CSS 4, SSR, i18n und Playwright-Tests — das perfekte Starterkit.",
+ "keywords": "svelte, tibi, cms, starter, template"
}
},
{
- "id": "about",
- "_id": "about",
+ "id": "home-en",
+ "_id": "home-en",
+ "active": true,
+ "type": "page",
+ "lang": "en",
+ "translationKey": "home",
+ "name": "Home",
+ "path": "/",
+ "blocks": [
+ {
+ "type": "hero",
+ "headline": "Modern web projects. Lightning fast.",
+ "headlineH1": true,
+ "subline": "Tibi CMS Starter — Svelte 5, Tailwind CSS 4, SSR and an API that just works.",
+ "containerWidth": "full",
+ "callToAction": {
+ "buttonText": "Explore features",
+ "buttonLink": "#features",
+ "buttonTarget": ""
+ },
+ "heroImage": {
+ "externalUrl": "https://images.unsplash.com/photo-1451187580459-43490279c0fa?w=1920&q=80"
+ }
+ },
+ {
+ "type": "features",
+ "headline": "What this template offers",
+ "tagline": "Features",
+ "anchorId": "features",
+ "padding": { "top": "lg", "bottom": "lg" },
+ "text": "⚡
Svelte 5 Runes Reactive UI with $state, $derived and $effect — no boilerplate, maximum performance.
🎨
Tailwind CSS 4 Utility-first styling with custom theme, dark-mode-ready and blazing fast builds.
🔌
Tibi CMS API Collections, hooks, media library — all via REST API. With mock mode for offline development.
🌍
Built-in i18n Multi-language out of the box: URL-based language selection, lazy-loaded locales, SSR-compatible.
🖥️
SSR via goja Server-side rendering in Go — fast initial delivery, SEO-friendly, with cache invalidation.
🧪
Playwright Tests E2E, API, visual regression and video tours — all preconfigured and ready to go.
"
+ },
+ {
+ "type": "richtext",
+ "headline": "How it works",
+ "tagline": "Workflow",
+ "anchorId": "workflow",
+ "padding": { "top": "lg", "bottom": "sm" },
+ "externalImageUrl": "https://images.unsplash.com/photo-1555066931-4365d14bab8c?w=800&q=80",
+ "imagePosition": "right",
+ "text": "Start the dev environment with make docker-up && make docker-start. The esbuild watcher compiles changes in real-time, BrowserSync auto-reloads the browser.
For offline development, enable mock mode with MOCK=1 in .env. Content is loaded via the Tibi API and rendered with the BlockRenderer.
Each block type (Hero, Richtext, Accordion, Features) is its own Svelte component — extensible and swappable .
"
+ },
+ {
+ "type": "accordion",
+ "headline": "Frequently Asked Questions",
+ "tagline": "FAQ",
+ "anchorId": "faq",
+ "padding": { "top": "sm", "bottom": "lg" },
+ "accordionItems": [
+ {
+ "question": "How do I start a new project with this template?",
+ "answer": "Clone the repository, adjust .env and start with make docker-up && make docker-start. Simply replace the demo content with your real content.
",
+ "open": true
+ },
+ {
+ "question": "Do I need a running Tibi server for development?",
+ "answer": "No! With MOCK=1 in .env, API calls are resolved against local JSON files. This lets you develop frontend features without a backend.
"
+ },
+ {
+ "question": "How do I add a new page?",
+ "answer": "Create a new content entry in the collection (or in mocking/content.json for mock mode) with the desired path and blocks. The app renders it automatically via the BlockRenderer.
"
+ },
+ {
+ "question": "Can I create custom block types?",
+ "answer": "Yes! Create a new Svelte component and register the type in BlockRenderer. You can extend the ContentBlockEntry type interface in global.d.ts accordingly.
"
+ }
+ ]
+ }
+ ],
+ "meta": {
+ "title": "Tibi Svelte Starter — Modern CMS Template",
+ "description": "Svelte 5, Tailwind CSS 4, SSR, i18n and Playwright tests — the perfect starter kit.",
+ "keywords": "svelte, tibi, cms, starter, template"
+ }
+ },
+ {
+ "id": "about-de",
+ "_id": "about-de",
"active": true,
"type": "page",
"lang": "de",
"translationKey": "about",
- "name": "Über uns",
+ "name": "Über das Template",
"path": "/ueber-uns",
"blocks": [
{
- "type": "richtext",
- "headline": "Über uns",
+ "type": "hero",
+ "headline": "Über das Template",
"headlineH1": true,
- "text": "Wir sind ein Demo-Unternehmen.
"
+ "subline": "Gebaut für Teams, die schnell professionelle Webprojekte umsetzen wollen.",
+ "containerWidth": "full",
+ "heroImage": {
+ "externalUrl": "https://images.unsplash.com/photo-1522071820081-009f0129c71c?w=1920&q=80"
+ }
+ },
+ {
+ "type": "richtext",
+ "headline": "Warum dieses Template?",
+ "padding": { "top": "lg", "bottom": "md" },
+ "text": "Das Tibi Svelte Starter vereint bewährte Patterns aus dutzenden Projekten in einem sofort einsetzbaren Fundament:
API-Layer mit Request-Deduplication, Loading-States und CachingWidget-Bibliothek mit Button, Input, Select, Carousel, Pagination und mehrBlock-Rendering für CMS-gesteuerte Seiten mit beliebig erweiterbaren TypenTesting-Setup mit Playwright für E2E, API und visuelle Regressionstests "
+ },
+ {
+ "type": "richtext",
+ "headline": "Technologie-Stack",
+ "padding": { "top": "md", "bottom": "lg" },
+ "externalImageUrl": "https://images.unsplash.com/photo-1461749280684-dccba630e2f6?w=800&q=80",
+ "imagePosition": "left",
+ "text": "Jede Komponente wurde sorgfältig ausgewählt:
Svelte 5 — Reaktives Framework mit Runes-APITailwind CSS 4 — Utility-first CSS mit @themeesbuild — Extrem schneller BundlerTibi CMS — Headless CMS mit Go-Backendgoja SSR — Server-Side Rendering in GoPlaywright — Modernes Testing-Framework "
}
],
"meta": {
- "title": "Über uns",
- "description": "Erfahren Sie mehr über uns"
+ "title": "Über das Template — Tibi Svelte Starter",
+ "description": "Architektur und Tech-Stack des Tibi Svelte Starter Templates.",
+ "keywords": "svelte, über uns, template, architektur"
}
},
{
- "id": "contact",
- "_id": "contact",
+ "id": "about-en",
+ "_id": "about-en",
+ "active": true,
+ "type": "page",
+ "lang": "en",
+ "translationKey": "about",
+ "name": "About the Template",
+ "path": "/about",
+ "blocks": [
+ {
+ "type": "hero",
+ "headline": "About the Template",
+ "headlineH1": true,
+ "subline": "Built for teams who want to ship professional web projects fast.",
+ "containerWidth": "full",
+ "heroImage": {
+ "externalUrl": "https://images.unsplash.com/photo-1522071820081-009f0129c71c?w=1920&q=80"
+ }
+ },
+ {
+ "type": "richtext",
+ "headline": "Why this template?",
+ "padding": { "top": "lg", "bottom": "md" },
+ "text": "The Tibi Svelte Starter combines proven patterns from dozens of projects into an immediately usable foundation:
API layer with request deduplication, loading states and cachingWidget library with Button, Input, Select, Carousel, Pagination and moreBlock rendering for CMS-driven pages with extensible typesTesting setup with Playwright for E2E, API and visual regression tests "
+ },
+ {
+ "type": "richtext",
+ "headline": "Technology Stack",
+ "padding": { "top": "md", "bottom": "lg" },
+ "externalImageUrl": "https://images.unsplash.com/photo-1461749280684-dccba630e2f6?w=800&q=80",
+ "imagePosition": "left",
+ "text": "Every component was carefully chosen:
Svelte 5 — Reactive framework with Runes APITailwind CSS 4 — Utility-first CSS with @themeesbuild — Extremely fast bundlerTibi CMS — Headless CMS with Go backendgoja SSR — Server-side rendering in GoPlaywright — Modern testing framework "
+ }
+ ],
+ "meta": {
+ "title": "About the Template — Tibi Svelte Starter",
+ "description": "Architecture and tech stack of the Tibi Svelte Starter Template.",
+ "keywords": "svelte, about, template, architecture"
+ }
+ },
+ {
+ "id": "contact-de",
+ "_id": "contact-de",
"active": true,
"type": "page",
"lang": "de",
@@ -62,15 +244,57 @@
"path": "/kontakt",
"blocks": [
{
- "type": "richtext",
+ "type": "hero",
"headline": "Kontakt",
"headlineH1": true,
- "text": "Schreiben Sie uns eine Nachricht.
"
+ "subline": "Fragen, Feedback oder Projektanfragen? Schreib uns!",
+ "containerWidth": "full",
+ "heroImage": {
+ "externalUrl": "https://images.unsplash.com/photo-1423666639041-f56000c27a9a?w=1920&q=80"
+ }
+ },
+ {
+ "type": "contact-form",
+ "headline": "Nachricht senden",
+ "padding": { "top": "lg", "bottom": "lg" }
}
],
"meta": {
- "title": "Kontakt",
- "description": "Kontaktieren Sie uns"
+ "title": "Kontakt — Tibi Svelte Starter",
+ "description": "Nimm Kontakt mit uns auf.",
+ "keywords": "kontakt, anfrage"
+ }
+ },
+ {
+ "id": "contact-en",
+ "_id": "contact-en",
+ "active": true,
+ "type": "page",
+ "lang": "en",
+ "translationKey": "contact",
+ "name": "Contact",
+ "path": "/contact",
+ "blocks": [
+ {
+ "type": "hero",
+ "headline": "Contact",
+ "headlineH1": true,
+ "subline": "Questions, feedback or project inquiries? Get in touch!",
+ "containerWidth": "full",
+ "heroImage": {
+ "externalUrl": "https://images.unsplash.com/photo-1423666639041-f56000c27a9a?w=1920&q=80"
+ }
+ },
+ {
+ "type": "contact-form",
+ "headline": "Send a message",
+ "padding": { "top": "lg", "bottom": "lg" }
+ }
+ ],
+ "meta": {
+ "title": "Contact — Tibi Svelte Starter",
+ "description": "Get in touch with us.",
+ "keywords": "contact, inquiry"
}
}
-]
\ No newline at end of file
+]
diff --git a/frontend/mocking/navigation.json b/frontend/mocking/navigation.json
index a06c97b..e198d9e 100644
--- a/frontend/mocking/navigation.json
+++ b/frontend/mocking/navigation.json
@@ -5,18 +5,20 @@
"language": "de",
"type": "header",
"elements": [
- {
- "name": "Startseite",
- "page": "home"
- },
- {
- "name": "Über uns",
- "page": "about"
- },
- {
- "name": "Kontakt",
- "page": "contact"
- }
+ { "name": "Startseite", "page": "/" },
+ { "name": "Über uns", "page": "/ueber-uns" },
+ { "name": "Kontakt", "page": "/kontakt" }
+ ]
+ },
+ {
+ "id": "header-en",
+ "_id": "header-en",
+ "language": "en",
+ "type": "header",
+ "elements": [
+ { "name": "Home", "page": "/" },
+ { "name": "About", "page": "/about" },
+ { "name": "Contact", "page": "/contact" }
]
},
{
@@ -25,14 +27,22 @@
"language": "de",
"type": "footer",
"elements": [
- {
- "name": "Impressum",
- "page": "imprint"
- },
- {
- "name": "Datenschutz",
- "page": "privacy"
- }
+ { "name": "Startseite", "page": "/" },
+ { "name": "Über uns", "page": "/ueber-uns" },
+ { "name": "Kontakt", "page": "/kontakt" },
+ { "name": "GitHub", "external": true, "externalUrl": "https://github.com" }
+ ]
+ },
+ {
+ "id": "footer-en",
+ "_id": "footer-en",
+ "language": "en",
+ "type": "footer",
+ "elements": [
+ { "name": "Home", "page": "/" },
+ { "name": "About", "page": "/about" },
+ { "name": "Contact", "page": "/contact" },
+ { "name": "GitHub", "external": true, "externalUrl": "https://github.com" }
]
}
-]
\ No newline at end of file
+]
diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte
index 63743fc..da7f4ce 100644
--- a/frontend/src/App.svelte
+++ b/frontend/src/App.svelte
@@ -1,11 +1,14 @@
-
-
-
Tibi Svelte Starter
-
-
-
- {#each SUPPORTED_LANGUAGES as lang}
+
+
-
- {#if getRoutePath($location.path) === "/about"}
- {$_("page.about.title")}
- {$_("page.about.text")}
- {:else if getRoutePath($location.path) === "/contact"}
- {$_("page.contact.title")}
- {$_("page.contact.text")}
- {:else}
- {$_("page.home.title")}
- {$_("page.home.text")}
+
+
+ {#if loading}
+
+ {:else if notFound}
+
+
+
+ {:else if contentEntry?.blocks}
+
+
+
{/if}
-
-
+
+
+
+
+
+
+
Tibi Starter
+
+ {$_("footer.madeWith")}
+
+
+
+
+ {#if footerNav?.elements}
+
+ {/if}
+
+
+
+
+
+
+
+
© {new Date().getFullYear()} Tibi Svelte Starter. {$_("footer.rights")}
+
+
+
diff --git a/frontend/src/blocks/AccordionBlock.svelte b/frontend/src/blocks/AccordionBlock.svelte
new file mode 100644
index 0000000..0982991
--- /dev/null
+++ b/frontend/src/blocks/AccordionBlock.svelte
@@ -0,0 +1,91 @@
+
+
+
+
+ {#if block.tagline}
+
+
+ {block.tagline}
+
+
+ {/if}
+
+ {#if block.headline}
+
+
+ {block.headline}
+
+
+ {/if}
+
+
+ {#each block.accordionItems || [] as item, i}
+
+
toggle(i)}
+ aria-expanded={openItems[i]}
+ >
+
+ {item.question}
+
+
+
+
+
+
+ {#if openItems[i]}
+
+ {@html item.answer || ""}
+
+ {/if}
+
+ {/each}
+
+
+
diff --git a/frontend/src/blocks/BlockRenderer.svelte b/frontend/src/blocks/BlockRenderer.svelte
new file mode 100644
index 0000000..eb8cba0
--- /dev/null
+++ b/frontend/src/blocks/BlockRenderer.svelte
@@ -0,0 +1,44 @@
+
+
+{#each blocks as block, i (i)}
+ {#if !block.hide}
+ {#if block.type === "hero"}
+
+ {:else if block.type === "features"}
+
+ {:else if block.type === "richtext"}
+
+ {:else if block.type === "accordion"}
+
+ {:else if block.type === "contact-form"}
+
+ {:else}
+
+ {#if typeof window !== "undefined" && window.location?.hostname === "localhost"}
+
+ Unknown block type: {block.type}
+
+ {/if}
+ {/if}
+ {/if}
+{/each}
diff --git a/frontend/src/blocks/ContactFormBlock.svelte b/frontend/src/blocks/ContactFormBlock.svelte
new file mode 100644
index 0000000..7e720db
--- /dev/null
+++ b/frontend/src/blocks/ContactFormBlock.svelte
@@ -0,0 +1,147 @@
+
+
+
diff --git a/frontend/src/blocks/FeaturesBlock.svelte b/frontend/src/blocks/FeaturesBlock.svelte
new file mode 100644
index 0000000..efad3dd
--- /dev/null
+++ b/frontend/src/blocks/FeaturesBlock.svelte
@@ -0,0 +1,50 @@
+
+
+
+
+ {#if block.tagline}
+
+
+ {block.tagline}
+
+
+ {/if}
+
+ {#if block.headline}
+
+
+ {block.headline}
+
+
+ {/if}
+
+ {#if block.text}
+
+ {@html block.text}
+
+ {/if}
+
+
diff --git a/frontend/src/blocks/HeroBlock.svelte b/frontend/src/blocks/HeroBlock.svelte
new file mode 100644
index 0000000..864ea49
--- /dev/null
+++ b/frontend/src/blocks/HeroBlock.svelte
@@ -0,0 +1,73 @@
+
+
+
+
+ {#if hasImage}
+
+ {#if block.heroImage?.externalUrl}
+
+ {/if}
+
+
+ {:else}
+
+ {/if}
+
+
+
+ {#if block.tagline}
+
+ {block.tagline}
+
+ {/if}
+
+ {#if block.headlineH1}
+
+ {:else}
+
+ {block.headline}
+
+ {/if}
+
+ {#if block.subline}
+
+ {block.subline}
+
+ {/if}
+
+ {#if block.callToAction?.buttonText}
+
+ {/if}
+
+
+
+
+
diff --git a/frontend/src/blocks/NotFound.svelte b/frontend/src/blocks/NotFound.svelte
new file mode 100644
index 0000000..8b82b0c
--- /dev/null
+++ b/frontend/src/blocks/NotFound.svelte
@@ -0,0 +1,27 @@
+
+
+
diff --git a/frontend/src/blocks/RichtextBlock.svelte b/frontend/src/blocks/RichtextBlock.svelte
new file mode 100644
index 0000000..06ebfe2
--- /dev/null
+++ b/frontend/src/blocks/RichtextBlock.svelte
@@ -0,0 +1,77 @@
+
+
+
+
+ {#if block.tagline}
+
+
+ {block.tagline}
+
+
+ {/if}
+
+ {#if block.headline}
+
+
+ {block.headline}
+
+
+ {/if}
+
+ {#if showImage}
+
+
+
+ {@html block.text || ""}
+
+
+
+
+
+
+
+
+
+ {:else}
+
+
+ {@html block.text || ""}
+
+ {/if}
+
+
diff --git a/frontend/src/css/style.css b/frontend/src/css/style.css
index ab3d907..5c349ed 100644
--- a/frontend/src/css/style.css
+++ b/frontend/src/css/style.css
@@ -1,4 +1,4 @@
-@import 'tailwindcss';
+@import "tailwindcss";
/*
The default border color has changed to `currentcolor` in Tailwind CSS v4,
@@ -9,11 +9,161 @@
color utility to any element that depends on these defaults.
*/
@layer base {
- *,
- ::after,
- ::before,
- ::backdrop,
- ::file-selector-button {
- border-color: var(--color-gray-200, currentcolor);
- }
+ *,
+ ::after,
+ ::before,
+ ::backdrop,
+ ::file-selector-button {
+ border-color: var(--color-gray-200, currentcolor);
+ }
+}
+
+/* ── Custom theme tokens ─────────────────────────────────────────── */
+@theme {
+ --color-brand-50: #f0f5ff;
+ --color-brand-100: #e0eaff;
+ --color-brand-200: #c7d7fe;
+ --color-brand-300: #a4bcfd;
+ --color-brand-400: #8098f9;
+ --color-brand-500: #6172f3;
+ --color-brand-600: #444ce7;
+ --color-brand-700: #3538cd;
+ --color-brand-800: #2d31a6;
+ --color-brand-900: #2d3282;
+ --color-brand-950: #1f235b;
+
+ --color-accent-400: #f79009;
+ --color-accent-500: #f59e0b;
+
+ --font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, sans-serif;
+ --font-display: "Inter", ui-sans-serif, system-ui, sans-serif;
+}
+
+/* ── Scroll-triggered reveal animation ───────────────────────────── */
+@keyframes reveal-up {
+ from {
+ opacity: 0;
+ transform: translateY(2rem);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.reveal {
+ opacity: 0;
+}
+
+.reveal.revealed {
+ animation: reveal-up 0.7s cubic-bezier(0.16, 1, 0.3, 1) forwards;
+}
+
+/* ── Prose styling for CMS richtext blocks ───────────────────────── */
+.prose h2 {
+ font-size: 1.5rem;
+ font-weight: 700;
+ margin-bottom: 0.75rem;
+}
+.prose h3 {
+ font-size: 1.25rem;
+ font-weight: 600;
+ margin-bottom: 0.5rem;
+}
+.prose p {
+ margin-bottom: 1rem;
+ line-height: 1.7;
+}
+.prose ul {
+ list-style: disc;
+ padding-left: 1.5rem;
+ margin-bottom: 1rem;
+}
+.prose ol {
+ list-style: decimal;
+ padding-left: 1.5rem;
+ margin-bottom: 1rem;
+}
+.prose a {
+ color: var(--color-brand-600);
+ text-decoration: underline;
+}
+
+/* ── Smooth page transitions ─────────────────────────────────────── */
+@keyframes fade-in {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
+
+.page-enter {
+ animation: fade-in 0.35s ease-out;
+}
+
+/* ── Gradient text helper ────────────────────────────────────────── */
+.text-gradient {
+ background: linear-gradient(135deg, var(--color-brand-600), var(--color-brand-400));
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+}
+
+/* ── Feature cards (rendered via CMS richtext HTML) ──────────────── */
+.feature-card {
+ background: white;
+ border: 1px solid var(--color-gray-100, #f3f4f6);
+ border-radius: 1rem;
+ padding: 2rem;
+ transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
+}
+
+.feature-card:hover {
+ transform: translateY(-4px);
+ box-shadow: 0 12px 40px rgba(0, 0, 0, 0.08);
+ border-color: var(--color-brand-200);
+}
+
+.feature-card .feature-icon {
+ font-size: 2.5rem;
+ margin-bottom: 1rem;
+}
+
+.feature-card h3 {
+ font-size: 1.125rem;
+ font-weight: 700;
+ color: var(--color-gray-900, #111827);
+ margin-bottom: 0.5rem;
+}
+
+.feature-card p {
+ font-size: 0.938rem;
+ color: var(--color-gray-500, #6b7280);
+ line-height: 1.65;
+}
+
+/* ── Prose code inline styling ───────────────────────────────────── */
+.prose code {
+ background: var(--color-brand-50, #f0f5ff);
+ color: var(--color-brand-700, #3538cd);
+ padding: 0.15em 0.4em;
+ border-radius: 0.375rem;
+ font-size: 0.875em;
+ font-weight: 500;
+}
+
+.prose strong {
+ color: var(--color-gray-900, #111827);
+}
+
+.prose li {
+ margin-bottom: 0.35rem;
+}
+
+/* ── Smooth scroll for anchor links ──────────────────────────────── */
+html {
+ scroll-behavior: smooth;
}
diff --git a/frontend/src/lib/actions/reveal.ts b/frontend/src/lib/actions/reveal.ts
new file mode 100644
index 0000000..a063cbf
--- /dev/null
+++ b/frontend/src/lib/actions/reveal.ts
@@ -0,0 +1,58 @@
+/**
+ * Scroll-reveal action — fades elements in when they enter the viewport.
+ *
+ * Usage: ...
+ * ...
+ *
+ * The element gets the `.reveal` CSS class on mount and `.revealed` when
+ * it enters the viewport. Animation is defined in style.css.
+ *
+ * SSR-safe: no-ops when IntersectionObserver is unavailable.
+ */
+
+export interface RevealOptions {
+ /** Delay in ms before the animation starts (default: 0) */
+ delay?: number
+ /** IntersectionObserver threshold 0–1 (default: 0.15) */
+ threshold?: number
+ /** Only animate once, then disconnect (default: true) */
+ once?: boolean
+}
+
+export function reveal(node: HTMLElement, options: RevealOptions = {}) {
+ if (typeof IntersectionObserver === "undefined") return
+
+ const { delay = 0, threshold = 0.15, once = true } = options
+
+ node.classList.add("reveal")
+
+ const observer = new IntersectionObserver(
+ (entries) => {
+ for (const entry of entries) {
+ if (entry.isIntersecting) {
+ if (delay > 0) {
+ setTimeout(() => node.classList.add("revealed"), delay)
+ } else {
+ node.classList.add("revealed")
+ }
+ if (once) observer.unobserve(node)
+ } else if (!once) {
+ node.classList.remove("revealed")
+ }
+ }
+ },
+ { threshold }
+ )
+
+ observer.observe(node)
+
+ return {
+ update(newOptions: RevealOptions) {
+ // Options are static for simplicity — recreate if needed
+ },
+ destroy() {
+ observer.unobserve(node)
+ observer.disconnect()
+ },
+ }
+}
diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts
index 2f8eb38..c127542 100644
--- a/frontend/src/lib/i18n.ts
+++ b/frontend/src/lib/i18n.ts
@@ -21,8 +21,8 @@ export const LANGUAGE_LABELS: Record = {
* Example: { about: { de: "ueber-uns", en: "about" } }
*/
export const ROUTE_TRANSLATIONS: Record> = {
- // Add your route translations here:
- // about: { de: "ueber-uns", en: "about" },
+ about: { de: "ueber-uns", en: "about" },
+ contact: { de: "kontakt", en: "contact" },
}
export const getLocalizedRoute = (canonicalRoute: string, lang: SupportedLanguage): string => {
diff --git a/frontend/src/lib/i18n/locales/de.json b/frontend/src/lib/i18n/locales/de.json
index a66da77..f90e915 100644
--- a/frontend/src/lib/i18n/locales/de.json
+++ b/frontend/src/lib/i18n/locales/de.json
@@ -16,8 +16,40 @@
"contact": {
"title": "Kontakt",
"text": "Hier kannst du ein Kontaktformular oder Kontaktdaten anzeigen."
+ },
+ "notFound": {
+ "title": "Seite nicht gefunden",
+ "text": "Die Seite, die du suchst, existiert leider nicht.",
+ "backHome": "Zur Startseite"
}
},
+ "form": {
+ "name": "Name",
+ "email": "E-Mail",
+ "subject": "Betreff",
+ "message": "Nachricht",
+ "send": "Nachricht senden",
+ "success": "Vielen Dank! Deine Nachricht wurde gesendet.",
+ "error": "Leider ist ein Fehler aufgetreten. Bitte versuche es erneut.",
+ "selectSubject": "Bitte wählen…",
+ "subjects": {
+ "general": "Allgemeine Anfrage",
+ "project": "Projektanfrage",
+ "support": "Technischer Support",
+ "feedback": "Feedback"
+ },
+ "validation": {
+ "required": "Dieses Feld ist erforderlich.",
+ "email": "Bitte gib eine gültige E-Mail-Adresse ein."
+ }
+ },
+ "footer": {
+ "builtWith": "Gebaut mit",
+ "rights": "Alle Rechte vorbehalten.",
+ "madeWith": "Gemacht mit ♥ und Svelte"
+ },
"welcome": "Willkommen",
- "language": "Sprache"
+ "language": "Sprache",
+ "scrollToTop": "Nach oben",
+ "loading": "Laden…"
}
\ No newline at end of file
diff --git a/frontend/src/lib/i18n/locales/en.json b/frontend/src/lib/i18n/locales/en.json
index dbc5664..b09d3e3 100644
--- a/frontend/src/lib/i18n/locales/en.json
+++ b/frontend/src/lib/i18n/locales/en.json
@@ -16,8 +16,40 @@
"contact": {
"title": "Contact",
"text": "Use this page to display a contact form or contact details."
+ },
+ "notFound": {
+ "title": "Page not found",
+ "text": "The page you're looking for doesn't exist.",
+ "backHome": "Back to Home"
}
},
+ "form": {
+ "name": "Name",
+ "email": "Email",
+ "subject": "Subject",
+ "message": "Message",
+ "send": "Send message",
+ "success": "Thank you! Your message has been sent.",
+ "error": "Sorry, something went wrong. Please try again.",
+ "selectSubject": "Please select…",
+ "subjects": {
+ "general": "General inquiry",
+ "project": "Project inquiry",
+ "support": "Technical support",
+ "feedback": "Feedback"
+ },
+ "validation": {
+ "required": "This field is required.",
+ "email": "Please enter a valid email address."
+ }
+ },
+ "footer": {
+ "builtWith": "Built with",
+ "rights": "All rights reserved.",
+ "madeWith": "Made with ♥ and Svelte"
+ },
"welcome": "Welcome",
- "language": "Language"
+ "language": "Language",
+ "scrollToTop": "Scroll to top",
+ "loading": "Loading…"
}
\ No newline at end of file
diff --git a/package.json b/package.json
index ee682f0..6bcdad1 100644
--- a/package.json
+++ b/package.json
@@ -58,4 +58,4 @@
"svelte-i18n": "^4.0.1"
},
"packageManager": "yarn@4.7.0"
-}
\ No newline at end of file
+}
diff --git a/tests/e2e/demo.spec.ts b/tests/e2e/demo.spec.ts
new file mode 100644
index 0000000..b9f0186
--- /dev/null
+++ b/tests/e2e/demo.spec.ts
@@ -0,0 +1,218 @@
+import { test, expect, waitForSpaReady, navigateToRoute, clickSpaLink } from "./fixtures"
+
+/**
+ * Helper: Force all scroll-reveal elements to be visible.
+ * The `.reveal` class starts with opacity:0 and only animates in
+ * when the IntersectionObserver fires — which doesn't happen
+ * reliably in headless Playwright screenshots/assertions.
+ */
+async function revealAll(page: import("@playwright/test").Page) {
+ await page.evaluate(() => document.querySelectorAll(".reveal").forEach((e) => e.classList.add("revealed")))
+}
+
+test.describe("Demo — Homepage", () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto("/de/")
+ await waitForSpaReady(page)
+ await revealAll(page)
+ })
+
+ test("should render hero section with headline and CTA", async ({ page }) => {
+ const hero = page.locator("[data-block='hero']").first()
+ await expect(hero).toBeVisible()
+
+ const h1 = hero.locator("h1")
+ await expect(h1).toBeVisible()
+ await expect(h1).not.toBeEmpty()
+
+ const cta = hero.locator("a[href*='#']")
+ await expect(cta).toBeVisible()
+ })
+
+ test("should render features section with cards", async ({ page }) => {
+ const features = page.locator("[data-block='features']")
+ await expect(features).toBeVisible()
+
+ const heading = features.locator("h2")
+ await expect(heading).toBeVisible()
+
+ const cards = features.locator(".feature-card")
+ await expect(cards).toHaveCount(6)
+ })
+
+ test("should render richtext section with image", async ({ page }) => {
+ const richtext = page.locator("[data-block='richtext']").first()
+ await expect(richtext).toBeVisible()
+
+ const img = richtext.locator("img")
+ await expect(img).toBeVisible()
+ })
+
+ test("should render accordion with expandable items", async ({ page }) => {
+ const accordion = page.locator("[data-block='accordion']")
+ await expect(accordion).toBeVisible()
+
+ const buttons = accordion.locator("button")
+ const count = await buttons.count()
+ expect(count).toBeGreaterThanOrEqual(3)
+
+ // First item should be expanded by default
+ const firstButton = buttons.first()
+ await expect(firstButton).toHaveAttribute("aria-expanded", "true")
+
+ // Click a collapsed item to expand it
+ const secondButton = buttons.nth(1)
+ await expect(secondButton).toHaveAttribute("aria-expanded", "false")
+ await secondButton.click()
+ await expect(secondButton).toHaveAttribute("aria-expanded", "true")
+ })
+
+ test("should have footer with navigation and language selector", async ({ page }) => {
+ const footer = page.locator("footer")
+ await expect(footer).toBeVisible()
+
+ const navLinks = footer.locator("nav a, ul a")
+ const linkCount = await navLinks.count()
+ expect(linkCount).toBeGreaterThanOrEqual(3)
+
+ // Language links in footer
+ const deLangLink = footer.locator('a:has-text("Deutsch")')
+ const enLangLink = footer.locator('a:has-text("English")')
+ await expect(deLangLink).toBeVisible()
+ await expect(enLangLink).toBeVisible()
+ })
+})
+
+test.describe("Demo — About Page", () => {
+ test("should load about page with hero and content", async ({ page }) => {
+ await page.goto("/en/about")
+ await waitForSpaReady(page)
+ await revealAll(page)
+
+ const hero = page.locator("[data-block='hero']").first()
+ await expect(hero).toBeVisible()
+
+ const h1 = hero.locator("h1")
+ await expect(h1).toContainText("About")
+
+ const richtextBlocks = page.locator("[data-block='richtext']")
+ const count = await richtextBlocks.count()
+ expect(count).toBeGreaterThanOrEqual(2)
+ })
+})
+
+test.describe("Demo — Contact Page", () => {
+ test("should load contact page with form", async ({ page }) => {
+ await page.goto("/en/contact")
+ await waitForSpaReady(page)
+ await revealAll(page)
+
+ const hero = page.locator("[data-block='hero']").first()
+ await expect(hero).toBeVisible()
+
+ const form = page.locator("[data-block='contact-form']")
+ await expect(form).toBeVisible()
+ })
+
+ test("should have all form fields", async ({ page }) => {
+ await page.goto("/en/contact")
+ await waitForSpaReady(page)
+ await revealAll(page)
+
+ await expect(page.getByLabel("Name")).toBeVisible()
+ await expect(page.getByLabel("Email")).toBeVisible()
+ await expect(page.locator("select, [role='combobox']").first()).toBeVisible()
+ await expect(page.getByLabel("Message")).toBeVisible()
+ await expect(page.locator("button[type='submit'], button:has-text('Send')")).toBeVisible()
+ })
+
+ test("should validate required fields", async ({ page }) => {
+ await page.goto("/en/contact")
+ await waitForSpaReady(page)
+ await revealAll(page)
+
+ // Click send without filling fields
+ const submitBtn = page.locator("button[type='submit'], button:has-text('Send')").first()
+ await submitBtn.click()
+
+ // Should show validation errors (form stays, no success toast)
+ await expect(page.locator("[data-block='contact-form']")).toBeVisible()
+ })
+})
+
+test.describe("Demo — Navigation", () => {
+ test("should navigate between pages via header links", async ({ page }) => {
+ await page.goto("/en/")
+ await waitForSpaReady(page)
+
+ // Navigate to About
+ await page.locator('header nav a[href*="/about"]').click()
+ await page.waitForLoadState("domcontentloaded")
+ expect(page.url()).toContain("/about")
+ await expect(page.locator("[data-block='hero'] h1")).toContainText("About")
+
+ // Navigate to Contact
+ await page.locator('header nav a[href*="/contact"]').click()
+ await page.waitForLoadState("domcontentloaded")
+ expect(page.url()).toContain("/contact")
+ await expect(page.locator("[data-block='hero'] h1")).toContainText("Contact")
+
+ // Navigate back to Home
+ await page.locator('header a[href="/en"]').first().click()
+ await page.waitForLoadState("domcontentloaded")
+ expect(page.url()).toMatch(/\/en\/?$/)
+ })
+
+ test("should switch language with route translation", async ({ page }) => {
+ // Start on English about page
+ await page.goto("/en/about")
+ await waitForSpaReady(page)
+ expect(page.url()).toContain("/en/about")
+
+ // Click German language link — should translate route to /de/ueber-uns
+ const deLink = page.locator('a[href*="/de/ueber-uns"]').first()
+ await expect(deLink).toBeVisible()
+ await deLink.click()
+ await page.waitForLoadState("domcontentloaded")
+ expect(page.url()).toContain("/de/ueber-uns")
+
+ // Verify hero updated to German
+ await expect(page.locator("[data-block='hero'] h1")).toBeVisible()
+ })
+
+ test("should switch language on contact page with route translation", async ({ page }) => {
+ await page.goto("/en/contact")
+ await waitForSpaReady(page)
+
+ const deLink = page.locator('a[href*="/de/kontakt"]').first()
+ await expect(deLink).toBeVisible()
+ await deLink.click()
+ await page.waitForLoadState("domcontentloaded")
+ expect(page.url()).toContain("/de/kontakt")
+ })
+})
+
+test.describe("Demo — 404 Page", () => {
+ test("should show 404 for unknown routes", async ({ page }) => {
+ await page.goto("/en/nonexistent-page")
+ await waitForSpaReady(page)
+
+ await expect(page.locator("text=404")).toBeVisible()
+ await expect(page.locator("h1")).toContainText("Page not found")
+
+ // Use the specific "Back to Home" link in the 404 main content, not header/footer
+ const homeLink = page.getByRole("link", { name: "Back to Home" })
+ await expect(homeLink).toBeVisible()
+ })
+
+ test("should navigate back from 404 to home", async ({ page }) => {
+ await page.goto("/en/nonexistent-page")
+ await waitForSpaReady(page)
+
+ const homeLink = page.getByRole("link", { name: "Back to Home" })
+ await homeLink.click()
+ await page.waitForLoadState("domcontentloaded")
+ expect(page.url()).toMatch(/\/en\/?$/)
+ await expect(page.locator("[data-block='hero']")).toBeVisible()
+ })
+})
diff --git a/types/global.d.ts b/types/global.d.ts
index 4340a64..f12216c 100644
--- a/types/global.d.ts
+++ b/types/global.d.ts
@@ -83,12 +83,16 @@ interface ContentBlockEntry {
}
heroImage?: {
image?: string
+ /** External image URL (e.g. Unsplash) — used when no medialib ID is available */
+ externalUrl?: string
}
// richtext fields
text?: string
imagePosition?: "none" | "left" | "right"
imageRounded?: string
image?: string
+ /** External image URL for richtext/generic blocks (e.g. Unsplash) */
+ externalImageUrl?: string
// accordion fields
accordionItems?: {
question?: string
diff --git a/video-tours/tours/demo-showcase.tour.ts b/video-tours/tours/demo-showcase.tour.ts
new file mode 100644
index 0000000..10b9a4e
--- /dev/null
+++ b/video-tours/tours/demo-showcase.tour.ts
@@ -0,0 +1,166 @@
+import { tour } from "./fixtures"
+import { moveThenClick, moveThenType, smoothScroll } from "../helpers"
+
+/**
+ * Video tour: Full demo showcase
+ *
+ * Walks through all demo pages, demonstrating:
+ * 1. Homepage — hero, features, richtext, FAQ accordion
+ * 2. About page — content blocks
+ * 3. Contact page — form interaction
+ * 4. Language switching (EN ↔ DE with route translation)
+ * 5. 404 page
+ *
+ * Run: yarn tour
+ */
+tour("Demo Showcase", async ({ tourPage: page }) => {
+ tour.setTimeout(120000)
+
+ // ── 1. Homepage (German) ─────────────────────────────────────────
+ await page.goto("/de/")
+ await page.waitForTimeout(2500)
+
+ // Scroll down through hero → features
+ await smoothScroll(page, 500)
+ await page.waitForTimeout(2000)
+
+ // Scroll through features
+ await smoothScroll(page, 1200)
+ await page.waitForTimeout(2500)
+
+ // Scroll to richtext/workflow section
+ await smoothScroll(page, 2000)
+ await page.waitForTimeout(2500)
+
+ // Scroll to FAQ accordion
+ await smoothScroll(page, 2800)
+ await page.waitForTimeout(2000)
+
+ // Interact with accordion — click second item
+ const accordionButtons = page.locator("[data-block='accordion'] button")
+ const secondAccordion = accordionButtons.nth(1)
+ if (await secondAccordion.isVisible({ timeout: 2000 }).catch(() => false)) {
+ await moveThenClick(page, secondAccordion)
+ await page.waitForTimeout(1500)
+ }
+
+ // Click third item
+ const thirdAccordion = accordionButtons.nth(2)
+ if (await thirdAccordion.isVisible({ timeout: 2000 }).catch(() => false)) {
+ await moveThenClick(page, thirdAccordion)
+ await page.waitForTimeout(1500)
+ }
+
+ // Scroll to footer
+ await smoothScroll(page, 9999)
+ await page.waitForTimeout(1500)
+
+ // Scroll back to top
+ await smoothScroll(page, 0)
+ await page.waitForTimeout(1500)
+
+ // ── 2. Navigate to About ─────────────────────────────────────────
+ const aboutLink = page.locator('header nav a[href*="ueber-uns"], header nav a[href*="about"]').first()
+ if (await aboutLink.isVisible({ timeout: 2000 }).catch(() => false)) {
+ await moveThenClick(page, aboutLink)
+ await page.waitForTimeout(2500)
+
+ // Slow scroll through about content
+ await smoothScroll(page, 400)
+ await page.waitForTimeout(2000)
+ await smoothScroll(page, 900)
+ await page.waitForTimeout(2000)
+ await smoothScroll(page, 1500)
+ await page.waitForTimeout(1500)
+ }
+
+ // ── 3. Navigate to Contact ───────────────────────────────────────
+ const contactLink = page.locator('header nav a[href*="kontakt"], header nav a[href*="contact"]').first()
+ if (await contactLink.isVisible({ timeout: 2000 }).catch(() => false)) {
+ await moveThenClick(page, contactLink)
+ await page.waitForTimeout(2500)
+
+ // Scroll to form
+ await smoothScroll(page, 500)
+ await page.waitForTimeout(1500)
+
+ // Fill out the contact form
+ const nameInput = page.getByLabel("Name", { exact: false })
+ if (await nameInput.isVisible({ timeout: 2000 }).catch(() => false)) {
+ await moveThenType(page, nameInput, "Max Mustermann", { delay: 60 })
+ await page.waitForTimeout(800)
+ }
+
+ const emailInput = page.getByLabel("E-Mail", { exact: false }).or(page.getByLabel("Email", { exact: false }))
+ if (
+ await emailInput
+ .first()
+ .isVisible({ timeout: 2000 })
+ .catch(() => false)
+ ) {
+ await moveThenType(page, emailInput.first(), "max@example.com", { delay: 60 })
+ await page.waitForTimeout(800)
+ }
+
+ // Select subject
+ const selectTrigger = page.locator("select, [role='combobox']").first()
+ if (await selectTrigger.isVisible({ timeout: 2000 }).catch(() => false)) {
+ await moveThenClick(page, selectTrigger)
+ await page.waitForTimeout(500)
+ await page.selectOption("select", { index: 2 })
+ await page.waitForTimeout(800)
+ }
+
+ const messageInput = page
+ .getByLabel("Nachricht", { exact: false })
+ .or(page.getByLabel("Message", { exact: false }))
+ if (
+ await messageInput
+ .first()
+ .isVisible({ timeout: 2000 })
+ .catch(() => false)
+ ) {
+ await moveThenType(page, messageInput.first(), "Das ist eine Testnachricht für die Video-Tour.", {
+ delay: 40,
+ })
+ await page.waitForTimeout(1000)
+ }
+
+ // Submit form
+ const submitBtn = page
+ .locator("button[type='submit'], button:has-text('Senden'), button:has-text('Send')")
+ .first()
+ if (await submitBtn.isVisible({ timeout: 2000 }).catch(() => false)) {
+ await moveThenClick(page, submitBtn)
+ await page.waitForTimeout(3000) // Wait for toast + simulated API call
+ }
+ }
+
+ // ── 4. Language switch ───────────────────────────────────────────
+ // Switch to English
+ const enLink = page.locator('header a[href*="/en"]').first()
+ if (await enLink.isVisible({ timeout: 2000 }).catch(() => false)) {
+ await moveThenClick(page, enLink)
+ await page.waitForTimeout(2500)
+
+ // Scroll a bit on English page
+ await smoothScroll(page, 300)
+ await page.waitForTimeout(1500)
+ await smoothScroll(page, 0)
+ await page.waitForTimeout(1000)
+ }
+
+ // ── 5. 404 page ─────────────────────────────────────────────────
+ await page.goto("/en/this-page-does-not-exist")
+ await page.waitForTimeout(2500)
+
+ // Click Back to Home
+ const backHomeLink = page.getByRole("link", { name: /back to home/i })
+ if (await backHomeLink.isVisible({ timeout: 2000 }).catch(() => false)) {
+ await moveThenClick(page, backHomeLink)
+ await page.waitForTimeout(2500)
+ }
+
+ // Final pause on homepage
+ await page.waitForTimeout(2000)
+})