Eerste commit voor Helvoirt Gevelreiniging
@@ -0,0 +1,39 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
|
||||
# Astro
|
||||
.astro/
|
||||
|
||||
# Data
|
||||
data.db
|
||||
data.db-shm
|
||||
data.db-wal
|
||||
uploads/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# Cloudflare
|
||||
.wrangler/
|
||||
|
||||
# Vite
|
||||
node_modules/.vite/
|
||||
@@ -0,0 +1,139 @@
|
||||
# Helvoirt Gevelreiniging — helvoirtgevelreiniging.nl
|
||||
|
||||
AI-assistent context. Lees ook `architecture.md` (technisch overzicht) en `todo.md` (bouwplan + status).
|
||||
**Belangrijk: wijzig uitsluitend bestanden in `C:\projects\dewebsmid\helvoirt`.**
|
||||
|
||||
## Project
|
||||
- **Type:** Multipage marketing-site met Emdash CMS voor een gevelreinigings- en renovatiebedrijf.
|
||||
- **Diensten:** gevelreiniging + renovatie, voegwerkzaamheden, dak reinigen + coating, schoorsteen-voegwerk, algemeen reinigen.
|
||||
- **Domein:** `helvoirtgevelreiniging.nl` (nog te koppelen).
|
||||
- **Zusterbedrijf / referentie:** `https://onderhoudsbedrijfvanhelvoirt.nl` (dakwerken). Klant: "dezelfde kleuren en indeling, alleen niet qua grootte." → we volgen die palette/familie.
|
||||
- **Register:** **conversiegericht** (doel: gratis-offerte-aanvragen via het formulier). Marketing-homepage waar het ontwerp het product IS, maar **niet** de quiet-luxury van het zusterproject `../exterios`. Before/after, Google-reviews en een prominente CTA zijn hier juist gewenst.
|
||||
|
||||
## Tech stack
|
||||
- **Framework:** Astro 6 + TypeScript, `output: "server"` (SSR).
|
||||
- **CMS:** Emdash v0.1.0 (beta) — admin op `/_emdash/admin`. Nog niet geseed (zie todo §3).
|
||||
- **Hosting:** Cloudflare Workers. **D1** + **R2** + **KV** + **Images** (KV/Images auto-provisioned door de adapter).
|
||||
- **Styling:** scoped CSS + CSS custom properties in `src/styles/tokens.css`. Géén Tailwind.
|
||||
- **Fonts:** **Archivo Expanded** (koppen) + **Open Sans** (body) via Google Fonts.
|
||||
- **Account:** `Mike@dewebsmid.nl` (`41972f4905f2c83f51111b80283e030b`) — zelfde als exterios.
|
||||
|
||||
## Cloudflare resources — AANGEMAAKT + GEDEPLOYED (preview)
|
||||
| Resource | Naam | Status |
|
||||
|---|---|---|
|
||||
| Worker | `helvoirt-gevelreiniging` | **live** op `https://helvoirt-gevelreiniging.mike-197.workers.dev` (preview; echt domein nog niet gekoppeld) |
|
||||
| D1 | `helvoirt-db` (WEUR) | aangemaakt — `database_id` `0fb83f17-f91d-4910-ac8c-80f00569ce35` (staat in `wrangler.jsonc`) |
|
||||
| R2 | `helvoirt-media` | aangemaakt |
|
||||
| KV | `helvoirt-gevelreiniging-session` (`SESSION`) | auto-provisioned bij eerste deploy |
|
||||
|
||||
**Preview-deploy (gedaan):** alle marketing-pagina's renderen live op fallbacks (200): homepage, `/diensten` (+ 5 dienstpagina's), `/over-ons`, `/contact`; onbekende route → 404. Emdash is nog **niet geseed** → `/_emdash/admin` redirect naar `/_emdash/admin/setup` (setup-wizard nog te doen, §3). Domein `helvoirtgevelreiniging.nl` staat bij een **andere registrar** → nameservers naar Cloudflare verzetten vóór koppeling (todo §12).
|
||||
|
||||
## Design (zie tokens.css)
|
||||
- **Palette (committed blauw/petrol):** actie-blauw `#0069ff` / hover `#0055ce` (CTA, links), cyaan `#4ebfd9` (accent/labels), diep petrol `#1d4852` / `#15363d` (donkere secties, header, footer), off-white `#f6fafb` (page), licht cyaan `#e3f6fd` (accent-sectie), ink `#1f2a2e` / ink-soft `#5b6a70`. Status/ster via tokens (`--c-on-error`, `--c-on-success`, `--c-star`). Neutralen getint (geen puur wit/zwart in surfaces).
|
||||
- **Type:** Archivo Expanded (700/800, stoer/industrieel) + Open Sans (400-700).
|
||||
- **Hero:** **tekst links + foto-collage rechts** (Tailark hero-6 nagebouwd in Astro/scoped-CSS, door `audit` 20/20 + `critique` 33/40 met fixes). Donker petrol, **geen kicker** (klant wilde "Gevelreiniging in Helvoirt en omgeving" weg). Links: Archivo-kop "Je gevel weer als nieuw." (**800**, wit), sub, dominante blauwe CTA + tekstlink, drie vinkjes, en een **prominente 4,9-Google-regel** (grote display-score + sterren). Rechts: **rij van 3 werkfoto-cards** (Voegwerk/Schoorsteen/Dakreiniging, staand, verspringend, schaduw, diepe bijschrift-overlay) die links **overlopen in het tekstblok** en wegvloeien in de petrol via `.hero__fade` (klantvoorkeur boven de rechts-uitgelijnde collage); mobiel = swipebare scroll-snap-rail onder de tekst. (Was 4 cards incl. Metselwerk; op klantverzoek terug naar **3** — 4 was te druk — en de cards verbreed naar `clamp(200px,19vw,280px)` zodat ze de rechterzone blijven vullen; overloop/fade/schaduw behouden.) Hero-cards = `werk-1/3/5`, kleine srcset-varianten (`werk-N-480/768.webp`). **Géén before/after-slider** in de hero (projecten is geschrapt; de dienstpagina's gebruiken een **resultaat-galerij** i.p.v. een slider). NB: op de homepage zijn `hero-after/before/gevel.webp` ongebruikt; `hero-after/gevel.webp` worden wel in de dienst-galerijen + over-ons-hero gebruikt, `hero-before.webp` nergens (alleen via het ongebruikte `BeforeAfter.astro`).
|
||||
- **Diensten-sectie = uitklappende beeld-accordion** (donker). Bewust gekozen ná het afwijzen van: zig-zag-banden, interactieve index, foto-mozaïek en typografisch menu (te saai / te veel als de fotogalerij).
|
||||
|
||||
## Persistente regels
|
||||
- **Geen em-dashes (—) in zichtbare site-tekst.** Komma/punt/dubbele punt. (Wel toegestaan in code-comments en deze docs.)
|
||||
- **Dot-separated meta** (`particulier en zakelijk · eerlijke prijs vooraf · …`) lowercase, behalve eigennamen.
|
||||
- **Eén dominante CTA:** "Gratis offerte" (actie-blauw, gevuld). **Header + sticky-mobile + drawer → `/contact`** (de contactpagina, nu gebouwd). **Hero's + dienst-CTA's + mid-page-band → `#contact`** (de in-page prefooter, lage drempel). De contactpagina zet zelf `hidePrefooter` en geeft zijn formulier-sectie `id="contact"`; de sticky-CTA wordt verborgen als `hidePrefooter` (dus op /contact). (Header-CTA-ghost is geprobeerd maar op klantverzoek teruggezet naar gevuld blauw.)
|
||||
- **Tapdoelen ≥ 44px** (header-telefoon, hamburger, knoppen) — gefixt in de audit; houd dit zo.
|
||||
- **`prefers-reduced-motion`** overal respecteren (accordion, scroll-reveals, tijdlijn-tekenanimatie, hero-gloed, form-spinner). Reveal-varianten: `.reveal--draw` (lijn tekent zich in) + `.reveal--stagger` (kinderen na elkaar omhoog, `--i`-index) — zie `tokens.css`.
|
||||
- **Beeld:** WebP + srcset via `src/lib/img.ts` (`srcsetFor` + `IMG_WIDTHS`, 768/1280/1920). Hero-galerij gebruikt kleine `werk-N-480/768.webp`-varianten; alle hero-cards `loading=lazy` (boven de vouw laadt toch direct).
|
||||
|
||||
## Windows-werkwijze (geërfd van exterios/ks-elektra)
|
||||
- **Elke `astro`/`wrangler`-call:** prefix met `$env:NODE_OPTIONS="--use-system-ca"` (corporate-cert, anders TLS-fout).
|
||||
- **Install:** `npm install --ignore-scripts` (better-sqlite3 compileert niet op Windows; geen impact, dev draait op workerd).
|
||||
- **Vite SSR chunk-issue (twee-fase opstart) bij koude cache:**
|
||||
1. `Remove-Item -Recurse -Force node_modules/.vite, .astro`
|
||||
2. `$env:NODE_OPTIONS="--use-system-ca"; npx astro dev --port 4321` → wacht, doe 1 GET op `/` (crasht = warmt cache), Ctrl+C
|
||||
3. opnieuw `npx astro dev --port 4321` (zonder cache wissen) → stabiel.
|
||||
Daarna werkt `npm run dev` direct zolang de cache intact is.
|
||||
- **Emdash seed CLI** werkt niet lokaal → via setup-wizard of `wrangler d1 execute`.
|
||||
|
||||
## Beeldgeneratie (nano-banana, Windows)
|
||||
```powershell
|
||||
$env:GEMINI_API_KEY = ((Get-Content .env | Where-Object {$_ -match '^GEMINI_API_KEY='}) -replace '^GEMINI_API_KEY=','').Trim()
|
||||
$py = "C:\Users\info\AppData\Local\Programs\Python\Python312\python.exe"
|
||||
uv run --native-tls --python $py scripts/nano-banana-with-truststore.py --prompt "..." --output public/images/x.webp --aspect 16:9 --size 2K
|
||||
```
|
||||
- `--native-tls` (uv's pypi-fetch) + **system python.org 3.12** (truststore crasht op uv's standalone-Python).
|
||||
- **Before/after-hero:** de "voor"-foto is een PIL-degradatie van de schone "na"-foto (`scripts/degrade-before.py`) zodat de slider pixel-identiek uitgelijnd is.
|
||||
- **Varianten:** `scripts/img-variants.py <input.webp> 768,1280,1920 82`. **Echte input-foto's → WebP:** `scripts/convert-input.py`.
|
||||
- **Screenshots (verificatie):** `uv run --native-tls --with playwright --python $py scripts/screenshot-home.py` (en `shoot-*.py`).
|
||||
|
||||
## Build & deploy
|
||||
```powershell
|
||||
$env:NODE_OPTIONS="--use-system-ca"
|
||||
npx astro check # type-check (0 errors verwacht)
|
||||
npx astro build # → dist/
|
||||
$env:CLOUDFLARE_ACCOUNT_ID="41972f4905f2c83f51111b80283e030b"
|
||||
npx wrangler deploy # pas ná D1/R2 aangemaakt + echte database_id
|
||||
```
|
||||
|
||||
## Status (laatste sessie)
|
||||
**Bouwhistorie (homepage-fase, ter referentie — huidige status staat hieronder bij "← HUIDIGE STATUS").** Gedaan: scaffold, tokens, `Base.astro`, `Hero.astro`, `ContactForm.astro`, `index.astro` (intro → diensten-accordion → werkwijze-tijdlijn → bewijs-galerij → reviews → werkgebied → FAQ), `api/contact.ts`. **Logo = H-monogram** (Base.astro + favicon.svg, druppel verwijderd). **Hero volledig uitgewerkt** via meerdere impeccable-passes: craft → bolder (verworpen: te druk/schreeuwerig) → quieter → **tekst-links + werkfoto-collage-rechts** (Tailark hero-6 nagebouwd) → `audit` **20/20** → `critique` **33/40** met fixes (kop 800, prominente 4,9-review, getokeniseerde scrims/schaduw, fade-afstelling). Kop = "Je gevel weer als nieuw.". `astro check` 0 errors, axe-core 0 violations.
|
||||
**Animate-pass (`/impeccable animate`):** samenhangende motion-laag toegevoegd — consistente scroll-reveals over álle secties, werkwijze-tijdlijn die zich vanaf links intekent (`.flow__line` + `.reveal--draw`), gestaggerde werkgebied-pills + FAQ (`.reveal--stagger` met `--i`), `:active`-indruk op `.btn-cta`/`.btn-outline`, offerteformulier-spinner (`aria-busy`) + geanimeerde succesmelding (`cform-success`), en een trage ambient gloed in de hero (`hero-glow`). Alles achter `prefers-reduced-motion`, transform/opacity-only. Tegelijk: **hero 4 → 3 cards** (klantverzoek).
|
||||
**Cloudflare + preview-deploy (§11):** D1/R2/KV aangemaakt, echte `database_id` in `wrangler.jsonc`, laatste `wrangler deploy` (version `22109fc1`, incl. animate-pass + 3-card-hero) → **`https://helvoirt-gevelreiniging.mike-197.workers.dev`** (homepage 200 op fallbacks). Klantkeuze: preview nu, echt domein pas als de site af is.
|
||||
|
||||
**Marketing-pagina's (rendered output) staan live:** homepage, `/diensten`-overzicht + 5 dienst-detailpagina's (`/diensten/[slug].astro`: `gevelreiniging`, `voegwerk`, `dakreiniging-coating`, `schoorsteen-voegwerk`, `algemeen-reinigen`; onbekend → 404), `/over-ons`, `/contact`. Projecten geschrapt. CTA's header/sticky/drawer → `/contact`, in-page → `#contact`.
|
||||
|
||||
**STATUS (16-06-2026 — CMS-migratie; zie het nieuwere "← GO-LIVE CODE-LAAG AF"-blok hieronder voor de huidige stand 17-06-2026).**
|
||||
- **Werkgebied-pills** omgezet naar Helvoirt + Limburgse steden (kop/SEO-copy blijft "Helvoirt en omgeving"); zie regel hieronder.
|
||||
- **CMS volledig ingericht + GESEED** (emdash, remote D1). Ingebouwde `pages`/`posts` verwijderd. `site_settings` stuurt nu de site-brede SEO (`Base.astro` leest `meta_title`/`meta_description`/`og_image`) + GTM-veld klaar.
|
||||
- **CMS gebruiksvriendelijk voor SEO-partij:** `paginas` (gewone pagina's, root-URL) + `locaties` (locatiepagina's, root-URL bv. `/maastricht` — LIVE) via root-router `[...slug].astro`; nette branded 404. Dupliceren (native), inline **repeater-rijen**, opgeschoonde SEO-labels. Handleiding: **`CMS-SEO-handleiding.md`**.
|
||||
- **Volledige migratie bestaande pagina's → CMS (klant: "volledig naar CMS"), met fallback-veiligheid + sentinel-geverifieerd:** **Over ons** (singleton `over_ons` + `waarden`-repeater + hero/verhaal-foto in mediabibliotheek), **Homepage** (singleton `homepage`: tekst + werkwijze/reviews/FAQ-repeaters), **Contact** (`contact_page`). **Maastricht** locatiepagina gepubliceerd.
|
||||
- **Migratie-machinerie:** `scripts/gen-cms-sql.mjs <model> <out.sql>` (spiegelt emdash's tabelstructuur, repeater-`validation`, ULID) + `scripts/cms-models/*` + `migrations/*.sql`. Media: `scripts/gen-over-ons-media.mjs` + `wrangler r2 object put` (storage_key == R2-key). **Laatste deploy: version `f576e989`.**
|
||||
|
||||
**★ DE SITE IS LIVE OP `https://helvoirtgevelreiniging.nl` (17-06-2026, deploy `7ed8f33b`).** Zone actief op Cloudflare (NS `cesar`/`priscilla.ns.cloudflare.com`), custom domain apex+www gekoppeld (`wrangler.jsonc` `routes` + rebuild). Alle pagina's 200, www→apex + http→https 301 (middleware), canonical/sitemap = echte domein, 404 werkt, SSL actief. `*.workers.dev` UITGESCHAKELD (de oude preview-URL werkt niet meer; testen nu op het echte domein, evt. `curl -sk` als corporate-DNS niet resolvet). **Nog open (extern):** mail (secret `CONTACT_SMTP_PASS` + mail-DNS MX/SPF/DKIM/DMARC — wacht op mailbox; OAuth-token mag geen DNS/settings, 403 → dashboard of DNS-edit-token), CMS-login-gebruiker (SEO-partij-e-mail), optioneel HTTPS-hardening (Always Use HTTPS + HSTS via dashboard).
|
||||
- **MAIL WERKT (17-06-2026, deploy `cb425dc4`):** mailbox `info@helvoirtgevelreiniging.nl` op **srv8.dewebsmid.nl** (DirectAdmin; ontdekt via `/webmail` → 301 naar `srv8.dewebsmid.nl/roundcube`). `CONTACT_SMTP_PASS` secret gezet (bash printf). **`CONTACT_SMTP_HOST=srv8.dewebsmid.nl` in `wrangler.jsonc` vars** — bewust NIET `mail.helvoirtgevelreiniging.nl` want dat is **NXDOMAIN** in Cloudflare-DNS (mail-records niet meegegaan bij de NS-verhuizing); srv8 resolvet wel (185.84.143.157) + geldig TLS-cert. Verbinden via puur IP kan niet (cert-mismatch) → daarom de srv8-hostname. User/From/To blijven info@. Live getest: `mail-sent melding-klant` + `mail-sent bevestiging-aanvrager`; alle paden (valid/422/honeypot/time-trap) OK; testrijen opgeruimd. **Nog open (collega, mijn token mag geen DNS, 403):** mail-DNS in Cloudflare — A `mail`→srv8-IP, MX, SPF, DKIM, DMARC — nodig voor INKOMENDE mail naar info@ van externen + afleverbaarheid (autoreply anders kans op spam). Outbound form-mail werkt al zonder.
|
||||
- **LOGIN-GOTCHA (passkey gebonden aan origin):** de admin-user `mike@dewebsmid.nl` (role 50) registreerde z'n passkey/Windows-Hello-pincode op de **oude** `*.workers.dev`-origin → werkt NIET op `helvoirtgevelreiniging.nl` (passkeys zijn origin-gebonden), en magic-link gaf 503 (mailbox/secret ontbreekt). **Oplossing zonder mailbox:** handmatig een magic-link-token minten — token = `base64url(randomBytes(32))`, hash = `base64url(sha256(tokenbytes))`; INSERT in `auth_tokens` (hash PK, user_id, email, type='magic_link', expires_at ISO, created_at) + `email_verified=1`; geef de gebruiker `…/_emdash/api/auth/magic-link/verify?token=<token>` (single-use, 15min default → ik zet 48u). Daarna moet de gebruiker **een nieuwe passkey registreren op het echte domein**. Zodra de mailbox + `CONTACT_SMTP_PASS` er zijn werkt de normale "Sign in with email link".
|
||||
- **CMS-gebruikers (17-06-2026): 2 admins (role 50)** — `mike@dewebsmid.nl` + `ries@rsonlinemarketing.nl` (SEO-partij). Aangemaakt via `INSERT INTO users` (email_verified=1). **Magic-link login live geverifieerd** (`POST /_emdash/api/auth/magic-link/send` → 200 + token in `auth_tokens` + e-mail via srv8). Ries logt in via "Sign in with email link".
|
||||
|
||||
**← GO-LIVE CODE-LAAG AF + LIVE (17-06-2026, deploy `15e076ff`).** In één sessie gebouwd, gedeployed en live geverifieerd (patronen 1-op-1 uit `../exterios`/`../behangerbrabant`, zie newsite.md):
|
||||
- **SEO-infra:** `public/robots.txt`, `src/pages/sitemap.xml.ts` (vaste routes + live CMS-`paginas`/`locaties`, noindex eruit; **overschrijft emdash's eigen `/sitemap.xml`** — user-route wint, build-warning is verwacht), `site: "https://helvoirtgevelreiniging.nl"` in `astro.config.mjs` (canonical/sitemap-base). Sitemap live geverifieerd incl. `/maastricht` uit CMS.
|
||||
- **`Base.astro`:** JSON-LD `HomeAndConstructionBusiness` (NAW/areaServed/4,9-rating uit `site_settings`), **GTM-renderblok** (head + body-noscript, leest `site_settings.gtm`, leeg=niets), theme-color + twitter-cards + og:image-default (`hero-after-1280.webp`).
|
||||
- **Footer-pagina's:** `privacy.astro` + `voorwaarden.astro` (NAW uit `site_settings` met fallback; voorwaarden = algemene basistekst, **klant laten controleren**) + footer-links.
|
||||
- **Mail + login (code af, secret/gebruiker open):** `src/lib/smtp.ts` (auth-SMTP over `cloudflare:sockets`), `api/contact.ts` slaat op in **D1 `contact_submissions`** (migratie `0001`, **toegepast op remote D1**, live POST→200+rij getest+opgeruimd) + 2 mails; `src/plugins/smtp-email.ts` (magic-link-provider) geregistreerd in `astro.config`; `src/middleware.ts` (http→https + www→apex); `src/cloudflare-env.d.ts`. **Mailtrap geschrapt → eigen mailbox-SMTP.** Mail/magic-link = no-op tot `CONTACT_SMTP_PASS` gezet is.
|
||||
- **OG/JSON-LD/icon-pass (deploy `50886467`):** gebrande **1200×630 OG-image** (`public/images/og-default.webp` via `scripts/make-og.py` — gevel + petrol-gradient + wordmark + 4,9-Google) + og:image-dims; **`Service` + `BreadcrumbList` JSON-LD** op de 5 dienstpagina's (3 ld+json-blokken/pagina); **`apple-touch-icon.png`** (180×180 H-monogram, full-bleed). `FAQPage` bewust overgeslagen (placeholder-FAQ = dun schema).
|
||||
- **Font-leerpunt:** Archivo Expanded is **niet** betrouwbaar self-te-hosten via de css2-API (Google heeft de width-varianten in de variabele "Archivo"-familie samengevoegd; `family=Archivo+Expanded` levert geen @font-face). Self-hosting bewust uitgesteld (zou de display-typografie kunnen breken) → fonts blijven op Google Fonts (preconnect + `display=swap`).
|
||||
- **Go-live-runbook:** `GO-LIVE.md` = kant-en-klare stappen ná DNS+mailaccount (secret via bash printf, CMS-`users`-rij, `routes`-blok + **rebuild**, mail-DNS, HTTPS, verificatie). Lighthouse keyless = 429 → eigen PSI-key nodig.
|
||||
- **ALLE BEELDEN in het CMS (deploy `57651519`):** **Diensten-media** (`0010_diensten_media.sql` + `gen-diensten-media.mjs`): 13 unieke foto's → R2 + `media`-rijen, gekoppeld aan alle **25** dienst-beeldvelden (live: detail 4/4, overzicht 5/5, accordion 5/5). **Homepage-BEELD = Wave 2b KLAAR** (`0011_homepage_media.sql` + `gen-homepage-media.mjs`): collecties **`hero_cards`** (3) + **`galerij`** (6); `Hero.astro` (via `cards`-prop) + bewijs-sectie lezen eruit met fallback (3/3 + 6/6 CMS-media, screenshot-geverifieerd). Galerij hergebruikt de werk-media; hero gebruikt kleine -768-varianten (perf). `gen-cms-sql.mjs` is nu importeerbaar (`generate()`, CLI achter `isMain`-guard). **Patroon media-populatie:** R2 `object put <key> --remote` + `media`-rij (storage_key==key) + veld-JSON `{id,provider:"local",…,meta:{storageKey}}`; CMS = één formaat (geen srcset), `srcsetFor` geeft `undefined` voor CMS-paden. **Over ons-media al gedaan (0006).** Dus tekst + beeld van álle pagina's staan in het CMS.
|
||||
- **CMS = 100% van de pagina's (Wave 3 Diensten KLAAR, deploy `ed137121`, `0009_diensten.sql`):** collectie **`diensten`** (5 entries, 36 velden) stuurt **tegelijk** de 5 detailpagina's (`/diensten/[slug]`), het `/diensten`-overzicht, de homepage-accordion én het **nav-dropdownmenu** aan (sortering op `nav_order`). Alles leest uit CMS met de hardcoded data als fallback; beeldvelden leeg = code-fallback naar de huidige foto's. Resultaat-galerij = 3 vaste image-slots (repeater kan geen image-subveld). Sentinel-geverifieerd. Admin-sidebar toont nu **Contactpagina · Diensten · Homepage · Locatiepagina's · Over ons · Pagina's · Instellingen · Media** = alle pagina's. **Rest CMS-migratie = alleen Wave 2b (homepage-BEELD: hero-cards + bewijs-galerij) + diensten-foto's in de mediabibliotheek (optioneel; nu code-fallback).** `CMS-SEO-handleiding.md` §6b/§7 bijgewerkt.
|
||||
|
||||
**CMS "Bekijk"-link gefixt (25-06-2026, `migrations/0016_collection_url_patterns.sql`, pure D1).** Het oogje/"Bekijk" in de admin wees naar `/locaties/maastricht` (emdash-default `/{collectie}/{slug}`) i.p.v. de echte root-URL `/maastricht`. emdash heeft per collectie een `url_pattern` (kolom `_emdash_collections.url_pattern`, `{slug}`-placeholder; leeg = de foute fallback). Gezet: `locaties`/`paginas` → `/{slug}`, `diensten` → `/diensten/{slug}`, `homepage` → `/`, `over_ons` → `/over-ons`, `contact_page` → `/contact`, `voorwaarden` → `/voorwaarden`. Manifest-API leest dit live → werkt na admin-herlaad, geen redeploy. Fixt ook emdash's auto-redirect-generatie bij slug-wijziging.
|
||||
|
||||
**"Foto verwijderen lukt niet, komt steeds terug" GEFIXT (25-06-2026, deploy `9f26388c`) — client-side emdash-bug.** Eerste poging (Maastricht's out-of-sync draft verwerpen) was een pleister; de échte oorzaak zit in de admin-UI. In `@emdash-cms/admin/dist/index.js` doet de remove-knop (X) van het beeldveld `handleRemove = () => onChange(void 0)` → de veldwaarde wordt **`undefined`** → `JSON.stringify(input)` (de PUT-body, `updateContent`) **laat `undefined` vallen** → `hero_image` zit NIET in `body.data` → `ContentRepository.update` schrijft alleen aanwezige keys → kolom wordt nooit op NULL gezet → de foto komt terug. (`onChange(null)` blijft wél in JSON staan → kolom wordt NULL.) **Fix:** `onChange(void 0)` → `onChange(null)` in `@emdash-cms/admin/dist/index.js`, **persistent via patch-package** (`patches/@emdash-cms+admin+0.1.1.patch`). Geverifieerd: bronpatch + minified client-bundle (`()=>{r(null)}` naast `storageKey`). ⚠️ Client-bundle → de klant moet **hard refreshen (Ctrl+F5)** om de nieuwe admin-JS te laden. Geldt voor álle beeldvelden + zusterprojecten (zie memory `emdash-image-clear-bug`). Maastricht's foto was al opgeruimd via de eerdere draft-discard.
|
||||
|
||||
**Cursor springt naar einde tijdens typen GEFIXT (25-06-2026, deploy `f0cf759e`).** In de content-editor (`@emdash-cms/admin/dist/index.js`) herlaadt een `useEffect` (deps o.a. `item.updatedAt`/`itemDataString`) `setFormData(item.data)` telkens als `item` verandert; na elke autosave ververst React Query de entry → formData wordt opnieuw gezet → caret naar het einde (+ tijdens-opslaan getypte tekens konden verdwijnen). **Fix:** `loadedIdRef` toegevoegd → formulier-setters draaien alleen bij entry-id-wissel; `setLastSavedData` blijft elke keer draaien (dirty-tracking intact, geen autosave-loop). In dezelfde patch (`patches/@emdash-cms+admin+0.1.1.patch`). Bundle is leesbaar (niet minified) → met Edit te patchen. Memory `emdash-caret-jump-bug`. **Hard refresh (Ctrl+F5)** nodig.
|
||||
|
||||
**Nog te doen vóór live — alleen externe input + optioneel:**
|
||||
1. **Externe input:** secret `CONTACT_SMTP_PASS` (collega: mailbox-wachtwoord/host, **bash printf**!) → mail+magic-link testen; SEO-partij-e-mail → `users`-rij (§6b); klantcontent (NAW/reviews/FAQ/foto's/GTM-ID in `site_settings`); domein-DNS (zone+nameservers+`routes`(rebuild)+mail-DNS+HTTPS).
|
||||
2. **Optioneel:** CMS-migratie afmaken (§3b **Wave 3 = diensten-collectie + dienstpagina's + overzicht**, **Wave 2b = homepage-beeld**); echte 1200×630 OG-image; Lighthouse/CWV-meting; checklist `.xlsx`.
|
||||
Herdeploy per iteratie: `astro build` + `wrangler deploy`. Schema-wijzigingen op de live D1 = via `migrations/*.sql` (`wrangler d1 execute --remote --file`), NIET de wizard opnieuw.
|
||||
|
||||
**`/impeccable audit` (hele site) = 17/20 (Good), fixes doorgevoerd:** tap-doelen telefoon/e-mail (prefooter + contact) + logo + skip-link → ≥44px; accordion-scrims → `--c-scrim-rgb`/`--c-action-deep-rgb`-tokens; dienst-kaarttitels op `/diensten` → `<h3>`; eerste-fold-beelden eager + `fetchpriority` (homepage-hero + diensten-overzicht); accordion-`flex-grow` gescoped met `contain: layout` (bewuste, begrensde uitzondering). Na fixes: axe 0 op alle 9 pagina's, geen overflow (1440/768/390), `astro check` 0. **Laatste deploy: version `ff725970`.**
|
||||
|
||||
**Embedcode-veld op `paginas` (23-06-2026, deploy `1e7fcdfc`):** klantvraag — een `<script>` (cookieverklaring) in het Inhoud-veld werd als tekst getoond. Oorzaak: PortableText escapet HTML, het "Code"-blok toont code bewust, en het `htmlBlock` filtert `<script>` via sanitize-html. Oplossing (optie C): nieuw veld **`embed_code`** op de collectie `paginas` (`migrations/0013_paginas_embed.sql` — ALTER TABLE + `_emdash_fields`-rij, want `paginas` was al geseed) dat `[...slug].astro` rauw via `set:html` rendert (geen sanitatie; bewuste escape-hatch, alleen vertrouwde admins). Sentinel-geverifieerd op de live `/cookieverklaring` (raw `<script>` aanwezig, geen `<script>`). Uitleg voor de SEO-partij in `CMS-SEO-handleiding.md` §3b. Site-brede cookiebanner hoort nog steeds in GTM. **De CookieScript-`<script>` STAAT LIVE** op `/cookieverklaring` (een autosave schreef `embed_code` succesvol weg vóór de fout hieronder).
|
||||
|
||||
**Cold-start CPU-limiet / Error 1102 op zware/koude routes (23-06-2026, deploy `5fe90377`):** klant meldde "Autosave failed" + Cloudflare **Error 1102 "Worker exceeded resource limits"**. Uitgezocht (live `wrangler tail`): `outcome:exceededCpu` op **koude isolates** voor laag-verkeer-routes (`/_emdash/admin/*`, 404-catch-all, favicon); de constant door Uptime-Kuma warmgehouden homepage faalt nooit; routes herstellen zodra warm (favicon 503→404, admin 503→302). **Niet** WAF (PUT/PATCH met `<script>`-body bereikt de app), **niet** de DB (kolomwrite werkt), **niet** mijn veld (de falende routes draaien mijn code niet). Oorzaak = **zware worker cold-start**: bundle ~4,8 MB (o.a. een 1,48 MB `adapt-sandbox-entry`-chunk), overschrijdt soms de **vaste, niet-instelbare 1s startup-CPU-limiet**. **`limits.cpu_ms` ophogen helpt NIET** (per-request default is al 30s; `usage_model=standard`, `limits=None`). Verzachting doorgevoerd: **`public/favicon.ico`** (statische asset i.p.v. SSR) + **extensie-guard in `[...slug].astro`** (paden met bestandsextensie → kale 404, scheelt 2 D1-lookups + Base-render per botscan). De zware emdash-admin blijft op koude isolates af en toe 1102 geven → **herstelt met refresh**; structureel verzachten = admin-URL aan de uptime-monitor toevoegen (warmhouden) of de worker-bundle afslanken (groter, emdash-intern, niet reactief gedaan).
|
||||
|
||||
**"Failed to publish" GEFIXT + rijkere locatiepagina-opmaak (25-06-2026, deploy `69899ec5`).** Klant kon geen enkele locatie-/gewone pagina publiceren ("Failed to publish"); saven/deleten gaven ook "Failed to…" maar lukten na refresh. **Echte oorzaak (deterministisch, lokaal 1-op-1 met python sqlite3 gereproduceerd):** de setup-wizard had voor `locaties` + `paginas` een **FTS5 external-content** zoekindex (`content='ec_locaties'`) met een **kapotte UPDATE-trigger** (`DELETE FROM _emdash_fts_* WHERE rowid=OLD.rowid` herberekent oude termen uit de al-bijgewerkte brontabel → `database disk image is malformed` → UPDATE rolt terug). Publiceren doet intern `UPDATE ec_* SET <kolommen>` (syncDataColumns) → trigger vuurt → faalt **altijd**. (/maastricht stond alleen live omdat die als 'published' is GESEED via INSERT, nooit het UPDATE-pad raakte; `live_revision_id` was NULL.) Saven schrijft naar `revisions` (geen kolom-UPDATE) en soft-delete zet alleen `deleted_at` (geen searchable kolom) → die raken de bug niet, vandaar "lukt na refresh". **Fix:** `migrations/0014_fix_publish_drop_fts.sql` dropt de 6 triggers + 2 FTS-tabellen en zet `searchable=0` → net als de zelf-gegenereerde collecties die al prima publiceren. **Pure D1-wijziging, geen redeploy** (publiceren werkt meteen). Frontend gebruikt geen FTS; alleen de admin-zoekbalk voor die 2 collecties vervalt. **Memory:** zie `emdash-fts-publish-bug` — geldt mogelijk ook voor `../exterios`/`../behangerbrabant` (zelfde wizard-seed). NB: de "Failed to saven/deleten maar lukt-na-refresh"-meldingen zijn de aparte, mildere cold-start/1102-kwestie (niet blokkerend).
|
||||
- **"Failed to saven/deleten/dupliceren/publiceren maar lukt-na-refresh" GEFIXT (deploy `fecc2ea3`) — was NIET cold-start.** Via `wrangler tail` bleek de echte fout: **elke** content-mutatie-route in Emdash 0.1.1 crasht ná de geslaagde DB-actie op `if (cache.enabled) await cache.invalidate(...)` — `cache` wordt uit de Astro-context gedestructureerd maar is **undefined** op Cloudflare-SSR → `TypeError: Cannot read properties of undefined (reading 'enabled')` → 500 → "Failed to …"-toast, terwijl de write al gecommit was (vandaar "lukt na refresh"). **Dupliceren** crasht volledig (geen FTS), vandaar "Failed to duplicate content:" terwijl de kopie-rij wél verscheen. **Fix:** alle `cache.enabled` → `cache?.enabled` in `node_modules/emdash/src/astro/routes/api/content/*` (11 plekken, 9 bestanden), **persistent via patch-package** (`patches/emdash+0.1.1.patch` + `"postinstall":"patch-package"`). ⚠️ Installs draaien met `--ignore-scripts` → postinstall draait NIET automatisch; **na elke `npm install` handmatig `npx patch-package` + rebuild/deploy**. Memory: `emdash-cache-enabled-bug` (geldt ook voor `../exterios`/`../behangerbrabant`). Hierna geeft saven/publiceren/dupliceren géén foutmelding meer.
|
||||
- **Rijkere locatiepagina-opmaak** (`migrations/0015_locaties_layout.sql` + `src/pages/[...slug].astro` locatie-tak): naast hero + (optionele) `hero_image` nu **4 vaste content-blokken** (`blokN_heading`/`blokN_text`/`blokN_image`, om-en-om/zig-zag, foto optioneel, leeg blok = niet getoond) + aanpasbare **CTA-balk** (`cta_heading`/`cta_text`, met plaats-fallback) + automatische **"Onze diensten in {plaats}"-strip** (vaste links naar de 5 dienstpagina's). Het oude `content`-veld is op **klantverzoek VERWIJDERD** van locatiepagina's (`migrations/0017_locaties_remove_content.sql` + render eruit in `[...slug].astro`): het gaf onderaan een lege grijze balk (Maastricht's `content` was teruggevallen op één leeg block) en is overbodig naast de 4 blokken. Alleen de velddefinitie weg (admin); de kolom blijft (data-behoud). `paginas` houdt zijn inhoud-veld wél. Deploy `7ef99e24`. **Ook de CTA-balk verwijderd** (klantverzoek, dubbelop: het offerteformulier staat er al onder) — `loc-band`-sectie uit `[...slug].astro` + velden `cta_heading`/`cta_text` van `locaties` (`migrations/0018_locaties_remove_cta.sql`), deploy `63882d87`. Locatiepagina = nu hero → 4 blokken → "Onze diensten in {plaats}"-strip → prefooter-offerteformulier (hero-CTA scrollt naar `#contact`).
|
||||
|
||||
**Blok-tekst → RICH TEXT (PortableText) + 2 koppen → `<p>` (25-06-2026, deploy `d47253f9`).** Klantverzoek: opsommingen/lijsten + interne links waren onmogelijk in het platte `blokN_text`-veld (zelfs rauwe `<a>` werd als tekst getoond). **Fix:** `blok1_text`..`blok4_text` op `locaties` omgezet van `text` → **`portableText`** (`migrations/0019_locaties_blocks_portabletext.sql`): admin toont nu de `PortableTextEditor` met toolbar (links via `setLink`, `toggleBulletList`/`toggleOrderedList`, vet/cursief); frontend rendert via `<PortableText>` (astro-portabletext → `<ul>/<ol>/<a>`) in een `.loc-block__body.prose`. **Bestaande platte tekst geconverteerd** met een Node-script (`scratchpad/convert.mjs`): `\n\n`→paragrafen, `- `→bullets, `N. `→genummerde lijst (titel+omschrijving samengevoegd per item), `<a href>`→echte link-marks; 12 rijen (o.a. `gevelrenovatie-limburg` 4 blokken, maastricht + kopieën). Live geverifieerd: `<ul>`/`<ol>`/`/diensten/voegwerk`-link renderen. **Twee niet-SEO-koppen → `<p>`** (klantverzoek, heading-hygiëne): "Onze diensten in {plaats}" (`.loc-diensten__h` in `[...slug].astro`) en de prefooter "Vraag een offerte aan" (`.prefooter__title` in `Base.astro`, **globaal** — alle pagina's met prefooter; /contact heeft hidePrefooter + eigen kop). Beide via class gestyled als kop (display-font) maar zonder H2-semantiek. **Kleur-regressie gefixt (deploy `b28a7f74`):** door de h2→p-wissel matchte de bestaande `.prefooter__lead p`-regel (grijs, on-dark-soft) ook de titel en won qua specificiteit van `.prefooter__title` (wit) → titel werd grijs. Fix: `.prefooter__lead p` → `.prefooter__lead p:not(.prefooter__title)`, titel weer wit.
|
||||
|
||||
**2 content-blokken op dienst-detailpagina's (25-06-2026, deploy `9325b946`, `migrations/0020_diensten_blocks.sql`).** Klantverzoek (extra content voor Google Ads-landingspagina's): onder de eerste alinea-sectie van `/diensten/<slug>` 2 optionele blokken (kop + **PortableText** rich-text + optionele foto, om-en-om/zig-zag), exact zoals blok 1/2 op locaties. Velden `blok1_*`/`blok2_*` op collectie `diensten` (sort_order 16-21, ná `included`, vóór `resultaat`; rest 6 opgeschoven). `diensten/[slug].astro`: `svcBlocks` uit CMS (geen fallback) → `.svc-block`-sectie (`section--paper`) tussen `svc-intro` en `svc-result`; leeg = niet getoond. Rendert via `<PortableText components={{mark: PtMark}}>` (links zelfde tab + opt-in nieuw tabblad). Live geverifieerd op dakreiniging-coating (2 blokken, zig-zag, foto's, vóór resultaat-galerij; leeg = weg). `astro check` 0. Handleiding §6b bijgewerkt. De 4 blok-**koppen** blijven `<h2>` (echte content). `astro check` 0. Veldtype-wijziging = admin herladen (Ctrl+F5).
|
||||
|
||||
**Links in rich text: standaard zelfde tabblad + opt-in "nieuw tabblad" (25-06-2026, deploy `6c1bf250`).** Klantverzoek: links openden in nieuw tabblad; moet standaard zelfde tabblad, met een vinkje voor nieuw tabblad. **Renderer:** `src/components/PtMark.astro` (custom astro-portabletext `mark`-component, doorgegeven via `components={{ mark: PtMark }}` in `[...slug].astro` voor blok-body én paginas-content). Rendert `<a>` zonder target (zelfde tab) tenzij `node.markDef.blank === true` → dan `target="_blank" rel="noopener noreferrer"`. **LET OP:** moet een ENKELE `mark`-component zijn — `components={{ mark: { link: ... } }}` (record) vervangt de hele Mark-component en wijzigt de prop-shape (vet/cursief breken + blank werkt niet). emdash's eigen PM↔PT-conversie mapt `target==="_blank"` ↔ `markDef.blank` al; geverifieerd via `json_set($[0].markDefs[0].blank,true)` → live `target="_blank"`. **Editor:** "Openen in nieuw tabblad"-checkbox toegevoegd aan BEIDE link-UI's (`EditorBubbleMenu` + `EditorToolbar`) in `@emdash-cms/admin/dist/index.js` → `setLink({href, target: newTab?"_blank":null})` + laden via `getAttributes("link").target`. Persistent in `patches/@emdash-cms+admin+0.1.1.patch` (bevat nu image-clear + caret + link-newtab). Client-bundle → **Ctrl+F5**. Standaard same-tab werkt zonder editor-actie (renderer). Alles CMS-bewerkbaar door de SEO-partij met veilige fallbacks; live geverifieerd (hero/blokken/zig-zag/alinea-split/diensten-strip/CTA-band renderen, getest op een tijdelijk un-soft-delete'te kopie). `astro check` 0. Handleiding §4 bijgewerkt. **Testkopieën:** `…copy`/`…copy-2` zijn soft-deleted, `…copy-1` is de WIP-draft van de klant (onaangeroerd).
|
||||
|
||||
**Aangeleverd (staat live):** telefoon `085 060 4956`, adres `Stationsplein 1, 6131 AS Sittard`. **Werkgebied (klant 16-06-2026):** kop + alle SEO-copy blijven **"Helvoirt en omgeving"**. **Alleen de werkgebied-pills vernieuwd:** Helvoirt blijft vooraan, daarna de nieuwe steden — **Helvoirt, Maastricht, Echt, Roermond, Weert, Sittard, Heerlen, Brunssum, Kerkrade, Landgraaf** (in `index.astro` + `over-ons.astro`). Merknaam "Helvoirt Gevelreiniging" (= achternaam) blijft. **Placeholders (wachten op klantdata):** e-mail `info@helvoirtgevelreiniging.nl`, Google-reviews (+ -URL), FAQ-teksten, alle AI-foto's.
|
||||
|
||||
## Hervatten (live zetten)
|
||||
1. **`GO-LIVE.md`** = de kant-en-klare go-live-checklist (secret, CMS-user, `routes`+rebuild, mail-DNS, HTTPS, verificatie) — start daar zodra DNS+mailaccount er zijn. Verder: `todo.md` (`← VOLGENDE STAP` + §9/§12) + dit bestand (`← GO-LIVE CODE-LAAG AF`) + `architecture.md`. SEO-partij-uitleg in `CMS-SEO-handleiding.md`.
|
||||
2. **Werken gaat vooral via deploy** (dev-server is instabiel; testen het betrouwbaarst op de live worker). Env elke call: `$env:NODE_OPTIONS="--use-system-ca"` (+ `$env:CLOUDFLARE_ACCOUNT_ID="41972f4905f2c83f51111b80283e030b"` voor wrangler). Tools via `node node_modules/<tool>/bin/...`.
|
||||
3. **CMS-content op de live D1 lezen/wijzigen:** `node node_modules/wrangler/bin/wrangler.js d1 execute helvoirt-db --remote --command "..."` (of `--file migrations/x.sql`). Verifieer een wijziging via een sentinel: zet een herkenbare waarde, fetch de live pagina (`Invoke-WebRequest`), zet terug.
|
||||
4. **Nieuwe collectie/veld toevoegen** = `scripts/gen-cms-sql.mjs` model → `migrations/*.sql` → `--remote --file`. Pagina daarna laten lezen uit CMS **met de huidige tekst als fallback** (zo breekt de site nooit). Beeld = `resolveCmsImage` + `wrangler r2 object put` + `media`-rij.
|
||||
5. Volgorde go-live: zie `newsite.md` §12 (zone/nameservers, custom domain via `routes` + rebuild, mail-DNS, HTTPS, SEO-basics, checklist, handover). Design-werk indien nodig: `/impeccable` (register = brand, conversiegericht).
|
||||
@@ -0,0 +1,208 @@
|
||||
# CMS-handleiding voor de SEO-partij — Helvoirt Gevelreiniging
|
||||
|
||||
Korte, praktische uitleg over het beheren en SEO-optimaliseren van pagina's in het CMS
|
||||
(Emdash). Het CMS is bewust eenvoudig ingericht: je werkt met **Instellingen**, **Pagina's**
|
||||
en **Locatiepagina's**.
|
||||
|
||||
> Admin-URL: `https://<domein>/_emdash/admin` (op de preview: `https://helvoirt-gevelreiniging.mike-197.workers.dev/_emdash/admin`).
|
||||
|
||||
---
|
||||
|
||||
## 1. Inloggen
|
||||
|
||||
Het CMS werkt **zonder wachtwoord** met een inloglink per e-mail (magic-link):
|
||||
|
||||
1. Ga naar `/_emdash/admin/login`.
|
||||
2. Klik **"Sign in with email link"**, vul je e-mailadres in.
|
||||
3. Je krijgt een (Engelstalige) mail "Sign in to …" → klik de knop → je bent ingelogd.
|
||||
|
||||
> Je e-mailadres moet eerst als gebruiker zijn aangemaakt (dat doet De Websmid eenmalig).
|
||||
> Open registratie bestaat niet. Geef je gewenste e-mailadres(sen) door.
|
||||
|
||||
---
|
||||
|
||||
## 2. De opbouw van het CMS
|
||||
|
||||
| Onderdeel | Waarvoor |
|
||||
|---|---|
|
||||
| **Instellingen** | Site-brede gegevens: telefoon, e-mail, adres, **standaard SEO-titel/omschrijving**, **Google Tag Manager**, social links, Google-score. |
|
||||
| **Pagina's** | Gewone, vrij in te delen pagina's (bv. een informatie- of landingspagina). URL = de slug, bv. `/onderhoudstips`. |
|
||||
| **Locatiepagina's** | Lokale SEO-pagina's per plaats. URL = `/<plaats>`, bv. `/maastricht`. |
|
||||
|
||||
Bij **Pagina's** en **Locatiepagina's** staat in elk item een blok **SEO-velden** onderaan
|
||||
(titel, omschrijving, canonical, deelafbeelding, noindex).
|
||||
|
||||
---
|
||||
|
||||
## 3. Een gewone pagina aanmaken
|
||||
|
||||
1. Ga naar **Pagina's → Nieuw** (of dupliceer de meegeleverde *"Voorbeeldpagina (sjabloon)"*).
|
||||
2. Vul in:
|
||||
- **Paginatitel** — de grote kop (H1) op de pagina.
|
||||
- **Slug** — het stukje achter de domeinnaam. `onderhoudstips` → `/onderhoudstips`.
|
||||
- **Intro** (optioneel) — korte inleiding onder de kop.
|
||||
- **Inhoud** — de hoofdtekst. Gebruik koppen (H2/H3), alinea's en lijsten.
|
||||
- **SEO-velden** (zie §5).
|
||||
3. Zet de status op **Gepubliceerd** en sla op. De pagina is direct live (geen rebuild nodig).
|
||||
|
||||
> Laat je een pagina op **Concept** staan, dan is hij wel zichtbaar in het CMS maar **niet** op
|
||||
> de site (de bezoeker krijgt een nette 404).
|
||||
|
||||
---
|
||||
|
||||
## 3b. Een script of embed op een pagina plaatsen (bv. cookieverklaring)
|
||||
|
||||
Wil je een **extern script** of stukje **HTML/JavaScript** op een pagina draaien, bijvoorbeeld een
|
||||
**cookieverklaring** (Cookiebot, CookieFirst, Complianz, Termly, …), een formulier-embed of een
|
||||
widget? Plak dat **niet** in het **Inhoud**-veld. Dat veld is voor tekst en zet code om in *zichtbare
|
||||
letters* (je ziet dan letterlijk `<script>…` op de pagina staan, of de code verdwijnt). Ook een
|
||||
"Code"-blok is bedoeld om code te *tónen*, niet om hem uit te voeren.
|
||||
|
||||
Gebruik in plaats daarvan het veld **"Embedcode / scripts (geavanceerd)"** onderaan een pagina
|
||||
(onder de SEO-velden):
|
||||
|
||||
1. Open de pagina (bv. **Pagina's → Cookieverklaring**).
|
||||
2. Plak de volledige embed-/scriptcode die je leverancier geeft (inclusief de `<script …></script>`)
|
||||
in **Embedcode / scripts**.
|
||||
3. Sla op. De code wordt **1-op-1 in de pagina-body geladen en uitgevoerd**, onder de gewone inhoud.
|
||||
(Je mag het Inhoud-veld leeg laten of er een korte intro-tekst boven zetten.)
|
||||
|
||||
> **Let op:** dit veld voert code uit zonder controle. Plak hier **alleen code van een vertrouwde
|
||||
> bron** (je cookie-/consent-leverancier). Een typefout of een verkeerd script kan de pagina breken.
|
||||
> Twijfel je? Stem het even af met De Websmid.
|
||||
>
|
||||
> De **site-brede** cookiebanner (die op álle pagina's moet verschijnen) hoort niet hier, maar in
|
||||
> **Google Tag Manager** (zie §5, GTM-ID in Instellingen). Dit veld is voor pagina-specifieke embeds
|
||||
> zoals juist de cookie**verklaring**.
|
||||
|
||||
---
|
||||
|
||||
## 4. Een locatiepagina aanmaken (lokale SEO)
|
||||
|
||||
1. Ga naar **Locatiepagina's → Nieuw** (of dupliceer de meegeleverde *"Maastricht"*-pagina).
|
||||
2. Vul in:
|
||||
- **Plaats** — bv. `Echt`. Deze naam gebruiken we in de kop, teksten en de diensten-strip.
|
||||
- **Slug** — bv. `echt` → de pagina komt op `/echt`.
|
||||
- **Paginakop / H1** — laat leeg voor automatisch *"Gevelreiniging in Echt"*, of typ een
|
||||
eigen kop (bv. *"Gevelreiniging en voegwerk in Echt"*).
|
||||
- **Intro** — korte lokale inleiding (staat in de donkere balk bovenaan, naast de CTA).
|
||||
- **Hero-foto bovenaan** (optioneel) — zet je er een foto in, dan komt die rechts in de
|
||||
donkere balk. Leeg = nette donkere balk zonder foto.
|
||||
- **Blok 1 t/m 4** (kop + tekst + optionele foto) — dit is de **opmaak met 4 koppen en
|
||||
alinea's**. Elk blok dat je invult verschijnt als een nette sectie; met een foto erbij
|
||||
komt tekst en beeld **om-en-om** te staan (links/rechts), zonder foto loopt de tekst over
|
||||
de volle breedte. Laat blokken die je niet nodig hebt gewoon leeg — die worden niet getoond.
|
||||
Het tekstveld van een blok is een **rich-text editor**: gebruik de werkbalk voor
|
||||
**opsommingen (bullets), genummerde lijsten, links (interne links naar je dienstpagina's),
|
||||
vet en cursief**. Je hoeft dus geen HTML te typen. Links openen standaard in **hetzelfde
|
||||
tabblad**; wil je een link in een nieuw tabblad openen, vink dan **"Openen in nieuw tabblad"**
|
||||
aan in het link-venster (handig voor links naar andere websites). (Bestaande blokteksten zijn automatisch
|
||||
omgezet: opsommingen, stappen en links zijn behouden — controleer ze even en verfijn naar
|
||||
wens, bv. de titel van een stap vet maken.)
|
||||
- **SEO-velden** (zie §5).
|
||||
|
||||
> De lokale inhoud (je SEO-tekst) zet je in de **4 blokken** hierboven (kop + alinea + foto).
|
||||
> Het oude losse veld "Lokale inhoud / Extra tekst" is op locatiepagina's verwijderd.
|
||||
3. Zet op **Gepubliceerd** en sla op.
|
||||
|
||||
> **Vaste onderdelen die je niet hoeft in te vullen:** de donkere hero met "Gratis offerte" +
|
||||
> 4,9-Google-regel, een "Onze diensten in {plaats}"-strip met links naar de 5 dienstpagina's,
|
||||
> en het offerteformulier onderaan staan er **automatisch** op elke locatiepagina.
|
||||
|
||||
> **Belangrijk voor SEO:** maak elke locatiepagina **uniek**. Tien keer dezelfde tekst met
|
||||
> alleen een andere plaatsnaam telt als dunne/duplicate content en kan averechts werken. Vul
|
||||
> de 4 blokken met echte, lokale tekst (waarom klanten in die plaats voor jullie kiezen, een
|
||||
> lokaal project, een wijk/straat, reistijd, etc.).
|
||||
|
||||
---
|
||||
|
||||
## 5. SEO optimaliseren
|
||||
|
||||
### Per pagina (in Pagina's en Locatiepagina's)
|
||||
- **SEO · titel** — de titel in Google + de browsertab. Houd ~50–60 tekens aan. Leeg laten =
|
||||
de paginatitel/kop wordt gebruikt.
|
||||
- **SEO · omschrijving** — de tekst onder de titel in Google (snippet). ~150–155 tekens.
|
||||
- **SEO · canonical-URL** — laat **leeg** (dan is de pagina zijn eigen canonical). Alleen
|
||||
invullen bij een bewuste verwijzing naar een andere URL.
|
||||
- **SEO · deelafbeelding (OG-image)** — de afbeelding bij delen op social media. Optioneel
|
||||
(anders wordt de standaard uit Instellingen gebruikt).
|
||||
- **SEO · noindex** — **alleen aanzetten** als de pagina *niet* in Google mag verschijnen.
|
||||
Standaard uit.
|
||||
|
||||
### Site-breed (in Instellingen)
|
||||
- **SEO · standaard paginatitel / omschrijving** — de fallback voor de homepage en pagina's
|
||||
zonder eigen SEO-titel.
|
||||
- **Google Tag Manager ID** — vul hier alleen het container-ID in (`GTM-XXXXXX`). De code
|
||||
wordt automatisch geplaatst. Leeg = geen tracking.
|
||||
- **Standaard deelafbeelding (OG-image)** — voor pagina's zonder eigen OG-image.
|
||||
|
||||
---
|
||||
|
||||
## 5b. Handige bewerk-functies (zoals in WordPress)
|
||||
|
||||
- **Pagina dupliceren:** in een lijst (Pagina's / Locatiepagina's) staat per rij een **kopieer-icoon**
|
||||
(naast het potlood en de prullenbak). Daarmee maak je een **kopie als concept**, met alle velden
|
||||
en SEO. Ideaal om een nieuwe stadspagina te starten vanaf een bestaande.
|
||||
- **Rijen toevoegen/verwijderen/verslepen:** sommige velden zijn **repeaters** (bv. "Waarden" op
|
||||
Over ons). Daar voeg je met **"Add item"** een rij toe, verwijder je een rij met het prullenbak-
|
||||
icoon, en **versleep** je rijen om de volgorde te wijzigen. Net een mini page-builder.
|
||||
- **Losse lijst-items:** waar iets een eigen onderdeel in de zijbalk is (bv. een collectie),
|
||||
gebruik je **"Add New"** voor een nieuw item en de prullenbak om te verwijderen.
|
||||
- **"(optional)" bij velden:** velden die niet verplicht zijn tonen "optional". Dat is standaard in
|
||||
het CMS en niet uit te zetten; het zegt simpelweg dat je het veld leeg mag laten.
|
||||
|
||||
## 6. Do's & don'ts
|
||||
|
||||
- **Locatiepagina's én gewone Pagina's staan beide op de root** (`/maastricht`,
|
||||
`/onderhoudstips`). Geef ze daarom een **unieke slug**: dezelfde slug bij een Locatie- én
|
||||
een Pagina kan niet (de Locatiepagina wint dan). Gebruik voor plaatsen altijd een
|
||||
**Locatiepagina** (die heeft het juiste lokale sjabloon).
|
||||
- **Hergebruik geen bestaande URL's als slug.** Deze vaste paden gaan vóór een CMS-pagina:
|
||||
`/`, `/diensten` (+ dienstpagina's), `/over-ons`, `/contact`, `/werkgebied`. Een pagina met
|
||||
slug `contact` wordt dus nooit getoond.
|
||||
- **Afbeeldingen** upload je via de mediabibliotheek in het CMS; kies het bestand in het
|
||||
betreffende beeldveld.
|
||||
- **Concept vs Gepubliceerd:** alleen gepubliceerde items staan live.
|
||||
|
||||
---
|
||||
|
||||
## 6b. De vaste pagina's beheren (Homepage, Over ons, Contact, Diensten)
|
||||
|
||||
Naast **Pagina's** en **Locatiepagina's** staan ook de bestaande vaste pagina's in het CMS,
|
||||
elk als een eigen onderdeel in de zijbalk:
|
||||
|
||||
| Onderdeel | Wat je er beheert |
|
||||
|---|---|
|
||||
| **Homepage** | Alle teksten van de homepage: hero, intro, koppen, werkwijze-stappen, reviews, FAQ (repeater-rijen). |
|
||||
| **Homepage · hero-foto's** | De 3 staande werkfoto's in de hero (foto + titel + ondertitel + alt). Volgorde via *Volgorde*. |
|
||||
| **Homepage · bewijs-galerij** | De 6 foto's in "Echt werk, echte resultaten" (foto + bijschrift; vink *Uitgelicht* voor de grote tegel). |
|
||||
| **Over ons (pagina)** | Hero, verhaal, waarden (repeater) + de twee foto's + SEO. |
|
||||
| **Contactpagina** | Kicker, titel, intro + SEO. (Telefoon/e-mail/adres staan in **Instellingen**.) |
|
||||
| **Diensten** | De 5 dienstpagina's. Elk item stuurt **tegelijk** de detailpagina (`/diensten/<slug>`), de kaart in het overzicht (`/diensten`), het paneel op de homepage én het menu aan. Velden: menu-titel/tekst/foto, hero, intro + "wat we doen", resultaat-galerij (3 foto's), aanpak-stappen, FAQ en SEO. Daarnaast **Blok 1 en Blok 2** (kop + rich-text + optionele foto, om-en-om getoond) onder de eerste alinea — zelfde rich-text-editor als de locatiepagina-blokken (lijsten, links, vet), leeg = niet getoond; handig voor extra content (bv. landingspagina's). **Volgorde** bepaal je met het veld *Volgorde in het menu (1-5)*. |
|
||||
| **Algemene voorwaarden** | De pagina `/voorwaarden`. Titel + intro + de artikelen als repeater-rijen (artikelkop + tekst; toevoegen/verwijderen/verslepen) + SEO. |
|
||||
|
||||
> **Beeldvelden met "leeg = standaardfoto":** laat je een fotoveld leeg, dan toont de site de
|
||||
> meegeleverde standaardfoto. Upload je een eigen foto, dan vervangt die de standaard.
|
||||
|
||||
## 7. Wat (nu nog) niet via het CMS gaat — eerlijk overzicht
|
||||
|
||||
**Volledig via het CMS te beheren:** Instellingen (NAW + site-brede SEO + GTM), Pagina's,
|
||||
Locatiepagina's, en alle vaste pagina's: **Homepage, Over ons, Contactpagina, Diensten en
|
||||
Algemene voorwaarden** (incl. het menu, dat de Diensten-volgorde/titels volgt).
|
||||
|
||||
Nog **in code** (bewust, want het is geen redactionele inhoud): de algemene **opmaak/lay-out**
|
||||
en losse technische teksten. Wil je daar iets aan wijzigen, geef het door aan De Websmid.
|
||||
|
||||
---
|
||||
|
||||
## 8. Operationeel — vóór livegang (door De Websmid)
|
||||
|
||||
Deze stappen activeren het bovenstaande; ze staan los van het dagelijkse pagina-beheer en
|
||||
worden door De Websmid uitgevoerd (zie `newsite.md`):
|
||||
|
||||
1. **Seed draaien** — het content-model (`seed/seed.json`) inladen via de setup-wizard op
|
||||
`/_emdash/admin/setup` (eenmalig). Daarna verschijnen Instellingen/Pagina's/Locatiepagina's.
|
||||
2. **CMS-gebruiker(s) aanmaken** voor de SEO-partij (magic-link, §1).
|
||||
3. **Sitemap** uitbreiden met gepubliceerde Pagina's + Locatiepagina's (§9 in `todo.md`).
|
||||
4. **Interne links** naar de locatiepagina's leggen (bv. vanuit de werkgebied-sectie/menu)
|
||||
zodat ze goed vindbaar zijn.
|
||||
@@ -0,0 +1,104 @@
|
||||
# Go-live checklist — Helvoirt Gevelreiniging
|
||||
|
||||
Concrete, kant-en-klare stappen om de site live te zetten op `helvoirtgevelreiniging.nl`.
|
||||
De **code-laag is af + gedeployed** (preview: `https://helvoirt-gevelreiniging.mike-197.workers.dev`);
|
||||
hieronder staat alleen wat nog **externe input/toegang** vereist. Achtergrond: `newsite.md` §12.
|
||||
|
||||
> **Windows/corporate, elke `astro`/`wrangler`-call:** zet eerst
|
||||
> `$env:NODE_OPTIONS="--use-system-ca"` (+ `$env:CLOUDFLARE_ACCOUNT_ID="41972f4905f2c83f51111b80283e030b"`
|
||||
> voor wrangler). Tools via `node node_modules/<tool>/bin/...`.
|
||||
|
||||
---
|
||||
|
||||
## Vooraf aanleveren (collega / klant)
|
||||
- **Mailbox** `info@helvoirtgevelreiniging.nl`: wachtwoord + SMTP-host (bv. `mail.helvoirtgevelreiniging.nl`,
|
||||
poort 465) + DKIM-record uit DirectAdmin. (Collega maakt het account aan.)
|
||||
- **DNS-toegang**: nameservers naar Cloudflare (collega) → de zone `helvoirtgevelreiniging.nl`
|
||||
verschijnt in het account. Voor DNS-records: dashboard of een API-token met **Zone.DNS:Edit**.
|
||||
- **SEO-partij e-mailadres(sen)** voor de CMS-login.
|
||||
- **Klantcontent**: echte e-mail/KvK/BTW/openingstijden, Google-reviews + URL + score, FAQ-teksten,
|
||||
foto's, evt. GTM-container-ID en eigen logo.
|
||||
|
||||
---
|
||||
|
||||
## 1. Mail-secret zetten (na mailbox)
|
||||
Het wachtwoord is het **enige** geheim. **Via bash `printf`**, nooit via PowerShell-pipe (BOM → 535).
|
||||
```bash
|
||||
printf '%s' '<mailbox-wachtwoord>' | NODE_OPTIONS=--use-system-ca \
|
||||
node node_modules/wrangler/bin/wrangler.js secret put CONTACT_SMTP_PASS
|
||||
```
|
||||
Wijkt host/gebruiker/inbox af van de defaults (`mail.helvoirtgevelreiniging.nl`,
|
||||
`info@helvoirtgevelreiniging.nl`)? Zet dan ook `CONTACT_SMTP_HOST` / `CONTACT_SMTP_USER` /
|
||||
`CONTACT_EMAIL_TO` als secret/var. **Testen** (na deploy): doe een test-aanvraag op `/contact` en kijk
|
||||
in `wrangler tail` naar `[contact] mail-sent melding-klant` + `bevestiging-aanvrager`. Ruim de testrij
|
||||
op uit `contact_submissions`.
|
||||
|
||||
## 2. CMS-gebruiker SEO-partij aanmaken
|
||||
```powershell
|
||||
$ulid = node --input-type=module -e "import {ulid} from 'ulidx'; console.log(ulid())"
|
||||
node node_modules/wrangler/bin/wrangler.js d1 execute helvoirt-db --remote --command `
|
||||
"INSERT INTO users (id, email, role, email_verified, disabled, created_at, updated_at) VALUES `
|
||||
('$ulid','<email-lowercase>',40,1,0,strftime('%Y-%m-%dT%H:%M:%fZ','now'),strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
|
||||
```
|
||||
Rol 40 = EDITOR (content + SEO, geen gebruikers/instellingen), 50 = ADMIN. Inloggen daarna via
|
||||
`/_emdash/admin/login` → "Sign in with email link" (magic-link werkt zodra stap 1 gezet is).
|
||||
|
||||
## 3. Domein koppelen ✅ GEDAAN (17-06-2026) — site live op https://helvoirtgevelreiniging.nl
|
||||
> Zone actief, `routes` toegevoegd + rebuild + deploy gedaan; apex+www live, www→apex + http→https werken.
|
||||
> Hieronder ter referentie wat er is gebeurd:
|
||||
Voeg dit blok toe aan **`wrangler.jsonc`** (naast `main`/`compatibility_date`):
|
||||
```jsonc
|
||||
"routes": [
|
||||
{ "pattern": "helvoirtgevelreiniging.nl", "custom_domain": true },
|
||||
{ "pattern": "www.helvoirtgevelreiniging.nl", "custom_domain": true }
|
||||
],
|
||||
```
|
||||
**Daarna VERPLICHT rebuilden** (de adapter genereert `dist/server/wrangler.json`; zonder rebuild
|
||||
deployt wrangler stale config):
|
||||
```powershell
|
||||
$env:NODE_OPTIONS="--use-system-ca"; $env:CLOUDFLARE_ACCOUNT_ID="41972f4905f2c83f51111b80283e030b"
|
||||
node node_modules/astro/bin/astro.mjs build
|
||||
node node_modules/wrangler/bin/wrangler.js deploy
|
||||
```
|
||||
> ⚠️ Voeg de routes pas toe **als de zone bestaat**, anders faalt de deploy. Tot dan blijft
|
||||
> `wrangler.jsonc` zonder `routes` (preview op `*.workers.dev`).
|
||||
|
||||
## 4. Mail-DNS (dashboard of Zone.DNS:Edit-token)
|
||||
| Type | Naam | Waarde | Prio |
|
||||
|---|---|---|---|
|
||||
| MX | `@` | `<smtp-host, bv. mail.helvoirtgevelreiniging.nl>` | 10 |
|
||||
| TXT (SPF) | `@` | `v=spf1 mx ~all` (of het SPF-record van de hoster) | — |
|
||||
| TXT (DMARC) | `_dmarc` | `v=DMARC1; p=none; rua=mailto:info@helvoirtgevelreiniging.nl` | — |
|
||||
| TXT (DKIM) | `<selector>._domainkey` | public key uit DirectAdmin | — |
|
||||
|
||||
MX = **DNS-only** (geen Cloudflare-proxy). Verifieer via DoH (corporate-DNS blokkeert poort 53):
|
||||
`curl -sk "https://dns.google/resolve?name=helvoirtgevelreiniging.nl&type=MX"`.
|
||||
|
||||
## 5. HTTPS / security (dashboard)
|
||||
- **SSL/TLS → Edge Certificates → Always Use HTTPS** aan, daarna **HSTS** aan.
|
||||
- **SSL-modus** op **Full (strict)** (niet Flexible → redirect-loop).
|
||||
- `src/middleware.ts` doet al http→https + www→apex (301); security-headers staan in `public/_headers`.
|
||||
|
||||
## 6. Verifiëren (corporate-DNS resolvet het nieuwe domein niet → forceer CF-IP)
|
||||
```powershell
|
||||
$ip = (Invoke-RestMethod "https://dns.google/resolve?name=helvoirtgevelreiniging.nl&type=A").Answer[0].data
|
||||
curl.exe -sk --resolve helvoirtgevelreiniging.nl:443:$ip https://helvoirtgevelreiniging.nl/
|
||||
```
|
||||
Checklist: homepage 200 · `/sitemap.xml` (root-URL's, incl. CMS-locaties) · `/robots.txt` ·
|
||||
`www` → apex 301 · `http` → `https` 301 · JSON-LD in de head · contactformulier → 2× mail.
|
||||
|
||||
## 7. Klantcontent in het CMS (Instellingen)
|
||||
Echte e-mail/KvK/BTW/openingstijden, Google-reviews + URL + score, **GTM-container-ID** (wordt
|
||||
automatisch in de `<head>` gezet), evt. OG-image/logo. Daarna: Search Console + sitemap indienen +
|
||||
PageSpeed-meting.
|
||||
|
||||
---
|
||||
|
||||
### Status van de code-laag (af, 17-06-2026, deploy ná OG/JSON-LD-pass)
|
||||
✅ robots.txt · sitemap.xml (vaste routes + live CMS-`paginas`/`locaties`) · `site:` in config ·
|
||||
JSON-LD `HomeAndConstructionBusiness` (site-breed) + `Service`/`BreadcrumbList` (dienstpagina's) ·
|
||||
GTM-renderblok · gebrande 1200×630 OG-image · privacy/voorwaarden + footer-links ·
|
||||
mail (`lib/smtp.ts` + `contact.ts` → D1 `contact_submissions` + 2 mails) · magic-link-plugin ·
|
||||
middleware (http→https/www→apex) · `_headers` (caching + security).
|
||||
**Open = alleen bovenstaande externe stappen + optioneel: CMS-migratie Wave 3/2b, self-hosted fonts,
|
||||
Lighthouse-meting, checklist.xlsx.**
|
||||
@@ -0,0 +1,188 @@
|
||||
# Helvoirt Gevelreiniging — Architecture
|
||||
|
||||
Status: **marketing-pagina's LIVE + CMS-migratie loopt (16-06-2026) — morgen verder met live zetten.** Astro 6 + Emdash + Cloudflare, gemodelleerd naar `../exterios`. Live op **`https://helvoirt-gevelreiniging.mike-197.workers.dev`** (preview; echt domein nog niet gekoppeld). **Laatste deploy: version `f576e989`.** Live: homepage + `/diensten`-overzicht + 5 dienst-detailpagina's + `/over-ons` + `/contact` + **`/maastricht`** (locatiepagina) (onbekende slug → branded 404). `astro check` 0, axe 0 (kwaliteitsmeting van vóór de CMS-migratie). Wijzig uitsluitend in `C:\projects\dewebsmid\helvoirt`.
|
||||
|
||||
**CMS (emdash, geseed op remote D1):** SEO-partij beheert `paginas` (gewone pagina's, root-URL) + `locaties` (locatiepagina's, root-URL) + `site_settings` (NAW + site-brede SEO + GTM). Ingebouwde `pages`/`posts` verwijderd. Zie `CMS-SEO-handleiding.md`. **Bestaande pagina's worden "volledig naar CMS" gemigreerd** (klantopdracht), elk met de huidige tekst als fallback (site breekt nooit) + live sentinel-verificatie: **Over ons** (`over_ons` + `waarden`-repeater + foto's in mediabibliotheek), **Homepage-tekst** (`homepage` + werkwijze/reviews/FAQ-repeaters), **Contact** (`contact_page`) = KLAAR.
|
||||
|
||||
**Go-live code-laag AF + LIVE (17-06-2026, deploy `15e076ff`):** `robots.txt`, `sitemap.xml.ts` (vaste routes + live CMS-`paginas`/`locaties`; overschrijft emdash's eigen sitemap), `site:` in config, JSON-LD `HomeAndConstructionBusiness` + GTM-render + twitter/theme-color in `Base.astro`, `privacy`/`voorwaarden`-pagina's + footer-links, `lib/smtp.ts` + `api/contact.ts` (D1 `contact_submissions` + 2 mails), `plugins/smtp-email.ts` (magic-link) geregistreerd, `middleware.ts` (http→https/www→apex), `cloudflare-env.d.ts`. **Resteert:** externe input (secret `CONTACT_SMTP_PASS`, SEO-partij-`users`-rij, klantcontent, domein-DNS) + optioneel CMS-migratie Wave 3/2b, self-hosted fonts, echte OG-image. Zie `todo.md` §9/§12 + `newsite.md` §12.
|
||||
|
||||
---
|
||||
|
||||
## Wat is dit project
|
||||
**Helvoirt Gevelreiniging** — lokaal gevelreinigings-/renovatiebedrijf. Diensten: gevelreiniging + renovatie, voegwerk, dak reinigen + coating, schoorsteen-voegwerk, algemeen reinigen. Domein `helvoirtgevelreiniging.nl`.
|
||||
|
||||
**Register:** conversiegerichte marketing-site (doel: gratis-offerte-aanvragen). Volgt de blauw/petrol-palette van het zusterbedrijf `onderhoudsbedrijfvanhelvoirt.nl`. **Niet** de quiet-luxury van `../exterios`: hier zijn before/after, reviews en een dominante CTA juist gewenst. We hergebruiken wel de **technische** architectuur van exterios.
|
||||
|
||||
---
|
||||
|
||||
## Repo layout (huidige staat)
|
||||
|
||||
```
|
||||
C:\projects\dewebsmid\helvoirt\
|
||||
├── architecture.md ← dit document
|
||||
├── CLAUDE.md ← AI-assistent context (stack, IDs, regels, Vite-fix, status)
|
||||
├── todo.md ← bouwplan + status
|
||||
├── package.json ← Astro 6 + Emdash 0.1.0 + Cloudflare adapter
|
||||
├── tsconfig.json ← extends astro/tsconfigs/strict
|
||||
├── astro.config.mjs ← output:server, cloudflare adapter, react, emdash (d1/r2), Vite noDiscovery
|
||||
├── wrangler.jsonc ← worker "helvoirt-gevelreiniging", D1 (PLACEHOLDER id) + R2 bindings, nodejs_compat
|
||||
├── emdash-env.d.ts ← auto-gen types (door dev server)
|
||||
├── .env ← GEMINI_API_KEY (gitignored)
|
||||
├── input/ ← klant-assets (NIET in build)
|
||||
│ ├── Content.docx ← dienstenlijst + referentie-instructie
|
||||
│ └── foto's/ ← 6 echte werkfoto's (schoorsteen+lood, tuinmuur, voegwerk, bemost dak, dakreiniging, trap)
|
||||
├── public/
|
||||
│ ├── favicon.svg ← cyaan tegel + witte waterdruppel (merkmark)
|
||||
│ └── images/ ← WebP's + 768/1280/1920-srcset-varianten
|
||||
│ ├── hero-after.webp / hero-before.webp ← schone + (PIL-vergroende) gevel (before/after-paar; hero-before alleen via het ongebruikte BeforeAfter.astro; hero-after ook in dienst-galerijen)
|
||||
│ ├── hero-gevel.webp ← schone gevel (over-ons-hero + voegwerk-galerij)
|
||||
│ ├── dienst-{gevelreiniging,voegwerk,dakreiniging,schoorsteen,algemeen}.webp
|
||||
│ └── werk-1..6.webp ← echte input-foto's, geconverteerd
|
||||
├── scripts/
|
||||
│ ├── nano-banana-with-truststore.py ← Gemini beeldgeneratie (Windows cert-wrapper)
|
||||
│ ├── img-variants.py ← srcset-varianten (PIL)
|
||||
│ ├── degrade-before.py ← maakt hero-before.webp uit hero-after.webp
|
||||
│ ├── convert-input.py ← input-foto's → WebP
|
||||
│ └── screenshot-home.py / shoot-*.py / axe.py / contrast.py / touch.py ← QA/verificatie
|
||||
└── src/
|
||||
├── worker.ts ← re-export PluginBridge + Cloudflare handler
|
||||
├── live.config.ts ← emdashLoader() voor live content collections
|
||||
├── lib/img.ts ← buildSrcset + srcsetFor + IMG_WIDTHS (hero + dienst)
|
||||
├── lib/images.ts ← resolveCmsImage (Emdash image-veld → src/alt, met fallback)
|
||||
├── styles/tokens.css ← palette, type-scale, .btn-cta/.btn-outline/.btn-text, section-helpers, .reveal
|
||||
├── layouts/Base.astro ← header, drawer, prefooter-form, footer, sticky-CTA, reveal-IO, SEO-head
|
||||
├── components/
|
||||
│ ├── Hero.astro ← homepage-hero (tekst + werkfoto-collage)
|
||||
│ ├── BeforeAfter.astro ← herbruikbare voor/na-sleepvergelijker (range + :has-focus); ONGEBRUIKT (projecten geschrapt), bewaard als herbruikbaar component
|
||||
│ └── ContactForm.astro ← herbruikbaar offerte-formulier
|
||||
└── pages/
|
||||
├── index.astro ← homepage (alle secties inline)
|
||||
├── over-ons.astro ← over ons (hero + verhaal + 4 waarden + werkgebied)
|
||||
├── contact.astro ← contact (hidePrefooter + NAW + formulier, id="contact")
|
||||
├── diensten/index.astro ← diensten-overzicht (5 kaarten → detail)
|
||||
├── diensten/[slug].astro ← SSR dienst-detailsjabloon (5 slugs gevuld; onbekend → 404)
|
||||
├── werkgebied/index.astro ← werkgebied-hub (lijst gepubliceerde locaties → /<plaats>, anders pills)
|
||||
├── [...slug].astro ← root-router: locatiepagina (`locaties`) of CMS-pagina (`paginas`), beide op root, + branded 404
|
||||
└── api/contact.ts ← POST /api/contact (honeypot + time-trap + validatie)
|
||||
```
|
||||
|
||||
**Gebouwd deze ronde:** `seed/seed.json` (CMS-model), `lib/images.ts`, `pages/[...slug].astro` (root-router: locatie- én gewone CMS-pagina's op de root + branded 404), `pages/werkgebied/index.astro` (hub), `CMS-SEO-handleiding.md`. **Nog te bouwen:** `pages/{privacy,voorwaarden}.astro` (of als CMS-pagina), `pages/sitemap.xml.ts` (incl. CMS-`paginas`/`locaties`), `robots.txt`, JSON-LD, `DESIGN.md`. (Projecten geschrapt op klantverzoek.) Homepage-secties zijn nog inline in `index.astro`; extraheren naar herbruikbare componenten zodra de subpagina's ze delen.
|
||||
|
||||
---
|
||||
|
||||
## Stack
|
||||
|
||||
| Laag | Technologie | Detail |
|
||||
|---|---|---|
|
||||
| Framework | Astro `^6.1.2` | `output: "server"` (SSR) |
|
||||
| Adapter | `@astrojs/cloudflare` `^13.1.6` | Workers SSR; KV (`SESSION`) + Images auto-provisioned |
|
||||
| React | `^19.2.4` | voor Emdash admin-UI |
|
||||
| CMS | Emdash `^0.1.0` + `@emdash-cms/cloudflare` | admin `/_emdash/admin` (nog niet geseed) |
|
||||
| Database | Cloudflare D1 | `helvoirt-db` (WEUR) — `database_id` `0fb83f17-f91d-4910-ac8c-80f00569ce35`, aangemaakt |
|
||||
| Storage | Cloudflare R2 | `helvoirt-media` — aangemaakt |
|
||||
| Styling | scoped CSS + CSS vars | `tokens.css`, geen Tailwind |
|
||||
| Fonts | Archivo Expanded + Open Sans | Google Fonts |
|
||||
| Hosting | Cloudflare Workers | account `41972f4905f2c83f51111b80283e030b` |
|
||||
| Mail | Mailtrap | nog te configureren (todo §12) |
|
||||
|
||||
`astro.config.mjs`: `react()` + `emdash({ database: d1({binding:"DB",session:"auto"}), storage: r2({binding:"MEDIA"}) })`, `vite.ssr.optimizeDeps.noDiscovery`.
|
||||
|
||||
---
|
||||
|
||||
## Design-systeem (`src/styles/tokens.css`)
|
||||
|
||||
- **Strategie:** *committed* blauw/petrol. Eén familie draagt het ontwerp; cyaan is de accent-spaarzaamheid.
|
||||
- **Kleuren:** actie-blauw `#0069ff` / hover `#0055ce` (CTA, links), cyaan `#4ebfd9` + `#8fd9e8` (accent/labels), petrol `#1d4852` / diep `#15363d` (donkere secties, header, footer), off-white `#f6fafb` (page) / `#eef4f7` (soft) / `#fdfeff` (card), tint `#e3f6fd`, ink `#1f2a2e` / soft `#5b6a70` / line `#dce6eb`. Status/ster: `--c-on-error`, `--c-on-success`, `--c-star`. Neutralen getint (geen puur wit/zwart in surfaces).
|
||||
- **Typografie:** `--font-display` Archivo Expanded (700/800), `--font-body` Open Sans. Fluid `clamp()`-schaal.
|
||||
- **Knoppen:** `.btn-cta` (actie-blauw fill, wit, radius 8px), `.btn-outline`, `.btn-text` (pijl-link, action-deep). **Tapdoelen ≥44px.**
|
||||
- **Sectie-helpers:** `.section--paper / --soft / --tint / --petrol / --petrol-deep`.
|
||||
- **Motion:** `.reveal` IO-systeem (`.revealed` + `data-delay`) + varianten `.reveal--draw` (lijn tekent zich in vanaf links — tijdlijn) en `.reveal--stagger` (directe kinderen na elkaar omhoog, `--i`-index — werkgebied-pills + FAQ), ease-out-expo. Verder: sitebrede scroll-reveals op elke sectie, werkwijze-tijdlijn `.flow__line` die zich intekent terwijl de cijfers inploppen (`flow-num`), `:active`-indruk op `.btn-cta`/`.btn-outline`, offerteformulier-spinner (`aria-busy` → `cform-spin`) + geanimeerde succesmelding (`cform-success`), trage ambient gloed in de hero (`hero-glow`). `prefers-reduced-motion` overal gerespecteerd (transform/opacity-only).
|
||||
- **Contrast:** alle gemeten paren ruim WCAG AA; axe-core 0 violations.
|
||||
|
||||
> Een formele `DESIGN.md` is nog niet geschreven (de design-context staat in dit document + `CLAUDE.md`).
|
||||
|
||||
---
|
||||
|
||||
## Componenten & homepage-secties
|
||||
|
||||
**`Base.astro`** — solide **donkere petrol headerbalk** (altijd zichtbaar): merkmark (cyaan tegel + witte druppel-SVG) + "Helvoirt / Gevelreiniging", desktop-nav met cyaan-underline + Diensten-dropdown, klikbaar telefoonnummer (44px hit-area) en "Gratis offerte"-CTA; mobiele **drawer** (`inert` focus-trap + ESC); **prefooter** (`id="contact"`, petrol) met `ContactForm`; **footer** (petrol-deep); **sticky-mobile-CTA** die verbergt bij de prefooter; `.reveal`-IO + formulier-AJAX-JS; SEO-head (canonical/OG, `lang="nl-NL"`). `site_settings` via `getEmDashEntry` in try/catch → werkt vóór seed met fallbacks (telefoon/e-mail placeholder).
|
||||
|
||||
**`Hero.astro`** — **tekst links + foto-collage rechts** (Tailark hero-6 nagebouwd in Astro/scoped-CSS; `audit` 20/20 + `critique`-fixes; donker petrol, geen before/after-slider, **geen kicker**). Links (`.hero__inner`, z-3): Archivo-kop "Je gevel weer als nieuw." (**800**, wit `--c-on-dark`), sub, `.btn-cta` → `#contact` + onderstreepte tekstlink (taphit ≥44px), `.hero__points` (3 vinkjes) en een prominente `.hero__trust` (grote sterren + display-score "4,9 op Google"). Rechts (`.hero__gallery`, absoluut, z-1, `width: min(72%,1120px)`): `.hero__rail` met **3** staande `.hero__card`'s (`werk-1/3/5`: Voegwerk/Schoorsteen/Dakreiniging; breedte `clamp(200px,19vw,280px)`, verspringend via nth-child translateY, `--shadow-card-dark`, diepe `.hero__cap` bijschrift-overlay) die links **overlopen in het tekstblok**; `.hero__fade` (z-2, sterk links) laat ze wegvloeien in `--c-petrol-deep` (klantvoorkeur boven een rechts-uitgelijnde collage). Was 4 cards (incl. Metselwerk `werk-6`); op klantverzoek terug naar 3 — 4 was te druk — en de cards verbreed zodat ze de rechterzone blijven vullen. Cards laden kleine srcset-varianten (`werk-N-480/768.webp`); hover = subtiele image-zoom. Gestaggerde entrance + trage ambient gloed achter `prefers-reduced-motion`. Props: `subtitle`. **Mobiel** (≤900px): flex-kolom, tekst boven (`order:0`), rail als **swipebare scroll-snap-rail** eronder (`order:1`). Donkere overlays/scrims/schaduw via tokens (`--c-scrim-rgb`, `--c-petrol-deep-rgb`, `--c-brand-rgb`, `--shadow-card-dark`). NB: `hero-after/before/gevel.webp` nu ongebruikt op de homepage.
|
||||
|
||||
**`ContactForm.astro`** — offerte-formulier: naam (placeholder "Naam"), e-mail, telefoon, bericht + honeypot + time-trap. Eén instantie per pagina (prefooter OF /contact). Styling (`.cform*`) + AJAX-submit-JS globaal in `Base.astro`. Tijdens verzenden zet de JS `aria-busy` op de submitknop → pijl wisselt voor een spinner (`cform-spin`); de succesmelding komt geanimeerd binnen (`cform-success`).
|
||||
|
||||
**`index.astro`** secties (boven→onder):
|
||||
1. **Hero** (donker, tekst links + werkfoto-galerij rechts die links wegvloeit in de achtergrond; mobiel swipebaar. Geen before/after-slider).
|
||||
2. **Intro** (paper) — groot Archivo-statement + alinea + één inline meta-regel.
|
||||
3. **Diensten** (petrol-deep) — **uitklappende beeld-accordion**: 5 hoge foto-panelen, het paneel onder cursor/focus klapt open (grote foto + titel + tekst + "Bekijk"), de rest smalle stroken met verticale titel + "+"-indicator; blijft open om te lezen; mobiel = 5 gestapelde kaarten.
|
||||
4. **Werkwijze** (soft) — verbonden tijdlijn, 4 stappen, blauwe genummerde cirkels.
|
||||
5. **Bewijs** (paper) — asymmetrische galerij van de **echte** werkfoto's.
|
||||
6. **Reviews** (tint) — één uitgelichte quote + Google-score + 2 kleine (placeholder).
|
||||
7. **Werkgebied** (soft) — plaats-pills.
|
||||
8. **FAQ "Goed om te weten"** (paper) — 4 vragen vóór het formulier.
|
||||
9. **Prefooter** (globaal, petrol) — offerte-formulier.
|
||||
|
||||
**`api/contact.ts`** — POST: honeypot + time-trap (2s–6u) + per-veld validatie; honeypot/time-trap → silent 200; fouten → 422 `{errors}`. Nu `console.log` (Mailtrap-send = todo §12). `prerender = false`.
|
||||
|
||||
---
|
||||
|
||||
## Emdash datamodel (emdash — GESEED op remote D1; todo §3)
|
||||
**Bewust SEO-vriendelijk + simpel** ingericht zodat de externe SEO-partij zelf pagina's kan aanmaken/optimaliseren. Drie collecties (i.p.v. het volledige sectie-model):
|
||||
- **`site_settings`** (singleton, slug `main`): NAW (`phone`, `email`, `address`, `address_street`, `address_city`), `kvk`, `btw`, `whatsapp`, `werkgebied`, `openingstijden`, social-URL's, `google_reviews_url`, `overall_rating`, `logo`, `favicon`, `og_image`, **`meta_title`/`meta_description`** (site-brede SEO-fallback) en **`gtm`** (Tag Manager-ID).
|
||||
- **`paginas`** (gewone pagina's, `drafts/revisions/search`): `title` (H1), `intro`, `content` (portableText) + **SEO-groep** `meta_title`/`meta_description`/`canonical`/`og_image`/`noindex` + **`embed_code`** (rauwe HTML/script-embed, bv. cookieverklaring; `migrations/0013_paginas_embed.sql`). `[...slug].astro` rendert `embed_code` via `set:html` ZONDER sanitatie (PortableText/htmlBlock filtert `<script>` eruit; dit veld is de bewuste escape-hatch voor vertrouwde admins). Het script draait omdat het in de server-gerenderde HTML staat.
|
||||
- **`locaties`** (locatiepagina's per plaats, idem supports): `plaats`, `title` (H1, leeg = "Gevelreiniging in {plaats}"), `intro`, `content` (portableText) + dezelfde SEO-groep.
|
||||
|
||||
SEO-velden zijn **expliciet** gedefinieerd (geen impliciete emdash-`seo`-support) met nette NL-labels, zodat ze voorspelbaar zijn én door de routes worden uitgelezen. Image-velden via `resolveCmsImage` (`src/lib/images.ts`). PortableText render via `<PortableText value={…} />` uit `emdash/ui`. Geen `getStaticPaths()` (alles SSR).
|
||||
|
||||
### Pagina-migratie ("volledig naar CMS", klantopdracht) — collecties per pagina
|
||||
Elke bestaande pagina leest uit het CMS **met de huidige tekst als fallback** (de site breekt nooit, ook niet als een collectie leeg/onbereikbaar is). Toegevoegd via `migrations/*.sql` (gegenereerd door `scripts/gen-cms-sql.mjs` uit `scripts/cms-models/*`, die emdash's eigen tabelstructuur spiegelt; **geen search/FTS** op deze collecties). Repeaters = inline rijen (`validation.subFields`); image-rijen → aparte collectie (repeater kan geen image-subveld).
|
||||
- **`over_ons`** (singleton): hero-velden, verhaal (portableText), waarden-koppen, SEO + **beeldvelden** `hero_image`/`story_image` (foto's staan in de **mediabibliotheek**: R2 + `media`-rij). "Waarden" = inline **repeater**. ✅ KLAAR.
|
||||
- **`homepage`** (singleton): hero (titel/sub) + repeaters `hero_points`/`werkwijze`/`reviews_small`/`faq` + losse review-/sectie-velden. Gelezen door `index.astro` + `Hero.astro` (props). ✅ KLAAR (tekst). Beeld (hero-cards + galerij) = Wave 2b (TODO).
|
||||
- **`contact_page`** (singleton): kicker/H1/intro + SEO. ✅ KLAAR. NAW blijft uit `site_settings`.
|
||||
- **`diensten`** (collectie, 5 entries, 36 velden) — ✅ KLAAR (Wave 3, `0009_diensten.sql`). Deelt content tussen de **homepage-accordion** (`nav_*`), het **`/diensten`-overzicht**, de **`/diensten/[slug]`-detailpagina's** (alle velden: hero/points/intro/included/resultaat-galerij/aanpak/faq/seo/noindex) en het **nav-dropdownmenu** (`Base.astro`), gesorteerd op `nav_order`. Alles met code-fallback; beeldvelden leeg = statische foto. Resultaat-galerij = 3 vaste image-slots (repeater kan geen image-subveld).
|
||||
|
||||
Velden verplicht maken (om "(optional)" te onderdrukken) en labels wijzigen = `UPDATE _emdash_fields` op de remote D1. **`pages`/`posts` (emdash-defaults) zijn verwijderd** (`scripts/cleanup-default-collections.sql`); blijven weg zolang de setup-wizard niet opnieuw draait.
|
||||
|
||||
---
|
||||
|
||||
## Routes & data flow
|
||||
|
||||
```
|
||||
/ ✓ index.astro (homepage; site_settings via Base, rest hardcoded/placeholder)
|
||||
/api/contact ✓ POST honeypot+time-trap+validatie → console.log (Mailtrap TODO)
|
||||
/_emdash/admin ✓ Emdash admin (na seed/setup)
|
||||
/contact ✓ contact.astro (hidePrefooter + NAW + formulier, sectie id="contact")
|
||||
/diensten ✓ index.astro (overzicht) + /diensten/[slug] (5 diensten) — uit CMS-collectie `diensten` (fallback in code); onbekende slug → 404
|
||||
/over-ons ✓ over-ons.astro (hero + verhaal + waarden + werkgebied)
|
||||
/werkgebied ✓ werkgebied/index.astro (hub: lijst gepubliceerde locaties → /<plaats>, anders pills)
|
||||
/<plaats> ✓ locatiepagina uit collectie `locaties` (CMS), op de root (bv. /maastricht)
|
||||
/<slug> ✓ gewone CMS-pagina uit collectie `paginas`, op de root (bv. /onderhoudstips)
|
||||
/[...slug] ✓ root-router (catch-all, laagste prioriteit): zoekt locaties → paginas; onbekend → branded 404
|
||||
/projecten ❌ GESCHRAPT (klantverzoek) — uit nav + homepage-link; BeforeAfter.astro ongebruikt bewaard
|
||||
/privacy /voorwaarden ✓ code-pagina's (NAW uit site_settings); footer-links
|
||||
/sitemap.xml ✓ sitemap.xml.ts (vaste routes + live CMS-paginas/locaties; overschrijft emdash's eigen sitemap)
|
||||
/robots.txt ✓ public/robots.txt
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Build, dev & deploy (Windows)
|
||||
- **Install:** `npm install --ignore-scripts`.
|
||||
- **Dev (twee-fase bij koude cache):** wis `node_modules/.vite` + `.astro`; `$env:NODE_OPTIONS="--use-system-ca"; npx astro dev --port 4321`; 1 GET op `/` (crasht = warmt cache); Ctrl+C; opnieuw starten → stabiel.
|
||||
- **Check/build:** `$env:NODE_OPTIONS="--use-system-ca"; npx astro check` (0 errors), `npx astro build`.
|
||||
- **Deploy:** `$env:CLOUDFLARE_ACCOUNT_ID="…"; npx wrangler deploy` — pas ná D1/R2 + echte `database_id`.
|
||||
|
||||
---
|
||||
|
||||
## Bekende issues (geërfd, gelden hier)
|
||||
1. **Vite SSR chunk-error (Windows)** → twee-fase opstart (zie boven). Niet `optimizeDeps.exclude` voor emdash zetten.
|
||||
2. **`NODE_OPTIONS=--use-system-ca`** vereist voor elke astro/wrangler-call (corporate-cert), anders TLS-fout.
|
||||
3. **better-sqlite3** compileert niet op Windows → `--ignore-scripts`.
|
||||
4. **`database_id`** staat nu op de echte id (`0fb83f17-…`) van `helvoirt-db` in `wrangler.jsonc` (was placeholder-UUID; lege id breekt lokale miniflare).
|
||||
5. **Emdash seed CLI** werkt niet lokaal → setup-wizard of `wrangler d1 execute`.
|
||||
6. **nano-banana** via de truststore-wrapper + system python.org 3.12 (zie CLAUDE.md).
|
||||
|
||||
---
|
||||
|
||||
## Kwaliteit (laatste meting)
|
||||
`astro check` 0 errors · productie-build OK · **axe-core 0 violations op alle 9 pagina's** · geen horizontale overflow (1440/768/390) · contrast ruim AA · tapdoelen ≥44px (telefoon/e-mail/logo/skip-link gefixt in de audit) · impeccable-detector schoon (`[]`) op alle bronbestanden · **hero-audit 20/20 · hero-critique 33/40 · dienst-critique 33/40 · site-audit 17/20** (alle fixes door) · **alle 9 routes live 200, onbekend 404** (deploy version `ff725970`).
|
||||
@@ -0,0 +1,45 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
import cloudflare from "@astrojs/cloudflare";
|
||||
import react from "@astrojs/react";
|
||||
import { d1, r2 } from "@emdash-cms/cloudflare";
|
||||
import { defineConfig } from "astro/config";
|
||||
import emdash from "emdash/astro";
|
||||
|
||||
// Emdash e-mail-provider-plugin (magic-link login) — absoluut pad met forward slashes,
|
||||
// want emdash genereert er een import-specifier mee (backslashes breken het). Zie newsite.md §6b.
|
||||
const smtpEntry = fileURLToPath(new URL("./src/plugins/smtp-email.ts", import.meta.url)).replace(/\\/g, "/");
|
||||
|
||||
export default defineConfig({
|
||||
site: "https://helvoirtgevelreiniging.nl",
|
||||
output: "server",
|
||||
adapter: cloudflare(),
|
||||
image: {
|
||||
layout: "constrained",
|
||||
responsiveStyles: true,
|
||||
},
|
||||
integrations: [
|
||||
react(),
|
||||
emdash({
|
||||
database: d1({ binding: "DB", session: "auto" }),
|
||||
storage: r2({ binding: "MEDIA" }),
|
||||
plugins: [
|
||||
{
|
||||
id: "helvoirt-smtp-email",
|
||||
version: "1.0.0",
|
||||
entrypoint: smtpEntry,
|
||||
format: "standard",
|
||||
capabilities: ["email:provide"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
devToolbar: { enabled: false },
|
||||
vite: {
|
||||
ssr: {
|
||||
optimizeDeps: {
|
||||
noDiscovery: true,
|
||||
include: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
// Generated by EmDash on dev server start
|
||||
// Do not edit manually
|
||||
|
||||
/// <reference types="emdash/locals" />
|
||||
|
||||
import type { ContentBylineCredit, PortableTextBlock } from "emdash";
|
||||
|
||||
export interface Page {
|
||||
id: string;
|
||||
slug: string | null;
|
||||
status: string;
|
||||
title: string;
|
||||
content?: PortableTextBlock[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
publishedAt: Date | null;
|
||||
bylines?: ContentBylineCredit[];
|
||||
}
|
||||
|
||||
export interface Post {
|
||||
id: string;
|
||||
slug: string | null;
|
||||
status: string;
|
||||
title: string;
|
||||
featured_image?: { id: string; src?: string; alt?: string; width?: number; height?: number };
|
||||
content?: PortableTextBlock[];
|
||||
excerpt?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
publishedAt: Date | null;
|
||||
bylines?: ContentBylineCredit[];
|
||||
}
|
||||
|
||||
declare module "emdash" {
|
||||
interface EmDashCollections {
|
||||
pages: Page;
|
||||
posts: Post;
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 361 KiB |
|
After Width: | Height: | Size: 342 KiB |
|
After Width: | Height: | Size: 190 KiB |
|
After Width: | Height: | Size: 528 KiB |
|
After Width: | Height: | Size: 391 KiB |
|
After Width: | Height: | Size: 313 KiB |
@@ -0,0 +1,22 @@
|
||||
-- Vangnet-tabel voor contactformulier-aanvragen.
|
||||
-- /api/contact schrijft elke geldige aanvraag hierheen naast de e-mailmelding,
|
||||
-- zodat er geen lead verloren gaat. Idempotent: veilig her-uit te voeren.
|
||||
--
|
||||
-- Remote (productie) toepassen:
|
||||
-- $env:NODE_OPTIONS="--use-system-ca"; $env:CLOUDFLARE_ACCOUNT_ID="41972f4905f2c83f51111b80283e030b"
|
||||
-- node node_modules/wrangler/bin/wrangler.js d1 execute helvoirt-db --remote --file=migrations/0001_contact_submissions.sql
|
||||
|
||||
CREATE TABLE IF NOT EXISTS contact_submissions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT NOT NULL,
|
||||
phone TEXT,
|
||||
message TEXT NOT NULL,
|
||||
ip TEXT,
|
||||
country TEXT,
|
||||
user_agent TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_contact_submissions_created_at
|
||||
ON contact_submissions (created_at DESC);
|
||||
@@ -0,0 +1,27 @@
|
||||
-- Auto-gegenereerd door scripts/gen-cms-sql.mjs — NIET met de hand bewerken.
|
||||
|
||||
-- ===== collectie: over_ons (Over ons (pagina)) =====
|
||||
INSERT INTO _emdash_collections (id, slug, label, label_singular, supports, source, has_seo) VALUES ('01KV808CAK076NW7QYYYP4Z9EE', 'over_ons', 'Over ons (pagina)', 'Over ons', '["drafts","revisions"]', 'seed', 0);
|
||||
CREATE TABLE "ec_over_ons" ("id" text primary key, "slug" text, "status" text default 'draft', "author_id" text, "primary_byline_id" text, "created_at" text default (datetime('now')), "updated_at" text default (datetime('now')), "published_at" text, "scheduled_at" text, "deleted_at" text, "version" integer default 1, "live_revision_id" text references "revisions" ("id"), "draft_revision_id" text references "revisions" ("id"), "locale" text default 'en' not null, "translation_group" text, "hero_eyebrow" TEXT, "hero_title" TEXT NOT NULL DEFAULT '', "hero_lead" TEXT, "story_title" TEXT, "story_body" JSON, "values_title" TEXT, "values_intro" TEXT, "meta_title" TEXT, "meta_description" TEXT, constraint "ec_over_ons_slug_locale_unique" unique ("slug", "locale"));
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KV808CAMHMAEY6SQD6H0T1SD', '01KV808CAK076NW7QYYYP4Z9EE', 'hero_eyebrow', 'Hero · kicker', 'string', 'TEXT', 0, 0, 0, 1);
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KV808CAMFSR9W3V86Z91NSMS', '01KV808CAK076NW7QYYYP4Z9EE', 'hero_title', 'Hero · titel (H1)', 'string', 'TEXT', 1, 1, 0, 1);
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KV808CAM6QKBP3NE3THP44FV', '01KV808CAK076NW7QYYYP4Z9EE', 'hero_lead', 'Hero · inleiding', 'text', 'TEXT', 0, 2, 0, 1);
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KV808CAM669PHXWHX0HQ5J29', '01KV808CAK076NW7QYYYP4Z9EE', 'story_title', 'Verhaal · titel (H2)', 'string', 'TEXT', 0, 3, 0, 1);
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KV808CAMBF79J65S1C5V0KKY', '01KV808CAK076NW7QYYYP4Z9EE', 'story_body', 'Verhaal · tekst', 'portableText', 'JSON', 0, 4, 0, 1);
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KV808CAMJ4FCVJ3A8ANKJY7B', '01KV808CAK076NW7QYYYP4Z9EE', 'values_title', 'Waarden · kop (H2)', 'string', 'TEXT', 0, 5, 0, 1);
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KV808CAM2ZJDHW2AC7MGVFY1', '01KV808CAK076NW7QYYYP4Z9EE', 'values_intro', 'Waarden · inleiding', 'string', 'TEXT', 0, 6, 0, 1);
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KV808CAM8850VVNEJS20X9Z6', '01KV808CAK076NW7QYYYP4Z9EE', 'meta_title', 'SEO · paginatitel (~60 tekens)', 'string', 'TEXT', 0, 7, 0, 1);
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KV808CAMB1DZ6ADXRA6P4HFH', '01KV808CAK076NW7QYYYP4Z9EE', 'meta_description', 'SEO · omschrijving (~155 tekens)', 'text', 'TEXT', 0, 8, 0, 1);
|
||||
INSERT INTO "ec_over_ons" ("id", "slug", "status", "locale", "hero_eyebrow", "hero_title", "hero_lead", "story_title", "story_body", "values_title", "values_intro", "meta_title", "meta_description") VALUES ('01KV808CAN1XVJ5MWH6MN5FPSP', 'main', 'published', 'en', 'Over ons', 'De vakmensen achter een schone gevel', 'Helvoirt Gevelreiniging maakt de buitenkant van je huis weer schoon en heel. Van gevelreiniging en voegwerk tot daken en schoorstenen, voor particulieren en bedrijven in de regio.', 'Schoon, strak en vakkundig', '[{"_type":"block","style":"normal","children":[{"_type":"span","text":"Een vieze gevel, groen dak of lekkende schoorsteen is meer dan een kwestie van uiterlijk. Vocht en aanslag tasten op den duur de steen, het voegwerk en de constructie aan. Wij pakken dat aan met de methode die bij jouw situatie past, zodat je huis er niet alleen mooi bij staat, maar ook heel blijft."}]},{"_type":"block","style":"normal","children":[{"_type":"span","text":"We werken voor particulieren en bedrijven en denken altijd met je mee. Je krijgt vooraf een heldere prijs en een duidelijke planning, en we leveren het werk netjes en opgeruimd op. Zo weet je precies waar je aan toe bent, van het eerste belletje tot de laatste spoelbeurt."}]}]', 'Waarom Helvoirt Gevelreiniging', 'Wat je van ons mag verwachten, bij elke klus.', 'Over ons · Helvoirt Gevelreiniging', 'Helvoirt Gevelreiniging is een lokaal gevelreinigings- en renovatiebedrijf. Vakwerk, een eerlijke prijs vooraf en netjes opgeleverd, in Helvoirt en omgeving.');
|
||||
|
||||
-- ===== collectie: waarden (Waarden (over ons)) =====
|
||||
INSERT INTO _emdash_collections (id, slug, label, label_singular, supports, source, has_seo) VALUES ('01KV808CANY30RMEB6Y38Y260M', 'waarden', 'Waarden (over ons)', 'Waarde', '["drafts","revisions"]', 'seed', 0);
|
||||
CREATE TABLE "ec_waarden" ("id" text primary key, "slug" text, "status" text default 'draft', "author_id" text, "primary_byline_id" text, "created_at" text default (datetime('now')), "updated_at" text default (datetime('now')), "published_at" text, "scheduled_at" text, "deleted_at" text, "version" integer default 1, "live_revision_id" text references "revisions" ("id"), "draft_revision_id" text references "revisions" ("id"), "locale" text default 'en' not null, "translation_group" text, "title" TEXT NOT NULL DEFAULT '', "text" TEXT, "icon" TEXT, "sort_order" INTEGER, constraint "ec_waarden_slug_locale_unique" unique ("slug", "locale"));
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KV808CANH56WJJQDAKN8QM10', '01KV808CANY30RMEB6Y38Y260M', 'title', 'Titel', 'string', 'TEXT', 1, 0, 0, 1);
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KV808CAN1RVZ3S2FT5SA4PNZ', '01KV808CANY30RMEB6Y38Y260M', 'text', 'Tekst', 'text', 'TEXT', 0, 1, 0, 1);
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KV808CANSKWS1854BZZ8KJQ8', '01KV808CANY30RMEB6Y38Y260M', 'icon', 'Icoon (SVG-inhoud, geavanceerd)', 'text', 'TEXT', 0, 2, 0, 1);
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KV808CANV5RT6JV03V2YRE88', '01KV808CANY30RMEB6Y38Y260M', 'sort_order', 'Volgorde', 'integer', 'INTEGER', 0, 3, 0, 1);
|
||||
INSERT INTO "ec_waarden" ("id", "slug", "status", "locale", "title", "text", "icon", "sort_order") VALUES ('01KV808CANZM26H20D3Q6PT8HE', 'eerlijke-prijs-vooraf', 'published', 'en', 'Eerlijke prijs vooraf', 'Je krijgt een duidelijke offerte op papier. Geen verrassingen achteraf, geen kleine lettertjes.', '<path d="M20.59 13.41 13.42 20.58a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><circle cx="7" cy="7" r="1.4"/>', 1);
|
||||
INSERT INTO "ec_waarden" ("id", "slug", "status", "locale", "title", "text", "icon", "sort_order") VALUES ('01KV808CANGT9YV8HBVD6XZDX3', 'echt-vakwerk', 'published', 'en', 'Echt vakwerk', 'We kennen ons vak en de materialen. Elke gevel, elk dak en elke schoorsteen krijgt de juiste aanpak.', '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="m8.5 12 2.2 2.2 4.5-4.5"/>', 2);
|
||||
INSERT INTO "ec_waarden" ("id", "slug", "status", "locale", "title", "text", "icon", "sort_order") VALUES ('01KV808CAN3BM8TWS72DRPT8BX', 'netjes-opgeleverd', 'published', 'en', 'Netjes opgeleverd', 'We dekken alles goed af, ruimen op en lopen het werk samen met je na. Je huis blijft schoon achter.', '<path d="M12 3l1.8 5.4L19 10l-5.2 1.6L12 17l-1.8-5.4L5 10l5.2-1.6z"/>', 3);
|
||||
INSERT INTO "ec_waarden" ("id", "slug", "status", "locale", "title", "text", "icon", "sort_order") VALUES ('01KV808CANKHZKT3MA94BVZTEM', 'lokaal-en-betrokken', 'published', 'en', 'Lokaal en betrokken', 'We werken in Helvoirt en omgeving. Korte lijnen, snel even langs en altijd persoonlijk aanspreekbaar.', '<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/>', 4);
|
||||
@@ -0,0 +1,17 @@
|
||||
-- Auto-gegenereerd — Over ons: waarden -> inline repeater + minder "(optional)".
|
||||
|
||||
-- 1. Repeater-kolom + velddefinitie (rijen toevoegen/verwijderen/verslepen).
|
||||
ALTER TABLE ec_over_ons ADD COLUMN waarden JSON;
|
||||
UPDATE _emdash_fields SET sort_order=8 WHERE collection_id=(SELECT id FROM _emdash_collections WHERE slug='over_ons') AND slug='meta_title';
|
||||
UPDATE _emdash_fields SET sort_order=9 WHERE collection_id=(SELECT id FROM _emdash_collections WHERE slug='over_ons') AND slug='meta_description';
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable, validation)
|
||||
VALUES ('01KV81R957N09TYXY4J5QPY9BD', (SELECT id FROM _emdash_collections WHERE slug='over_ons'), 'waarden', 'Waarden (rijen: voeg toe / verwijder / versleep)', 'repeater', 'JSON', 0, 7, 0, 1, '{"subFields":[{"slug":"title","type":"string","label":"Titel","required":true},{"slug":"text","type":"text","label":"Tekst"},{"slug":"icon","type":"text","label":"Icoon (SVG-inhoud, geavanceerd)"}],"minItems":0,"maxItems":8}');
|
||||
UPDATE ec_over_ons SET waarden='[{"title":"Eerlijke prijs vooraf","text":"Je krijgt een duidelijke offerte op papier. Geen verrassingen achteraf, geen kleine lettertjes.","icon":"<path d=\"M20.59 13.41 13.42 20.58a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z\"/><circle cx=\"7\" cy=\"7\" r=\"1.4\"/>"},{"title":"Echt vakwerk","text":"We kennen ons vak en de materialen. Elke gevel, elk dak en elke schoorsteen krijgt de juiste aanpak.","icon":"<path d=\"M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z\"/><path d=\"m8.5 12 2.2 2.2 4.5-4.5\"/>"},{"title":"Netjes opgeleverd","text":"We dekken alles goed af, ruimen op en lopen het werk samen met je na. Je huis blijft schoon achter.","icon":"<path d=\"M12 3l1.8 5.4L19 10l-5.2 1.6L12 17l-1.8-5.4L5 10l5.2-1.6z\"/>"},{"title":"Lokaal en betrokken","text":"We werken in Helvoirt en omgeving. Korte lijnen, snel even langs en altijd persoonlijk aanspreekbaar.","icon":"<path d=\"M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z\"/><circle cx=\"12\" cy=\"10\" r=\"3\"/>"}]' WHERE slug='main';
|
||||
|
||||
-- 2. Kern-inhoudvelden verplicht -> "(optional)" verdwijnt daar (SEO blijft optioneel).
|
||||
UPDATE _emdash_fields SET required=1 WHERE collection_id=(SELECT id FROM _emdash_collections WHERE slug='over_ons') AND slug IN ('hero_lead','story_title','story_body','values_title');
|
||||
|
||||
-- 3. Losse 'waarden'-collectie opruimen (vervangen door het repeater-veld hierboven).
|
||||
DROP TABLE IF EXISTS ec_waarden;
|
||||
DELETE FROM _emdash_fields WHERE collection_id=(SELECT id FROM _emdash_collections WHERE slug='waarden');
|
||||
DELETE FROM _emdash_collections WHERE slug='waarden';
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Auto-gegenereerd — Over ons: hero- en verhaal-afbeelding beheerbaar maken.
|
||||
ALTER TABLE ec_over_ons ADD COLUMN hero_image TEXT;
|
||||
ALTER TABLE ec_over_ons ADD COLUMN story_image TEXT;
|
||||
-- Herorden zodat de beeldvelden logisch tussen de tekst staan.
|
||||
UPDATE _emdash_fields SET sort_order=4 WHERE collection_id=(SELECT id FROM _emdash_collections WHERE slug='over_ons') AND slug='story_title';
|
||||
UPDATE _emdash_fields SET sort_order=5 WHERE collection_id=(SELECT id FROM _emdash_collections WHERE slug='over_ons') AND slug='story_body';
|
||||
UPDATE _emdash_fields SET sort_order=7 WHERE collection_id=(SELECT id FROM _emdash_collections WHERE slug='over_ons') AND slug='values_title';
|
||||
UPDATE _emdash_fields SET sort_order=8 WHERE collection_id=(SELECT id FROM _emdash_collections WHERE slug='over_ons') AND slug='values_intro';
|
||||
UPDATE _emdash_fields SET sort_order=9 WHERE collection_id=(SELECT id FROM _emdash_collections WHERE slug='over_ons') AND slug='waarden';
|
||||
UPDATE _emdash_fields SET sort_order=10 WHERE collection_id=(SELECT id FROM _emdash_collections WHERE slug='over_ons') AND slug='meta_title';
|
||||
UPDATE _emdash_fields SET sort_order=11 WHERE collection_id=(SELECT id FROM _emdash_collections WHERE slug='over_ons') AND slug='meta_description';
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KV83NTC09VQG6CDC4CWPP7PD', (SELECT id FROM _emdash_collections WHERE slug='over_ons'), 'hero_image', 'Hero · afbeelding (banner) — leeg = standaardfoto', 'image', 'TEXT', 0, 3, 0, 1);
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KV83NTC27JH5XEV6CBFX2PX8', (SELECT id FROM _emdash_collections WHERE slug='over_ons'), 'story_image', 'Verhaal · foto — leeg = standaardfoto', 'image', 'TEXT', 0, 6, 0, 1);
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Klantverzoek: verwijder de "(~N tekens)"-hints uit de SEO-labels.
|
||||
UPDATE _emdash_fields SET label='SEO · standaard paginatitel' WHERE slug='meta_title' AND collection_id=(SELECT id FROM _emdash_collections WHERE slug='site_settings');
|
||||
UPDATE _emdash_fields SET label='SEO · standaard omschrijving / Google-snippet' WHERE slug='meta_description' AND collection_id=(SELECT id FROM _emdash_collections WHERE slug='site_settings');
|
||||
UPDATE _emdash_fields SET label='SEO · titel voor Google (leeg = paginatitel)' WHERE slug='meta_title' AND collection_id=(SELECT id FROM _emdash_collections WHERE slug='paginas');
|
||||
UPDATE _emdash_fields SET label='SEO · omschrijving / Google-snippet' WHERE slug='meta_description' AND collection_id=(SELECT id FROM _emdash_collections WHERE slug='paginas');
|
||||
UPDATE _emdash_fields SET label='SEO · titel voor Google (leeg = paginakop)' WHERE slug='meta_title' AND collection_id=(SELECT id FROM _emdash_collections WHERE slug='locaties');
|
||||
UPDATE _emdash_fields SET label='SEO · omschrijving / Google-snippet' WHERE slug='meta_description' AND collection_id=(SELECT id FROM _emdash_collections WHERE slug='locaties');
|
||||
UPDATE _emdash_fields SET label='SEO · paginatitel' WHERE slug='meta_title' AND collection_id=(SELECT id FROM _emdash_collections WHERE slug='over_ons');
|
||||
UPDATE _emdash_fields SET label='SEO · omschrijving' WHERE slug='meta_description' AND collection_id=(SELECT id FROM _emdash_collections WHERE slug='over_ons');
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Over ons: huidige foto's in de mediabibliotheek + gekoppeld aan de beeldvelden.
|
||||
INSERT OR REPLACE INTO media (id, filename, mime_type, size, width, height, alt, storage_key, status, created_at) VALUES
|
||||
('01KV84WV45PEE39DKGYB0VM0YD', 'over-ons-hero.webp', 'image/webp', 158222, 1280, 714, 'Schone, verzorgde gevel van een woonhuis na reiniging', '01KV84WV45PEE39DKGYB0VM0YD.webp', 'ready', datetime('now')),
|
||||
('01KV84WV46WDTJJ0573BM0WYVD', 'over-ons-verhaal.webp', 'image/webp', 181398, 738, 983, 'Vakman aan het werk aan het voegwerk van een gevel', '01KV84WV46WDTJJ0573BM0WYVD.webp', 'ready', datetime('now'));
|
||||
UPDATE ec_over_ons SET hero_image='{"id":"01KV84WV45PEE39DKGYB0VM0YD","provider":"local","filename":"over-ons-hero.webp","mimeType":"image/webp","width":1280,"height":714,"alt":"Schone, verzorgde gevel van een woonhuis na reiniging","meta":{"storageKey":"01KV84WV45PEE39DKGYB0VM0YD.webp"}}' WHERE slug='main';
|
||||
UPDATE ec_over_ons SET story_image='{"id":"01KV84WV46WDTJJ0573BM0WYVD","provider":"local","filename":"over-ons-verhaal.webp","mimeType":"image/webp","width":738,"height":983,"alt":"Vakman aan het werk aan het voegwerk van een gevel","meta":{"storageKey":"01KV84WV46WDTJJ0573BM0WYVD.webp"}}' WHERE slug='main';
|
||||
@@ -0,0 +1,26 @@
|
||||
-- Auto-gegenereerd door scripts/gen-cms-sql.mjs — NIET met de hand bewerken.
|
||||
|
||||
-- ===== collectie: homepage (Homepage) =====
|
||||
INSERT INTO _emdash_collections (id, slug, label, label_singular, supports, source, has_seo) VALUES ('01KV85FTR6069N14G8ANM032XB', 'homepage', 'Homepage', 'Homepage', '["drafts","revisions"]', 'seed', 0);
|
||||
CREATE TABLE "ec_homepage" ("id" text primary key, "slug" text, "status" text default 'draft', "author_id" text, "primary_byline_id" text, "created_at" text default (datetime('now')), "updated_at" text default (datetime('now')), "published_at" text, "scheduled_at" text, "deleted_at" text, "version" integer default 1, "live_revision_id" text references "revisions" ("id"), "draft_revision_id" text references "revisions" ("id"), "locale" text default 'en' not null, "translation_group" text, "hero_title" TEXT NOT NULL DEFAULT '', "hero_sub" TEXT NOT NULL DEFAULT '', "hero_points" JSON, "intro_statement" TEXT NOT NULL DEFAULT '', "intro_body" TEXT NOT NULL DEFAULT '', "intro_meta" TEXT, "diensten_kicker" TEXT, "diensten_kop" TEXT NOT NULL DEFAULT '', "werkwijze_kop" TEXT NOT NULL DEFAULT '', "werkwijze" JSON, "proof_kop" TEXT NOT NULL DEFAULT '', "reviews_featured_text" TEXT NOT NULL DEFAULT '', "reviews_featured_name" TEXT, "reviews_featured_place" TEXT, "reviews_small" JSON, "gebied_kop" TEXT NOT NULL DEFAULT '', "gebied_sub" TEXT, "faq_kop" TEXT NOT NULL DEFAULT '', "faq_lead" TEXT, "faq" JSON, constraint "ec_homepage_slug_locale_unique" unique ("slug", "locale"));
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KV85FTR9KA233VFHXJYKH8M6', '01KV85FTR6069N14G8ANM032XB', 'hero_title', 'Hero · titel (H1)', 'string', 'TEXT', 1, 0, 0, 1);
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KV85FTR95GFH8YE21S52SHGG', '01KV85FTR6069N14G8ANM032XB', 'hero_sub', 'Hero · subtekst', 'text', 'TEXT', 1, 1, 0, 1);
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable, validation) VALUES ('01KV85FTR9J0WQ54R4Y1YGNCYX', '01KV85FTR6069N14G8ANM032XB', 'hero_points', 'Hero · vinkjes (rijen)', 'repeater', 'JSON', 0, 2, 0, 1, '{"subFields":[{"slug":"text","type":"string","label":"Tekst","required":true}],"minItems":0,"maxItems":20}');
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KV85FTRACBQ392VJDG4RB7WG', '01KV85FTR6069N14G8ANM032XB', 'intro_statement', 'Intro · statement', 'text', 'TEXT', 1, 3, 0, 1);
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KV85FTRAEJTZDY9EXTAAZ8GY', '01KV85FTR6069N14G8ANM032XB', 'intro_body', 'Intro · alinea', 'text', 'TEXT', 1, 4, 0, 1);
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KV85FTRAZ70HB3376WV5FKNA', '01KV85FTR6069N14G8ANM032XB', 'intro_meta', 'Intro · meta-regel', 'string', 'TEXT', 0, 5, 0, 1);
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KV85FTRA04S345B9MCEQQ42T', '01KV85FTR6069N14G8ANM032XB', 'diensten_kicker', 'Diensten · kicker', 'string', 'TEXT', 0, 6, 0, 1);
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KV85FTRAJEQNA3H503E2BPJN', '01KV85FTR6069N14G8ANM032XB', 'diensten_kop', 'Diensten · kop', 'string', 'TEXT', 1, 7, 0, 1);
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KV85FTRA283DGA2C4N4M6CEB', '01KV85FTR6069N14G8ANM032XB', 'werkwijze_kop', 'Werkwijze · kop', 'string', 'TEXT', 1, 8, 0, 1);
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable, validation) VALUES ('01KV85FTRA3Y1W6WVHH74J2ZGF', '01KV85FTR6069N14G8ANM032XB', 'werkwijze', 'Werkwijze · stappen (rijen)', 'repeater', 'JSON', 0, 9, 0, 1, '{"subFields":[{"slug":"title","type":"string","label":"Titel","required":true},{"slug":"text","type":"text","label":"Tekst"}],"minItems":0,"maxItems":20}');
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KV85FTRB3S6RTRCJC5TZM8F4', '01KV85FTR6069N14G8ANM032XB', 'proof_kop', 'Bewijs-galerij · kop', 'string', 'TEXT', 1, 10, 0, 1);
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KV85FTRB8BGKZM5EGZ47A7VS', '01KV85FTR6069N14G8ANM032XB', 'reviews_featured_text', 'Reviews · uitgelichte quote', 'text', 'TEXT', 1, 11, 0, 1);
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KV85FTRBYCX8YKMR957J4KEF', '01KV85FTR6069N14G8ANM032XB', 'reviews_featured_name', 'Reviews · uitgelichte naam', 'string', 'TEXT', 0, 12, 0, 1);
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KV85FTRBN35PFZ4SHG2F615E', '01KV85FTR6069N14G8ANM032XB', 'reviews_featured_place', 'Reviews · uitgelichte plaats', 'string', 'TEXT', 0, 13, 0, 1);
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable, validation) VALUES ('01KV85FTRBDBM30119C8MRWWFH', '01KV85FTR6069N14G8ANM032XB', 'reviews_small', 'Reviews · kleine reviews (rijen)', 'repeater', 'JSON', 0, 14, 0, 1, '{"subFields":[{"slug":"text","type":"text","label":"Reviewtekst","required":true},{"slug":"name","type":"string","label":"Naam"}],"minItems":0,"maxItems":20}');
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KV85FTRBBM6H6HQTRS8987G9', '01KV85FTR6069N14G8ANM032XB', 'gebied_kop', 'Werkgebied · kop', 'string', 'TEXT', 1, 15, 0, 1);
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KV85FTRB3C9QA939AWMCCCHC', '01KV85FTR6069N14G8ANM032XB', 'gebied_sub', 'Werkgebied · subtekst', 'string', 'TEXT', 0, 16, 0, 1);
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KV85FTRBEZ8BJFW92R7JWSHD', '01KV85FTR6069N14G8ANM032XB', 'faq_kop', 'FAQ · kop', 'string', 'TEXT', 1, 17, 0, 1);
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KV85FTRB31ZQ7YR0E21MM06V', '01KV85FTR6069N14G8ANM032XB', 'faq_lead', 'FAQ · inleiding', 'string', 'TEXT', 0, 18, 0, 1);
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable, validation) VALUES ('01KV85FTRBZB7WGD5FBGG4QMFA', '01KV85FTR6069N14G8ANM032XB', 'faq', 'FAQ · vragen (rijen)', 'repeater', 'JSON', 0, 19, 0, 1, '{"subFields":[{"slug":"question","type":"string","label":"Vraag","required":true},{"slug":"answer","type":"text","label":"Antwoord"}],"minItems":0,"maxItems":20}');
|
||||
INSERT INTO "ec_homepage" ("id", "slug", "status", "locale", "hero_title", "hero_sub", "hero_points", "intro_statement", "intro_body", "intro_meta", "diensten_kicker", "diensten_kop", "werkwijze_kop", "werkwijze", "proof_kop", "reviews_featured_text", "reviews_featured_name", "reviews_featured_place", "reviews_small", "gebied_kop", "gebied_sub", "faq_kop", "faq_lead", "faq") VALUES ('01KV85FTRBPVZGKWGDW655XE97', 'main', 'published', 'en', 'Je gevel weer als nieuw.', 'Van groene aanslag tot kapot voegwerk: wij maken de buitenkant van je huis weer schoon en heel. Netjes opgeleverd, met een eerlijke prijs vooraf.', '[{"text":"Eerlijke prijs vooraf"},{"text":"Netjes opgeleverd"},{"text":"Particulier en zakelijk"}]', 'Schoon, strak en vakkundig. Dat is waar Helvoirt Gevelreiniging voor staat.', 'Of het nu om een hele gevel gaat of om één schoorsteen: we komen langs, denken mee en leveren werk waar je trots op bent. Voor particulieren en bedrijven in Helvoirt en omgeving.', 'Particulier en zakelijk · eerlijke prijs vooraf · netjes opgeleverd', 'Onze diensten', 'Vakwerk voor je hele buitenkant', 'Van eerste belletje tot schoon opgeleverd', '[{"title":"Bellen of mailen","text":"Je vraagt een gratis offerte aan. Vertel kort om welk werk het gaat."},{"title":"We komen kijken","text":"We bekijken het werk ter plekke, meten op en denken met je mee."},{"title":"Heldere offerte","text":"Je krijgt een duidelijke prijs op papier. Geen verrassingen achteraf."},{"title":"Schoon opgeleverd","text":"We voeren het netjes uit, ruimen op en lopen het samen na."}]', 'Echt werk, echte resultaten', 'Onze gevel zat vol groene aanslag en ziet er nu weer als nieuw uit. Keurig gewerkt, alles netjes afgedekt en opgeruimd. Echt vakwerk.', 'Familie Jansen', 'Helvoirt', '[{"text":"Snel een eerlijke offerte gekregen en het voegwerk is prachtig geworden.","name":"Peter de Vries"},{"text":"Dak laten reinigen en coaten. Goede communicatie en een mooi resultaat.","name":"M. Bakker"}]', 'Werkzaam in Helvoirt en omgeving', 'Staat jouw plaats er niet bij? Bel of mail gerust, vaak komen we er ook.', 'Goed om te weten', 'De vragen die klanten ons meestal als eerste stellen.', '[{"question":"Wat bepaalt de prijs?","answer":"De grootte van het oppervlak, de mate van vervuiling en de bereikbaarheid. Je krijgt vooraf een vaste prijs, zonder verrassingen achteraf."},{"question":"Hoe lang duurt het?","answer":"Dat hangt van het werk af. Bij de offerte hoor je een concrete planning, zodat je weet waar je aan toe bent."},{"question":"Geven jullie garantie?","answer":"Ja. We werken netjes en staan achter ons werk; de afspraken leggen we vast in de offerte."},{"question":"Moet ik thuis zijn?","answer":"Voor de inspectie en de oplevering is het handig, maar lang niet altijd nodig. We stemmen het samen af."}]');
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Auto-gegenereerd door scripts/gen-cms-sql.mjs — NIET met de hand bewerken.
|
||||
|
||||
-- ===== collectie: contact_page (Contactpagina) =====
|
||||
INSERT INTO _emdash_collections (id, slug, label, label_singular, supports, source, has_seo) VALUES ('01KV861174PBRAR04T26ZM8S4D', 'contact_page', 'Contactpagina', 'Contactpagina', '["drafts","revisions"]', 'seed', 0);
|
||||
CREATE TABLE "ec_contact_page" ("id" text primary key, "slug" text, "status" text default 'draft', "author_id" text, "primary_byline_id" text, "created_at" text default (datetime('now')), "updated_at" text default (datetime('now')), "published_at" text, "scheduled_at" text, "deleted_at" text, "version" integer default 1, "live_revision_id" text references "revisions" ("id"), "draft_revision_id" text references "revisions" ("id"), "locale" text default 'en' not null, "translation_group" text, "eyebrow" TEXT, "title" TEXT NOT NULL DEFAULT '', "intro" TEXT NOT NULL DEFAULT '', "meta_title" TEXT, "meta_description" TEXT, constraint "ec_contact_page_slug_locale_unique" unique ("slug", "locale"));
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KV861176AJ97JADDW76KTZ1S', '01KV861174PBRAR04T26ZM8S4D', 'eyebrow', 'Kicker', 'string', 'TEXT', 0, 0, 0, 1);
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KV86117653QW0MTMBJE5711F', '01KV861174PBRAR04T26ZM8S4D', 'title', 'Titel (H1)', 'string', 'TEXT', 1, 1, 0, 1);
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KV861176B720RF1T2HQMVFRA', '01KV861174PBRAR04T26ZM8S4D', 'intro', 'Intro', 'text', 'TEXT', 1, 2, 0, 1);
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KV861176RPS7QAY62CCYAKCF', '01KV861174PBRAR04T26ZM8S4D', 'meta_title', 'SEO · paginatitel', 'string', 'TEXT', 0, 3, 0, 1);
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KV861176ZGV6Y48EXHFFWP0T', '01KV861174PBRAR04T26ZM8S4D', 'meta_description', 'SEO · omschrijving', 'text', 'TEXT', 0, 4, 0, 1);
|
||||
INSERT INTO "ec_contact_page" ("id", "slug", "status", "locale", "eyebrow", "title", "intro", "meta_title", "meta_description") VALUES ('01KV8611767AHXRRK2A1Q432XK', 'main', 'published', 'en', 'Contact', 'Vraag een gratis offerte aan', 'Laat je gegevens achter, dan plannen we een moment om langs te komen en het werk te bekijken. Je krijgt een heldere prijs op papier, zonder verplichtingen. Liever even bellen of mailen? Dat kan ook.', 'Contact en gratis offerte · Helvoirt Gevelreiniging', 'Neem contact op met Helvoirt Gevelreiniging voor een gratis, vrijblijvende offerte. Bel, mail of laat je gegevens achter via het formulier.');
|
||||
@@ -0,0 +1,46 @@
|
||||
-- Auto-gegenereerd door scripts/gen-cms-sql.mjs — NIET met de hand bewerken.
|
||||
|
||||
-- ===== collectie: diensten (Diensten) =====
|
||||
INSERT INTO _emdash_collections (id, slug, label, label_singular, supports, source, has_seo) VALUES ('01KVA99538R5JTKEVHDYN32XD3', 'diensten', 'Diensten', 'Dienst', '["drafts","revisions"]', 'seed', 0);
|
||||
CREATE TABLE "ec_diensten" ("id" text primary key, "slug" text, "status" text default 'draft', "author_id" text, "primary_byline_id" text, "created_at" text default (datetime('now')), "updated_at" text default (datetime('now')), "published_at" text, "scheduled_at" text, "deleted_at" text, "version" integer default 1, "live_revision_id" text references "revisions" ("id"), "draft_revision_id" text references "revisions" ("id"), "locale" text default 'en' not null, "translation_group" text, "nav_order" INTEGER, "nav_title" TEXT NOT NULL DEFAULT '', "nav_text" TEXT NOT NULL DEFAULT '', "nav_image" TEXT, "nav_alt" TEXT, "title" TEXT NOT NULL DEFAULT '', "eyebrow" TEXT, "lead" TEXT NOT NULL DEFAULT '', "hero_image" TEXT, "hero_alt" TEXT, "hero_caption" TEXT, "points" JSON, "intro_heading" TEXT NOT NULL DEFAULT '', "intro_body" JSON, "included_title" TEXT, "included" JSON, "resultaat_heading" TEXT, "resultaat_sub" TEXT, "result_1_image" TEXT, "result_1_alt" TEXT, "result_1_caption" TEXT, "result_1_detail" TEXT, "result_2_image" TEXT, "result_2_alt" TEXT, "result_2_caption" TEXT, "result_2_detail" TEXT, "result_3_image" TEXT, "result_3_alt" TEXT, "result_3_caption" TEXT, "result_3_detail" TEXT, "aanpak_title" TEXT, "aanpak" JSON, "faq" JSON, "meta_title" TEXT, "meta_description" TEXT, "noindex" INTEGER, constraint "ec_diensten_slug_locale_unique" unique ("slug", "locale"));
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KVA99539W1MVK5SHZB8AJT6Y', '01KVA99538R5JTKEVHDYN32XD3', 'nav_order', 'Volgorde in het menu (1-5)', 'integer', 'INTEGER', 0, 0, 0, 1);
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KVA99539DREEZK0G7A9FZC85', '01KVA99538R5JTKEVHDYN32XD3', 'nav_title', 'Menu/overzicht · titel', 'string', 'TEXT', 1, 1, 0, 1);
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KVA99539GZ9MT7MQFHEPJRVP', '01KVA99538R5JTKEVHDYN32XD3', 'nav_text', 'Menu/overzicht · korte tekst', 'text', 'TEXT', 1, 2, 0, 1);
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KVA99539G0WTZHSD0YETHASB', '01KVA99538R5JTKEVHDYN32XD3', 'nav_image', 'Overzicht · afbeelding (leeg = standaardfoto)', 'image', 'TEXT', 0, 3, 0, 1);
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KVA995397F9W7NMBRDBGAJZD', '01KVA99538R5JTKEVHDYN32XD3', 'nav_alt', 'Overzicht · alt-tekst', 'string', 'TEXT', 0, 4, 0, 1);
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KVA99539CGBSW2WCQED1KQD1', '01KVA99538R5JTKEVHDYN32XD3', 'title', 'Detailpagina · titel (H1)', 'string', 'TEXT', 1, 5, 0, 1);
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KVA995393FWWYDA30J1YP9JZ', '01KVA99538R5JTKEVHDYN32XD3', 'eyebrow', 'Detailpagina · kicker', 'string', 'TEXT', 0, 6, 0, 1);
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KVA99539KVMR7NBVH4V6TJKT', '01KVA99538R5JTKEVHDYN32XD3', 'lead', 'Detailpagina · introzin (hero)', 'text', 'TEXT', 1, 7, 0, 1);
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KVA9953901ME27BGWF2THBJW', '01KVA99538R5JTKEVHDYN32XD3', 'hero_image', 'Hero · afbeelding (leeg = standaardfoto)', 'image', 'TEXT', 0, 8, 0, 1);
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KVA995399AKANV3RRNXF2BBS', '01KVA99538R5JTKEVHDYN32XD3', 'hero_alt', 'Hero · alt-tekst', 'string', 'TEXT', 0, 9, 0, 1);
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KVA99539K7GVCR6FP7B7B8G4', '01KVA99538R5JTKEVHDYN32XD3', 'hero_caption', 'Hero · bijschrift', 'string', 'TEXT', 0, 10, 0, 1);
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable, validation) VALUES ('01KVA99539NCW5J5D6CA34069B', '01KVA99538R5JTKEVHDYN32XD3', 'points', 'Hero · vinkjes (rijen)', 'repeater', 'JSON', 0, 11, 0, 1, '{"subFields":[{"slug":"text","type":"string","label":"Tekst","required":true}],"minItems":0,"maxItems":20}');
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KVA99539J158DQW95TW7QPZY', '01KVA99538R5JTKEVHDYN32XD3', 'intro_heading', 'Intro · kop', 'string', 'TEXT', 1, 12, 0, 1);
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable, validation) VALUES ('01KVA995399EY9RARAHN0Z6N8B', '01KVA99538R5JTKEVHDYN32XD3', 'intro_body', 'Intro · alinea''s (rijen)', 'repeater', 'JSON', 0, 13, 0, 1, '{"subFields":[{"slug":"text","type":"text","label":"Alinea","required":true}],"minItems":0,"maxItems":20}');
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KVA9953936PWTVCPTRWC5PBV', '01KVA99538R5JTKEVHDYN32XD3', 'included_title', 'Wat we doen · kop', 'string', 'TEXT', 0, 14, 0, 1);
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable, validation) VALUES ('01KVA99539D73ZRHH9PSN2GXWV', '01KVA99538R5JTKEVHDYN32XD3', 'included', 'Wat we doen · punten (rijen)', 'repeater', 'JSON', 0, 15, 0, 1, '{"subFields":[{"slug":"text","type":"string","label":"Punt","required":true}],"minItems":0,"maxItems":20}');
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KVA9953APRSFFM11889ADRNB', '01KVA99538R5JTKEVHDYN32XD3', 'resultaat_heading', 'Resultaat · kop', 'string', 'TEXT', 0, 16, 0, 1);
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KVA9953AR52G40D0EA6CP69C', '01KVA99538R5JTKEVHDYN32XD3', 'resultaat_sub', 'Resultaat · subtekst', 'text', 'TEXT', 0, 17, 0, 1);
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KVA9953A0D9W40GGH591389P', '01KVA99538R5JTKEVHDYN32XD3', 'result_1_image', 'Resultaat 1 · afbeelding (leeg = standaardfoto)', 'image', 'TEXT', 0, 18, 0, 1);
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KVA9953A889PG42PVQBTHJCS', '01KVA99538R5JTKEVHDYN32XD3', 'result_1_alt', 'Resultaat 1 · alt-tekst', 'string', 'TEXT', 0, 19, 0, 1);
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KVA9953AEH0PS7EN814QE2D8', '01KVA99538R5JTKEVHDYN32XD3', 'result_1_caption', 'Resultaat 1 · bijschrift', 'string', 'TEXT', 0, 20, 0, 1);
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KVA9953A7V94ZQT9VFRGPAKW', '01KVA99538R5JTKEVHDYN32XD3', 'result_1_detail', 'Resultaat 1 · detailtekst', 'text', 'TEXT', 0, 21, 0, 1);
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KVA9953A90007TRNW9X1FNV9', '01KVA99538R5JTKEVHDYN32XD3', 'result_2_image', 'Resultaat 2 · afbeelding (leeg = standaardfoto)', 'image', 'TEXT', 0, 22, 0, 1);
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KVA9953A3AMCP04JH17ZKM77', '01KVA99538R5JTKEVHDYN32XD3', 'result_2_alt', 'Resultaat 2 · alt-tekst', 'string', 'TEXT', 0, 23, 0, 1);
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KVA9953A7E1JDB5FT18818K8', '01KVA99538R5JTKEVHDYN32XD3', 'result_2_caption', 'Resultaat 2 · bijschrift', 'string', 'TEXT', 0, 24, 0, 1);
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KVA9953A33YZ0KKM90PPECFV', '01KVA99538R5JTKEVHDYN32XD3', 'result_2_detail', 'Resultaat 2 · detailtekst', 'text', 'TEXT', 0, 25, 0, 1);
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KVA9953AVF10QJMK2ME7XNAQ', '01KVA99538R5JTKEVHDYN32XD3', 'result_3_image', 'Resultaat 3 · afbeelding (leeg = standaardfoto)', 'image', 'TEXT', 0, 26, 0, 1);
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KVA9953AZ8NB820DVY0KZ1G5', '01KVA99538R5JTKEVHDYN32XD3', 'result_3_alt', 'Resultaat 3 · alt-tekst', 'string', 'TEXT', 0, 27, 0, 1);
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KVA9953AZA4XW4KD3VRZJ0JS', '01KVA99538R5JTKEVHDYN32XD3', 'result_3_caption', 'Resultaat 3 · bijschrift', 'string', 'TEXT', 0, 28, 0, 1);
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KVA9953AD2VDJWXKGT0EXKDR', '01KVA99538R5JTKEVHDYN32XD3', 'result_3_detail', 'Resultaat 3 · detailtekst', 'text', 'TEXT', 0, 29, 0, 1);
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KVA9953AW0ZWBB3WR8TJ90VY', '01KVA99538R5JTKEVHDYN32XD3', 'aanpak_title', 'Aanpak · kop', 'string', 'TEXT', 0, 30, 0, 1);
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable, validation) VALUES ('01KVA9953AEQAFB5E4QS7P239R', '01KVA99538R5JTKEVHDYN32XD3', 'aanpak', 'Aanpak · stappen (rijen)', 'repeater', 'JSON', 0, 31, 0, 1, '{"subFields":[{"slug":"title","type":"string","label":"Titel","required":true},{"slug":"text","type":"text","label":"Tekst"}],"minItems":0,"maxItems":20}');
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable, validation) VALUES ('01KVA9953AQGD2HQQKN12ADAVM', '01KVA99538R5JTKEVHDYN32XD3', 'faq', 'FAQ · vragen (rijen)', 'repeater', 'JSON', 0, 32, 0, 1, '{"subFields":[{"slug":"question","type":"string","label":"Vraag","required":true},{"slug":"answer","type":"text","label":"Antwoord"}],"minItems":0,"maxItems":20}');
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KVA9953ATASY0S7K6DTWFMY5', '01KVA99538R5JTKEVHDYN32XD3', 'meta_title', 'SEO · paginatitel', 'string', 'TEXT', 0, 33, 0, 1);
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KVA9953BJ40GW99R6YCCXRVZ', '01KVA99538R5JTKEVHDYN32XD3', 'meta_description', 'SEO · omschrijving', 'text', 'TEXT', 0, 34, 0, 1);
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KVA9953BVDWQVE4YJX763CKA', '01KVA99538R5JTKEVHDYN32XD3', 'noindex', 'SEO · uitsluiten van Google (noindex)', 'boolean', 'INTEGER', 0, 35, 0, 1);
|
||||
INSERT INTO "ec_diensten" ("id", "slug", "status", "locale", "nav_order", "nav_title", "nav_text", "nav_alt", "title", "eyebrow", "lead", "hero_alt", "hero_caption", "points", "intro_heading", "intro_body", "included_title", "included", "resultaat_heading", "resultaat_sub", "result_1_alt", "result_1_caption", "result_1_detail", "result_2_alt", "result_2_caption", "result_2_detail", "result_3_alt", "result_3_caption", "result_3_detail", "aanpak_title", "aanpak", "faq", "meta_title", "meta_description") VALUES ('01KVA9953B7H0NG3S51CX30TRW', 'gevelreiniging', 'published', 'en', 1, 'Gevelreiniging + renovatie', 'Jaren aanslag, groene vervuiling en vuil weg, en herstel waar nodig. De gevel staat er weer strak bij, zonder schade aan de steen.', 'Bakstenen gevel half gereinigd: links nog vuil, rechts weer schoon', 'Gevelreiniging en renovatie', 'Onze diensten', 'Jaren van aanslag, groene vervuiling en vuil halen we van je gevel, en waar nodig herstellen we de steen en het voegwerk. Je huis staat er weer strak en verzorgd bij, zonder schade aan de gevel.', 'Bakstenen gevel halverwege de reiniging: links nog vuil, rechts weer schoon', 'Halverwege de reiniging', '[{"text":"Veilig voor de steen"},{"text":"Herstel inbegrepen"},{"text":"Eerlijke prijs vooraf"}]', 'Een schone, gezonde gevel die er weer staat als nieuw', '[{"text":"Een vieze gevel is meer dan een kwestie van uiterlijk. Groene aanslag, algen en mos houden vocht vast, en dat tast op den duur de steen en het voegwerk aan. Wij halen die laag eraf met een methode die past bij jouw gevel, en pakken zwakke plekken meteen mee."},{"text":"Of het nu om een rijtjeshuis gaat of om een hele bedrijfsgevel: je krijgt vooraf een vaste prijs en een duidelijke planning. We werken netjes, dekken alles goed af en leveren schoon op."}]', 'Wat we voor je doen', '[{"text":"Groene aanslag, algen en mos verwijderen"},{"text":"Vuil, roet en uitslag van de gevel halen"},{"text":"De juiste reinigingsmethode per steensoort"},{"text":"Loszittend of beschadigd voegwerk herstellen"},{"text":"Optioneel impregneren tegen nieuwe vervuiling"},{"text":"Alles netjes afgedekt en opgeruimd"}]', 'Zo ziet het resultaat eruit', 'Een greep uit ons werk: schone gevels, hersteld voegwerk en steen die er weer verzorgd bij staat.', 'Schone bakstenen gevel na het verwijderen van aanslag en mos', 'Aanslag en mos verwijderd', 'Groene vervuiling en vuil weg, zonder de steen te beschadigen.', 'Strak hersteld voegwerk tussen de bakstenen', 'Voegwerk hersteld', 'Loszittende en kapotte voegen vervangen, weer netjes en waterdicht.', 'Verzorgde, schone gevel van een woonhuis', 'Strak en verzorgd', 'De gevel staat er weer als nieuw bij, klaar voor jaren vooruit.', 'Zo pakken we het aan', '[{"title":"Inspectie en advies","text":"We bekijken de steen en de mate van vervuiling, en kiezen de methode die schoon maakt zonder de gevel te beschadigen."},{"title":"Reinigen op maat","text":"Van een zachte lagedruk-reiniging tot een steviger aanpak bij hardnekkige aanslag, afgestemd op jouw gevel."},{"title":"Herstel waar nodig","text":"Loszittend of kapot voegwerk en kleine beschadigingen pakken we in dezelfde klus mee."},{"title":"Strak opgeleverd","text":"We spoelen na, ruimen alles op en lopen het samen na, zodat je tevreden achterblijft."}]', '[{"question":"Beschadigt gevelreiniging de steen?","answer":"Niet als het goed gebeurt. We stemmen de druk en het reinigingsmiddel af op de steensoort, zodat de gevel schoon wordt zonder dat de steen of de voegen eronder lijden."},{"question":"Welke methode gebruiken jullie?","answer":"Dat hangt van de gevel af. Soms volstaat een zachte lagedruk-reiniging, bij zware aanslag is een steviger aanpak nodig. We kiezen altijd wat veilig en effectief is voor jouw situatie."},{"question":"Hoe lang blijft de gevel schoon?","answer":"Dat verschilt per ligging en omgeving. Een gevel op het noorden vervuilt sneller dan een zonnige kant. Met een impregneerlaag gaat het langer mee, omdat vuil en vocht minder grip krijgen."},{"question":"Doen jullie ook het voegwerk?","answer":"Ja. Loszittend of beschadigd voegwerk herstellen we in dezelfde klus, zodat de gevel niet alleen schoon is maar ook weer heel en waterdicht."}]', 'Gevelreiniging en renovatie in Helvoirt en omgeving · Helvoirt Gevelreiniging', 'Gevelreiniging en renovatie door Helvoirt Gevelreiniging. Aanslag en groene vervuiling weg, herstel van steen en voegwerk, veilig voor de gevel. Vraag een gratis offerte aan.');
|
||||
INSERT INTO "ec_diensten" ("id", "slug", "status", "locale", "nav_order", "nav_title", "nav_text", "nav_alt", "title", "eyebrow", "lead", "hero_alt", "hero_caption", "points", "intro_heading", "intro_body", "included_title", "included", "resultaat_heading", "resultaat_sub", "result_1_alt", "result_1_caption", "result_1_detail", "result_2_alt", "result_2_caption", "result_2_detail", "result_3_alt", "result_3_caption", "result_3_detail", "aanpak_title", "aanpak", "faq", "meta_title", "meta_description") VALUES ('01KVA9953BHYC0T530GXBEF3R3', 'voegwerk', 'published', 'en', 2, 'Voegwerkzaamheden', 'Oud en kapot voegwerk uithakken en opnieuw invoegen. Dat houdt de gevel waterdicht en netjes, passend bij het huis.', 'Strak nieuw voegwerk tussen rode bakstenen', 'Voegwerkzaamheden', 'Onze diensten', 'Oud, uitgesleten of kapot voegwerk vervangen we vakkundig. We hakken de slechte voegen uit en voegen opnieuw in, in een kleur en stijl die bij je huis passen. Zo blijft de gevel waterdicht en netjes.', 'Strak nieuw voegwerk tussen rode bakstenen van een gevel', 'Vers ingevoegd', '[{"text":"Past bij je gevel"},{"text":"Weer waterdicht"},{"text":"Eerlijke prijs vooraf"}]', 'Goed voegwerk houdt je gevel heel en droog', '[{"text":"Voegen zijn meer dan een afwerking. Ze houden het vocht buiten en de stenen op hun plek. Als de voegen uitslijten, verzanden of loslaten, krijgt water vrij spel en kan er vorstschade en lekkage ontstaan. Wij hakken het slechte voegwerk uit en voegen opnieuw in."},{"text":"We stemmen de kleur en de vorm van de voeg af op je huis, zodat het werk niet opvalt maar gewoon klopt. Bij oudere panden werken we waar nodig met een ademende kalkmortel."}]', 'Wat we voor je doen', '[{"text":"Oude en kapotte voegen uithakken"},{"text":"Opnieuw invoegen in de juiste kleur en vorm"},{"text":"Knip-, snij- of platvol voegwerk naar wens"},{"text":"Passende mortel, ook voor oudere panden"},{"text":"Scheuren en kleine beschadigingen meenemen"},{"text":"Netjes afgewerkt en opgeruimd"}]', 'Zo ziet het resultaat eruit', 'Een greep uit ons voegwerk: strak ingevoegd, waterdicht en passend bij de gevel.', 'Strak nieuw voegwerk tussen de bakstenen van een gevel', 'Strak ingevoegd', 'Uitgehakt en opnieuw gevoegd in een passende kleur.', 'Hersteld metsel- en voegwerk aan een tuinmuur met trap', 'Metsel- en voegwerk hersteld', 'Loszittende stenen en voegen weer vast en netjes.', 'Verzorgde gevel met strak voegwerk', 'Verzorgd geheel', 'Schone steen en strak voegwerk, weer als nieuw.', 'Zo pakken we het aan', '[{"title":"Inspectie en advies","text":"We bekijken de voegen en de steen, en bepalen welke mortel en voegvorm passen."},{"title":"Uithakken","text":"Het oude, slechte voegwerk halen we netjes weg tot op de juiste diepte."},{"title":"Opnieuw invoegen","text":"We voegen in met de juiste mortel, kleur en afwerking voor jouw gevel."},{"title":"Strak opgeleverd","text":"We borstelen na, ruimen op en lopen het samen na."}]', '[{"question":"Wanneer is voegwerk aan vervanging toe?","answer":"Als voegen verzanden, uitslijten, loslaten of als je vocht of vorstschade ziet. Wij beoordelen het vrijblijvend en zeggen eerlijk of het nodig is."},{"question":"Kunnen jullie de kleur van de voeg aanpassen?","answer":"Ja. We stemmen de kleur en de vorm af op je huis, zodat het nieuwe voegwerk natuurlijk overgaat in het bestaande beeld."},{"question":"Doen jullie ook oudere of monumentale panden?","answer":"Ja. Bij oudere panden gebruiken we waar nodig een ademende kalkmortel, zodat de muur droog kan blijven ademen."},{"question":"Moet de hele gevel opnieuw gevoegd worden?","answer":"Niet altijd. Soms volstaat het om de slechte vlakken aan te pakken. We adviseren wat verstandig en kostenefficiënt is."}]', 'Voegwerkzaamheden in Helvoirt en omgeving · Helvoirt Gevelreiniging', 'Voegwerk vervangen en herstellen door Helvoirt Gevelreiniging. Oude voegen uithakken en opnieuw invoegen in de juiste kleur en mortel, weer waterdicht. Vraag een gratis offerte aan.');
|
||||
INSERT INTO "ec_diensten" ("id", "slug", "status", "locale", "nav_order", "nav_title", "nav_text", "nav_alt", "title", "eyebrow", "lead", "hero_alt", "hero_caption", "points", "intro_heading", "intro_body", "included_title", "included", "resultaat_heading", "resultaat_sub", "result_1_alt", "result_1_caption", "result_1_detail", "result_2_alt", "result_2_caption", "result_2_detail", "result_3_alt", "result_3_caption", "result_3_detail", "aanpak_title", "aanpak", "faq", "meta_title", "meta_description") VALUES ('01KVA9953CPG3K9D3J20TAGYPB', 'dakreiniging-coating', 'published', 'en', 3, 'Dak reinigen + coating', 'Mos en aanslag eraf, daarna een beschermende coating. Een schoon dak dat water beter afvoert en langer mooi blijft.', 'Schoon dakpannendak na reiniging en coating', 'Dak reinigen en coaten', 'Onze diensten', 'Mos, algen en aanslag halen we van je dak, daarna brengen we waar gewenst een beschermende coating aan. Een schoon dak dat water beter afvoert en langer mooi en heel blijft.', 'Schoon dakpannendak na reiniging en coating', 'Schoon en beschermd', '[{"text":"Mos en aanslag eraf"},{"text":"Beschermende coating"},{"text":"Eerlijke prijs vooraf"}]', 'Een schoon dak gaat langer mee', '[{"text":"Mos en algen houden vocht vast en kunnen pannen poreus maken en de waterafvoer verstoppen. Wij reinigen het dak grondig en verwijderen de begroeiing, zodat het water weer vrij wegloopt en je dak er weer netjes bij ligt."},{"text":"Daarna kun je kiezen voor een beschermende coating. Die sluit de pannen af, gaat nieuwe aangroei tegen en geeft het dak weer een egale, frisse kleur."}]', 'Wat we voor je doen', '[{"text":"Mos, algen en aanslag verwijderen"},{"text":"Dak grondig reinigen, veilig op hoogte"},{"text":"Dakgoten en afvoer meenemen"},{"text":"Optioneel een beschermende coating"},{"text":"Kleurherstel voor een egaal, fris dak"},{"text":"Afgespoelde resten netjes opgeruimd"}]', 'Van mos tot een schoon dak', 'Mos eraf, grondig gereinigd en waar gewenst beschermend gecoat.', 'Dakpannendak vol mos voor de reiniging', 'Mos en algen verwijderen', 'Begroeiing houdt vocht vast en verstopt de afvoer, die halen we eraf.', 'Dak wordt op hoogte gereinigd', 'Grondig reinigen op hoogte', 'Veilig en zorgvuldig, ook de lastig bereikbare delen.', 'Schoon dakpannendak na reiniging en coating', 'Schoon en beschermd opgeleverd', 'Een egaal, fris dak, klaar voor jaren vooruit.', 'Zo pakken we het aan', '[{"title":"Inspectie op hoogte","text":"We bekijken het dak, de pannen en de afvoer, en bepalen de juiste aanpak."},{"title":"Reinigen","text":"Mos, algen en aanslag verwijderen we grondig, veilig en netjes op hoogte."},{"title":"Coaten (optioneel)","text":"Een beschermende coating sluit de pannen af en gaat nieuwe aangroei tegen."},{"title":"Strak opgeleverd","text":"We spoelen na, ruimen de resten op en lopen het samen na."}]', '[{"question":"Is mijn dak geschikt voor coating?","answer":"De meeste pannendaken wel. We beoordelen de staat van de pannen eerst; soms is alleen reinigen verstandiger. We zeggen eerlijk wat het beste is."},{"question":"Hoe lang blijft het dak schoon?","answer":"Dat hangt van de ligging en de begroeiing in de omgeving af. Met een coating gaat het langer mee, omdat mos en vuil minder grip krijgen."},{"question":"Werken jullie veilig op hoogte?","answer":"Ja. We werken met de juiste voorzieningen, zodat het werk veilig en zorgvuldig gebeurt."},{"question":"Beschadigt de reiniging de dakpannen niet?","answer":"Niet als de druk en de methode kloppen. We stemmen die af op de staat van de pannen, zodat het dak schoon wordt zonder schade."}]', 'Dak reinigen en coaten in Helvoirt en omgeving · Helvoirt Gevelreiniging', 'Dakreiniging en coating door Helvoirt Gevelreiniging. Mos en aanslag eraf, een beschermende coating en betere waterafvoer. Vraag een gratis offerte aan.');
|
||||
INSERT INTO "ec_diensten" ("id", "slug", "status", "locale", "nav_order", "nav_title", "nav_text", "nav_alt", "title", "eyebrow", "lead", "hero_alt", "hero_caption", "points", "intro_heading", "intro_body", "included_title", "included", "resultaat_heading", "resultaat_sub", "result_1_alt", "result_1_caption", "result_1_detail", "result_2_alt", "result_2_caption", "result_2_detail", "result_3_alt", "result_3_caption", "result_3_detail", "aanpak_title", "aanpak", "faq", "meta_title", "meta_description") VALUES ('01KVA9953CCD1BZS9G3DACX7FQ', 'schoorsteen-voegwerk', 'published', 'en', 4, 'Schoorsteen voegwerk', 'Voeg-, metsel- en loodwerk aan de schoorsteen herstellen, zodat lekkage en verdere schade voorkomen worden.', 'Herstelde schoorsteen met nieuw voegwerk op een dak', 'Schoorsteen voegwerk', 'Onze diensten', 'Voeg- en metselwerk aan de schoorsteen herstellen we vakkundig, vaak in combinatie met het loodwerk. Zo voorkomen we lekkage en verdere schade, en staat de schoorsteen er weer netjes en veilig bij.', 'Herstelde schoorsteen met nieuw voegwerk en loodwerk op een dak', 'Schoorsteen hersteld', '[{"text":"Voorkomt lekkage"},{"text":"Vaak met loodwerk"},{"text":"Eerlijke prijs vooraf"}]', 'Een schoorsteen die weer waterdicht is', '[{"text":"De schoorsteen krijgt veel weer te verduren en is een veelvoorkomende bron van lekkage. Loszittend voegwerk, scheuren in het metselwerk of slecht aansluitend lood laten water binnen, soms tot in de woning. Wij herstellen het voeg- en metselwerk en pakken het loodwerk in dezelfde klus mee."},{"text":"Omdat het werk op hoogte gebeurt, werken we veilig en zorgvuldig. Je krijgt vooraf een duidelijke prijs, ook als blijkt dat alleen een deel hersteld hoeft te worden."}]', 'Wat we voor je doen', '[{"text":"Loszittend voegwerk uithakken en opnieuw invoegen"},{"text":"Scheuren en beschadigd metselwerk herstellen"},{"text":"Loodwerk nakijken en waar nodig vervangen"},{"text":"Aansluitingen weer waterdicht maken"},{"text":"Veilig werken op hoogte"},{"text":"Netjes opgeleverd en opgeruimd"}]', 'Zo ziet het resultaat eruit', 'Schoorstenen met hersteld voeg-, metsel- en loodwerk, weer waterdicht en netjes.', 'Schoorsteen met nieuw aangebracht loodwerk op een dak', 'Nieuw loodwerk', 'Strak aangesloten lood rond de schoorsteen, weer waterdicht.', 'Hersteld metsel- en voegwerk', 'Metselwerk hersteld', 'Loszittende stenen en voegen weer vast en netjes.', 'Herstelde schoorsteen met nieuw voegwerk op een dak', 'Schoorsteen weer netjes', 'Hersteld voeg- en loodwerk, klaar voor jaren vooruit.', 'Zo pakken we het aan', '[{"title":"Inspectie op hoogte","text":"We bekijken het voeg-, metsel- en loodwerk en sporen de oorzaak van lekkage op."},{"title":"Herstellen","text":"Slecht voegwerk hakken we uit, scheuren en metselwerk herstellen we."},{"title":"Loodwerk waterdicht","text":"Het lood kijken we na en vervangen we waar nodig, zodat alles weer goed aansluit."},{"title":"Veilig opgeleverd","text":"We ruimen op en lopen het werk samen na."}]', '[{"question":"Hoe weet ik of mijn schoorsteen lekt?","answer":"Vochtplekken op zolder of rond de schoorsteen, of een muffe geur, wijzen vaak op een lekkende schoorsteen. We zoeken vrijblijvend de oorzaak op."},{"question":"Doen jullie ook het loodwerk?","answer":"Ja. Voeg-, metsel- en loodwerk pakken we het liefst in een klus, want de lekkage zit vaak in de combinatie van die drie."},{"question":"Moet de schoorsteen helemaal opnieuw?","answer":"Lang niet altijd. Vaak volstaat herstel van de slechte plekken. We zeggen eerlijk wat nodig is."},{"question":"Werken jullie veilig op hoogte?","answer":"Ja. We werken met de juiste voorzieningen, zodat het werk veilig en netjes gebeurt."}]', 'Schoorsteen voegwerk en herstel in Helvoirt en omgeving · Helvoirt Gevelreiniging', 'Schoorsteen voegwerk, metselwerk en loodwerk herstellen door Helvoirt Gevelreiniging. Voorkom lekkage en schade, weer waterdicht. Vraag een gratis offerte aan.');
|
||||
INSERT INTO "ec_diensten" ("id", "slug", "status", "locale", "nav_order", "nav_title", "nav_text", "nav_alt", "title", "eyebrow", "lead", "hero_alt", "hero_caption", "points", "intro_heading", "intro_body", "included_title", "included", "resultaat_heading", "resultaat_sub", "result_1_alt", "result_1_caption", "result_1_detail", "result_2_alt", "result_2_caption", "result_2_detail", "result_3_alt", "result_3_caption", "result_3_detail", "aanpak_title", "aanpak", "faq", "meta_title", "meta_description") VALUES ('01KVA9953C2GRRXNQP5JXBQEP9', 'algemeen-reinigen', 'published', 'en', 5, 'Algemeen reinigen', 'Terras, oprit, bestrating en ander buitenwerk. We pakken het hele plaatje aan en maken de buitenkant weer fris.', 'Terras en bestrating worden schoongespoten', 'Algemeen reinigen', 'Onze diensten', 'Terras, oprit, bestrating en ander buitenwerk maken we weer fris. We pakken het hele plaatje rond je huis aan, zodat de buitenkant er weer verzorgd en veilig bij ligt.', 'Terras en bestrating worden schoongespoten', 'Bestrating weer fris', '[{"text":"Terras en oprit"},{"text":"Heel het buitenwerk"},{"text":"Eerlijke prijs vooraf"}]', 'De hele buitenkant weer fris', '[{"text":"Niet alleen de gevel, ook het terras, de oprit, de bestrating en ander buitenwerk vervuilen en vergroenen na verloop van tijd. Groene aanslag maakt bestrating bovendien glad en onveilig. Wij reinigen het grondig, zodat alles er weer netjes en veilig bij ligt."},{"text":"We kijken naar het hele plaatje rond je huis en stemmen de aanpak af op de ondergrond, of het nu om sierbestrating, beton of natuursteen gaat."}]', 'Wat we voor je doen', '[{"text":"Terras, oprit en bestrating reinigen"},{"text":"Groene aanslag en onkruid aanpakken"},{"text":"Tuinmuren, schuttingen en bijgebouwen"},{"text":"Aanpak afgestemd op de ondergrond"},{"text":"Optioneel opnieuw invegen van voegzand"},{"text":"Alles netjes opgeruimd en afgespoeld"}]', 'Zo ziet het resultaat eruit', 'Een greep uit ons werk: terrassen, bestrating en muren weer schoon en fris.', 'Schone tuinmuur en bestrating na reiniging', 'Tuinmuur en bestrating', 'Muur en terras weer schoon en verzorgd.', 'Schone tuintrap en metselwerk', 'Tuintrap en metselwerk', 'Bestrating en trap weer netjes en veilig.', 'Vers gereinigd terras en bestrating', 'Terras weer fris', 'Groene aanslag eraf, de bestrating is weer veilig.', 'Zo pakken we het aan', '[{"title":"Inspectie en advies","text":"We bekijken het buitenwerk en de ondergrond, en kiezen de juiste methode."},{"title":"Reinigen op maat","text":"Met de juiste druk halen we aanslag en vuil weg, zonder de bestrating te beschadigen."},{"title":"Afwerken","text":"Waar nodig vegen we voegzand opnieuw in en pakken we onkruid aan."},{"title":"Strak opgeleverd","text":"We spoelen na, ruimen op en lopen het samen na."}]', '[{"question":"Wat kunnen jullie allemaal reinigen?","answer":"Terras, oprit, bestrating, tuinmuren, schuttingen en bijgebouwen. Vrijwel al het buitenwerk rond je huis."},{"question":"Beschadigt hogedruk de bestrating niet?","answer":"Niet als de druk klopt. We stemmen die af op de ondergrond, zodat het schoon wordt zonder schade."},{"question":"Vegen jullie het voegzand opnieuw in?","answer":"Dat kan. Na het reinigen kunnen we het voegzand aanvullen, zodat de bestrating stevig blijft liggen."},{"question":"Kan dit samen met de gevelreiniging?","answer":"Zeker. Veel klanten combineren het, dan staat de hele buitenkant in een keer weer fris. Dat scheelt ook in de kosten."}]', 'Terras, oprit en bestrating reinigen in Helvoirt en omgeving · Helvoirt Gevelreiniging', 'Algemeen reinigen door Helvoirt Gevelreiniging: terras, oprit, bestrating en ander buitenwerk weer fris en veilig. Vraag een gratis offerte aan.');
|
||||
@@ -0,0 +1,40 @@
|
||||
-- Diensten: foto's in de mediabibliotheek + gekoppeld aan de 25 beeldvelden.
|
||||
INSERT OR REPLACE INTO media (id, filename, mime_type, size, width, height, alt, storage_key, status, created_at) VALUES
|
||||
('01KVABG8HVD5Q9SZHB57R1V4QE', 'dienst-gevelreiniging-1280.webp', 'image/webp', 223140, 1280, 956, 'Bakstenen gevel halverwege de reiniging', '01KVABG8HVD5Q9SZHB57R1V4QE.webp', 'ready', datetime('now')),
|
||||
('01KVABG8HY61YGK0E9153CQ3WT', 'dienst-voegwerk-1280.webp', 'image/webp', 158522, 1280, 956, 'Strak nieuw voegwerk tussen rode bakstenen', '01KVABG8HY61YGK0E9153CQ3WT.webp', 'ready', datetime('now')),
|
||||
('01KVABG8HYX533E7BA88787QC8', 'dienst-dakreiniging-1280.webp', 'image/webp', 147566, 1280, 956, 'Schoon dakpannendak na reiniging en coating', '01KVABG8HYX533E7BA88787QC8.webp', 'ready', datetime('now')),
|
||||
('01KVABG8HYSDJ42N6SXVH96KRA', 'dienst-schoorsteen-1280.webp', 'image/webp', 141238, 1280, 956, 'Herstelde schoorsteen met nieuw voegwerk op een dak', '01KVABG8HYSDJ42N6SXVH96KRA.webp', 'ready', datetime('now')),
|
||||
('01KVABG8HZQS2JPPZH7R6EZH2N', 'dienst-algemeen-1280.webp', 'image/webp', 283500, 1280, 956, 'Vers gereinigd terras en bestrating', '01KVABG8HZQS2JPPZH7R6EZH2N.webp', 'ready', datetime('now')),
|
||||
('01KVABG8HZ74N5V5ZM6JYVGNJH', 'hero-after-1280.webp', 'image/webp', 158222, 1280, 714, 'Schone bakstenen gevel na reiniging', '01KVABG8HZ74N5V5ZM6JYVGNJH.webp', 'ready', datetime('now')),
|
||||
('01KVABG8HZH5Y705NAS5B7FMEX', 'hero-gevel-1280.webp', 'image/webp', 192040, 1280, 714, 'Verzorgde, schone gevel van een woonhuis', '01KVABG8HZH5Y705NAS5B7FMEX.webp', 'ready', datetime('now')),
|
||||
('01KVABG8HZENXT71V00NY1M2N5', 'werk-1.webp', 'image/webp', 376578, 1200, 1600, 'Schoorsteen met nieuw aangebracht loodwerk op een dak', '01KVABG8HZENXT71V00NY1M2N5.webp', 'ready', datetime('now')),
|
||||
('01KVABG8HZSPH8YKM0GJC0WDE2', 'werk-2.webp', 'image/webp', 341744, 1600, 1200, 'Schone tuinmuur en bestrating na reiniging', '01KVABG8HZSPH8YKM0GJC0WDE2.webp', 'ready', datetime('now')),
|
||||
('01KVABG8HZN66T8KPTBWHQSFZS', 'werk-3.webp', 'image/webp', 181398, 738, 1600, 'Strak nieuw voegwerk tussen de bakstenen van een gevel', '01KVABG8HZN66T8KPTBWHQSFZS.webp', 'ready', datetime('now')),
|
||||
('01KVABG8HZ2H1TAB7E4J3QRCSM', 'werk-4.webp', 'image/webp', 614486, 1200, 1600, 'Dakpannendak vol mos voor de reiniging', '01KVABG8HZ2H1TAB7E4J3QRCSM.webp', 'ready', datetime('now')),
|
||||
('01KVABG8HZ9KFWV8604FYYSEAY', 'werk-5.webp', 'image/webp', 428016, 1200, 1600, 'Dak wordt op hoogte gereinigd', '01KVABG8HZ9KFWV8604FYYSEAY.webp', 'ready', datetime('now')),
|
||||
('01KVABG8HZV3AHQ5RQGVAKW29F', 'werk-6.webp', 'image/webp', 293814, 1200, 1600, 'Hersteld metsel- en voegwerk aan een tuinmuur met trap', '01KVABG8HZV3AHQ5RQGVAKW29F.webp', 'ready', datetime('now'));
|
||||
UPDATE ec_diensten SET nav_image='{"id":"01KVABG8HVD5Q9SZHB57R1V4QE","provider":"local","filename":"dienst-gevelreiniging-1280.webp","mimeType":"image/webp","width":1280,"height":956,"alt":"Bakstenen gevel halverwege de reiniging","meta":{"storageKey":"01KVABG8HVD5Q9SZHB57R1V4QE.webp"}}' WHERE slug='gevelreiniging';
|
||||
UPDATE ec_diensten SET hero_image='{"id":"01KVABG8HVD5Q9SZHB57R1V4QE","provider":"local","filename":"dienst-gevelreiniging-1280.webp","mimeType":"image/webp","width":1280,"height":956,"alt":"Bakstenen gevel halverwege de reiniging","meta":{"storageKey":"01KVABG8HVD5Q9SZHB57R1V4QE.webp"}}' WHERE slug='gevelreiniging';
|
||||
UPDATE ec_diensten SET result_1_image='{"id":"01KVABG8HZ74N5V5ZM6JYVGNJH","provider":"local","filename":"hero-after-1280.webp","mimeType":"image/webp","width":1280,"height":714,"alt":"Schone bakstenen gevel na reiniging","meta":{"storageKey":"01KVABG8HZ74N5V5ZM6JYVGNJH.webp"}}' WHERE slug='gevelreiniging';
|
||||
UPDATE ec_diensten SET result_2_image='{"id":"01KVABG8HY61YGK0E9153CQ3WT","provider":"local","filename":"dienst-voegwerk-1280.webp","mimeType":"image/webp","width":1280,"height":956,"alt":"Strak nieuw voegwerk tussen rode bakstenen","meta":{"storageKey":"01KVABG8HY61YGK0E9153CQ3WT.webp"}}' WHERE slug='gevelreiniging';
|
||||
UPDATE ec_diensten SET result_3_image='{"id":"01KVABG8HZH5Y705NAS5B7FMEX","provider":"local","filename":"hero-gevel-1280.webp","mimeType":"image/webp","width":1280,"height":714,"alt":"Verzorgde, schone gevel van een woonhuis","meta":{"storageKey":"01KVABG8HZH5Y705NAS5B7FMEX.webp"}}' WHERE slug='gevelreiniging';
|
||||
UPDATE ec_diensten SET nav_image='{"id":"01KVABG8HY61YGK0E9153CQ3WT","provider":"local","filename":"dienst-voegwerk-1280.webp","mimeType":"image/webp","width":1280,"height":956,"alt":"Strak nieuw voegwerk tussen rode bakstenen","meta":{"storageKey":"01KVABG8HY61YGK0E9153CQ3WT.webp"}}' WHERE slug='voegwerk';
|
||||
UPDATE ec_diensten SET hero_image='{"id":"01KVABG8HY61YGK0E9153CQ3WT","provider":"local","filename":"dienst-voegwerk-1280.webp","mimeType":"image/webp","width":1280,"height":956,"alt":"Strak nieuw voegwerk tussen rode bakstenen","meta":{"storageKey":"01KVABG8HY61YGK0E9153CQ3WT.webp"}}' WHERE slug='voegwerk';
|
||||
UPDATE ec_diensten SET result_1_image='{"id":"01KVABG8HZN66T8KPTBWHQSFZS","provider":"local","filename":"werk-3.webp","mimeType":"image/webp","width":738,"height":1600,"alt":"Strak nieuw voegwerk tussen de bakstenen van een gevel","meta":{"storageKey":"01KVABG8HZN66T8KPTBWHQSFZS.webp"}}' WHERE slug='voegwerk';
|
||||
UPDATE ec_diensten SET result_2_image='{"id":"01KVABG8HZV3AHQ5RQGVAKW29F","provider":"local","filename":"werk-6.webp","mimeType":"image/webp","width":1200,"height":1600,"alt":"Hersteld metsel- en voegwerk aan een tuinmuur met trap","meta":{"storageKey":"01KVABG8HZV3AHQ5RQGVAKW29F.webp"}}' WHERE slug='voegwerk';
|
||||
UPDATE ec_diensten SET result_3_image='{"id":"01KVABG8HZH5Y705NAS5B7FMEX","provider":"local","filename":"hero-gevel-1280.webp","mimeType":"image/webp","width":1280,"height":714,"alt":"Verzorgde, schone gevel van een woonhuis","meta":{"storageKey":"01KVABG8HZH5Y705NAS5B7FMEX.webp"}}' WHERE slug='voegwerk';
|
||||
UPDATE ec_diensten SET nav_image='{"id":"01KVABG8HYX533E7BA88787QC8","provider":"local","filename":"dienst-dakreiniging-1280.webp","mimeType":"image/webp","width":1280,"height":956,"alt":"Schoon dakpannendak na reiniging en coating","meta":{"storageKey":"01KVABG8HYX533E7BA88787QC8.webp"}}' WHERE slug='dakreiniging-coating';
|
||||
UPDATE ec_diensten SET hero_image='{"id":"01KVABG8HYX533E7BA88787QC8","provider":"local","filename":"dienst-dakreiniging-1280.webp","mimeType":"image/webp","width":1280,"height":956,"alt":"Schoon dakpannendak na reiniging en coating","meta":{"storageKey":"01KVABG8HYX533E7BA88787QC8.webp"}}' WHERE slug='dakreiniging-coating';
|
||||
UPDATE ec_diensten SET result_1_image='{"id":"01KVABG8HZ2H1TAB7E4J3QRCSM","provider":"local","filename":"werk-4.webp","mimeType":"image/webp","width":1200,"height":1600,"alt":"Dakpannendak vol mos voor de reiniging","meta":{"storageKey":"01KVABG8HZ2H1TAB7E4J3QRCSM.webp"}}' WHERE slug='dakreiniging-coating';
|
||||
UPDATE ec_diensten SET result_2_image='{"id":"01KVABG8HZ9KFWV8604FYYSEAY","provider":"local","filename":"werk-5.webp","mimeType":"image/webp","width":1200,"height":1600,"alt":"Dak wordt op hoogte gereinigd","meta":{"storageKey":"01KVABG8HZ9KFWV8604FYYSEAY.webp"}}' WHERE slug='dakreiniging-coating';
|
||||
UPDATE ec_diensten SET result_3_image='{"id":"01KVABG8HYX533E7BA88787QC8","provider":"local","filename":"dienst-dakreiniging-1280.webp","mimeType":"image/webp","width":1280,"height":956,"alt":"Schoon dakpannendak na reiniging en coating","meta":{"storageKey":"01KVABG8HYX533E7BA88787QC8.webp"}}' WHERE slug='dakreiniging-coating';
|
||||
UPDATE ec_diensten SET nav_image='{"id":"01KVABG8HYSDJ42N6SXVH96KRA","provider":"local","filename":"dienst-schoorsteen-1280.webp","mimeType":"image/webp","width":1280,"height":956,"alt":"Herstelde schoorsteen met nieuw voegwerk op een dak","meta":{"storageKey":"01KVABG8HYSDJ42N6SXVH96KRA.webp"}}' WHERE slug='schoorsteen-voegwerk';
|
||||
UPDATE ec_diensten SET hero_image='{"id":"01KVABG8HYSDJ42N6SXVH96KRA","provider":"local","filename":"dienst-schoorsteen-1280.webp","mimeType":"image/webp","width":1280,"height":956,"alt":"Herstelde schoorsteen met nieuw voegwerk op een dak","meta":{"storageKey":"01KVABG8HYSDJ42N6SXVH96KRA.webp"}}' WHERE slug='schoorsteen-voegwerk';
|
||||
UPDATE ec_diensten SET result_1_image='{"id":"01KVABG8HZENXT71V00NY1M2N5","provider":"local","filename":"werk-1.webp","mimeType":"image/webp","width":1200,"height":1600,"alt":"Schoorsteen met nieuw aangebracht loodwerk op een dak","meta":{"storageKey":"01KVABG8HZENXT71V00NY1M2N5.webp"}}' WHERE slug='schoorsteen-voegwerk';
|
||||
UPDATE ec_diensten SET result_2_image='{"id":"01KVABG8HZV3AHQ5RQGVAKW29F","provider":"local","filename":"werk-6.webp","mimeType":"image/webp","width":1200,"height":1600,"alt":"Hersteld metsel- en voegwerk aan een tuinmuur met trap","meta":{"storageKey":"01KVABG8HZV3AHQ5RQGVAKW29F.webp"}}' WHERE slug='schoorsteen-voegwerk';
|
||||
UPDATE ec_diensten SET result_3_image='{"id":"01KVABG8HYSDJ42N6SXVH96KRA","provider":"local","filename":"dienst-schoorsteen-1280.webp","mimeType":"image/webp","width":1280,"height":956,"alt":"Herstelde schoorsteen met nieuw voegwerk op een dak","meta":{"storageKey":"01KVABG8HYSDJ42N6SXVH96KRA.webp"}}' WHERE slug='schoorsteen-voegwerk';
|
||||
UPDATE ec_diensten SET nav_image='{"id":"01KVABG8HZQS2JPPZH7R6EZH2N","provider":"local","filename":"dienst-algemeen-1280.webp","mimeType":"image/webp","width":1280,"height":956,"alt":"Vers gereinigd terras en bestrating","meta":{"storageKey":"01KVABG8HZQS2JPPZH7R6EZH2N.webp"}}' WHERE slug='algemeen-reinigen';
|
||||
UPDATE ec_diensten SET hero_image='{"id":"01KVABG8HZQS2JPPZH7R6EZH2N","provider":"local","filename":"dienst-algemeen-1280.webp","mimeType":"image/webp","width":1280,"height":956,"alt":"Vers gereinigd terras en bestrating","meta":{"storageKey":"01KVABG8HZQS2JPPZH7R6EZH2N.webp"}}' WHERE slug='algemeen-reinigen';
|
||||
UPDATE ec_diensten SET result_1_image='{"id":"01KVABG8HZSPH8YKM0GJC0WDE2","provider":"local","filename":"werk-2.webp","mimeType":"image/webp","width":1600,"height":1200,"alt":"Schone tuinmuur en bestrating na reiniging","meta":{"storageKey":"01KVABG8HZSPH8YKM0GJC0WDE2.webp"}}' WHERE slug='algemeen-reinigen';
|
||||
UPDATE ec_diensten SET result_2_image='{"id":"01KVABG8HZV3AHQ5RQGVAKW29F","provider":"local","filename":"werk-6.webp","mimeType":"image/webp","width":1200,"height":1600,"alt":"Hersteld metsel- en voegwerk aan een tuinmuur met trap","meta":{"storageKey":"01KVABG8HZV3AHQ5RQGVAKW29F.webp"}}' WHERE slug='algemeen-reinigen';
|
||||
UPDATE ec_diensten SET result_3_image='{"id":"01KVABG8HZQS2JPPZH7R6EZH2N","provider":"local","filename":"dienst-algemeen-1280.webp","mimeType":"image/webp","width":1280,"height":956,"alt":"Vers gereinigd terras en bestrating","meta":{"storageKey":"01KVABG8HZQS2JPPZH7R6EZH2N.webp"}}' WHERE slug='algemeen-reinigen';
|
||||
@@ -0,0 +1,32 @@
|
||||
-- Nieuwe hero-card-varianten in de mediabibliotheek.
|
||||
INSERT OR REPLACE INTO media (id, filename, mime_type, size, width, height, alt, storage_key, status, created_at) VALUES
|
||||
('01KVABVVPZA6FYGXQYPH0SGJP1', 'werk-1-768.webp', 'image/webp', 172718, 768, 1024, 'Herstelde schoorsteen met nieuw loodwerk op een dak', '01KVABVVPZA6FYGXQYPH0SGJP1.webp', 'ready', datetime('now')),
|
||||
('01KVABVVQ0DE1QX42SJ0SD44W8', 'werk-5-768.webp', 'image/webp', 191256, 768, 1024, 'Dak wordt op hoogte gereinigd', '01KVABVVQ0DE1QX42SJ0SD44W8.webp', 'ready', datetime('now'));
|
||||
|
||||
-- Auto-gegenereerd door scripts/gen-cms-sql.mjs — NIET met de hand bewerken.
|
||||
|
||||
-- ===== collectie: hero_cards (Homepage · hero-foto's) =====
|
||||
INSERT INTO _emdash_collections (id, slug, label, label_singular, supports, source, has_seo) VALUES ('01KVABVVQ1NR3K9F9WKSRESQS2', 'hero_cards', 'Homepage · hero-foto''s', 'Hero-foto', '["drafts"]', 'seed', 0);
|
||||
CREATE TABLE "ec_hero_cards" ("id" text primary key, "slug" text, "status" text default 'draft', "author_id" text, "primary_byline_id" text, "created_at" text default (datetime('now')), "updated_at" text default (datetime('now')), "published_at" text, "scheduled_at" text, "deleted_at" text, "version" integer default 1, "live_revision_id" text references "revisions" ("id"), "draft_revision_id" text references "revisions" ("id"), "locale" text default 'en' not null, "translation_group" text, "sort" INTEGER, "image" TEXT, "title" TEXT NOT NULL DEFAULT '', "sub" TEXT, "alt" TEXT, constraint "ec_hero_cards_slug_locale_unique" unique ("slug", "locale"));
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KVABVVQ122ME8J05N5KVVN6Y', '01KVABVVQ1NR3K9F9WKSRESQS2', 'sort', 'Volgorde', 'integer', 'INTEGER', 0, 0, 0, 1);
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KVABVVQ16T3Y8AGE0CC5WDMN', '01KVABVVQ1NR3K9F9WKSRESQS2', 'image', 'Foto', 'image', 'TEXT', 0, 1, 0, 1);
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KVABVVQ16JSEANWCYPSXYZFR', '01KVABVVQ1NR3K9F9WKSRESQS2', 'title', 'Titel (op de foto)', 'string', 'TEXT', 1, 2, 0, 1);
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KVABVVQ1ECG3EWNNCBJBQ9GS', '01KVABVVQ1NR3K9F9WKSRESQS2', 'sub', 'Ondertitel (op de foto)', 'string', 'TEXT', 0, 3, 0, 1);
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KVABVVQ1RZHRW4SS4BFQCP0C', '01KVABVVQ1NR3K9F9WKSRESQS2', 'alt', 'Alt-tekst', 'string', 'TEXT', 0, 4, 0, 1);
|
||||
INSERT INTO "ec_hero_cards" ("id", "slug", "status", "locale", "sort", "image", "title", "sub", "alt") VALUES ('01KVABVVQ11RQWC684MBJ6WBYK', 'card-1', 'published', 'en', 1, '{"id":"01KVABG8HZN66T8KPTBWHQSFZS","provider":"local","filename":"werk-3.webp","mimeType":"image/webp","width":738,"height":1600,"alt":"Strak nieuw voegwerk tussen bakstenen van een gevel","meta":{"storageKey":"01KVABG8HZN66T8KPTBWHQSFZS.webp"}}', 'Voegwerk', 'gevel hersteld', 'Strak nieuw voegwerk tussen bakstenen van een gevel');
|
||||
INSERT INTO "ec_hero_cards" ("id", "slug", "status", "locale", "sort", "image", "title", "sub", "alt") VALUES ('01KVABVVQ1FDVGKZ9D3WMGH5VN', 'card-2', 'published', 'en', 2, '{"id":"01KVABVVPZA6FYGXQYPH0SGJP1","provider":"local","filename":"werk-1-768.webp","mimeType":"image/webp","width":768,"height":1024,"alt":"Herstelde schoorsteen met nieuw loodwerk op een dak","meta":{"storageKey":"01KVABVVPZA6FYGXQYPH0SGJP1.webp"}}', 'Schoorsteen', 'nieuw loodwerk', 'Herstelde schoorsteen met nieuw loodwerk op een dak');
|
||||
INSERT INTO "ec_hero_cards" ("id", "slug", "status", "locale", "sort", "image", "title", "sub", "alt") VALUES ('01KVABVVQ1F6R1AX4EQEVX3NY8', 'card-3', 'published', 'en', 3, '{"id":"01KVABVVQ0DE1QX42SJ0SD44W8","provider":"local","filename":"werk-5-768.webp","mimeType":"image/webp","width":768,"height":1024,"alt":"Dak wordt op hoogte gereinigd","meta":{"storageKey":"01KVABVVQ0DE1QX42SJ0SD44W8.webp"}}', 'Dakreiniging', 'op hoogte', 'Dak wordt op hoogte gereinigd');
|
||||
|
||||
-- ===== collectie: galerij (Homepage · bewijs-galerij) =====
|
||||
INSERT INTO _emdash_collections (id, slug, label, label_singular, supports, source, has_seo) VALUES ('01KVABVVQ14SQR0WHZTH7WFDMZ', 'galerij', 'Homepage · bewijs-galerij', 'Galerij-foto', '["drafts"]', 'seed', 0);
|
||||
CREATE TABLE "ec_galerij" ("id" text primary key, "slug" text, "status" text default 'draft', "author_id" text, "primary_byline_id" text, "created_at" text default (datetime('now')), "updated_at" text default (datetime('now')), "published_at" text, "scheduled_at" text, "deleted_at" text, "version" integer default 1, "live_revision_id" text references "revisions" ("id"), "draft_revision_id" text references "revisions" ("id"), "locale" text default 'en' not null, "translation_group" text, "sort" INTEGER, "image" TEXT, "caption" TEXT NOT NULL DEFAULT '', "featured" INTEGER, constraint "ec_galerij_slug_locale_unique" unique ("slug", "locale"));
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KVABVVQ2XMM41D22G827XSK1', '01KVABVVQ14SQR0WHZTH7WFDMZ', 'sort', 'Volgorde', 'integer', 'INTEGER', 0, 0, 0, 1);
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KVABVVQ2EHPAYE6W6MGDH97V', '01KVABVVQ14SQR0WHZTH7WFDMZ', 'image', 'Foto', 'image', 'TEXT', 0, 1, 0, 1);
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KVABVVQ2EW3FW6JR4KHXZ99F', '01KVABVVQ14SQR0WHZTH7WFDMZ', 'caption', 'Bijschrift', 'string', 'TEXT', 1, 2, 0, 1);
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KVABVVQ2VAGD68C1JR9TJS8H', '01KVABVVQ14SQR0WHZTH7WFDMZ', 'featured', 'Uitgelicht (grote tegel)', 'boolean', 'INTEGER', 0, 3, 0, 1);
|
||||
INSERT INTO "ec_galerij" ("id", "slug", "status", "locale", "sort", "image", "caption", "featured") VALUES ('01KVABVVQ2YS6NB5BR9QP49G21', 'g-1', 'published', 'en', 1, '{"id":"01KVABG8HZN66T8KPTBWHQSFZS","provider":"local","filename":"werk-3.webp","mimeType":"image/webp","width":738,"height":1600,"alt":"Voegwerk aan de gevel","meta":{"storageKey":"01KVABG8HZN66T8KPTBWHQSFZS.webp"}}', 'Voegwerk aan de gevel', 1);
|
||||
INSERT INTO "ec_galerij" ("id", "slug", "status", "locale", "sort", "image", "caption", "featured") VALUES ('01KVABVVQ2CARDWEFR2S7527QT', 'g-2', 'published', 'en', 2, '{"id":"01KVABG8HZENXT71V00NY1M2N5","provider":"local","filename":"werk-1.webp","mimeType":"image/webp","width":1200,"height":1600,"alt":"Schoorsteen met nieuw loodwerk","meta":{"storageKey":"01KVABG8HZENXT71V00NY1M2N5.webp"}}', 'Schoorsteen met nieuw loodwerk', 0);
|
||||
INSERT INTO "ec_galerij" ("id", "slug", "status", "locale", "sort", "image", "caption", "featured") VALUES ('01KVABVVQ2R2CFFNSVST3RKW1E', 'g-3', 'published', 'en', 3, '{"id":"01KVABG8HZ2H1TAB7E4J3QRCSM","provider":"local","filename":"werk-4.webp","mimeType":"image/webp","width":1200,"height":1600,"alt":"Dak vol mos, voor reiniging","meta":{"storageKey":"01KVABG8HZ2H1TAB7E4J3QRCSM.webp"}}', 'Dak vol mos, voor reiniging', 0);
|
||||
INSERT INTO "ec_galerij" ("id", "slug", "status", "locale", "sort", "image", "caption", "featured") VALUES ('01KVABVVQ20N5N5YNM50TK2991', 'g-4', 'published', 'en', 4, '{"id":"01KVABG8HZSPH8YKM0GJC0WDE2","provider":"local","filename":"werk-2.webp","mimeType":"image/webp","width":1600,"height":1200,"alt":"Tuinmuur en bestrating","meta":{"storageKey":"01KVABG8HZSPH8YKM0GJC0WDE2.webp"}}', 'Tuinmuur en bestrating', 0);
|
||||
INSERT INTO "ec_galerij" ("id", "slug", "status", "locale", "sort", "image", "caption", "featured") VALUES ('01KVABVVQ2K99A0XS85TRWPD9Q', 'g-5', 'published', 'en', 5, '{"id":"01KVABG8HZ9KFWV8604FYYSEAY","provider":"local","filename":"werk-5.webp","mimeType":"image/webp","width":1200,"height":1600,"alt":"Dakreiniging op hoogte","meta":{"storageKey":"01KVABG8HZ9KFWV8604FYYSEAY.webp"}}', 'Dakreiniging op hoogte', 0);
|
||||
INSERT INTO "ec_galerij" ("id", "slug", "status", "locale", "sort", "image", "caption", "featured") VALUES ('01KVABVVQ2Z0YDXZB31X4TQ10Y', 'g-6', 'published', 'en', 6, '{"id":"01KVABG8HZV3AHQ5RQGVAKW29F","provider":"local","filename":"werk-6.webp","mimeType":"image/webp","width":1200,"height":1600,"alt":"Metselwerk, tuintrap","meta":{"storageKey":"01KVABG8HZV3AHQ5RQGVAKW29F.webp"}}', 'Metselwerk, tuintrap', 0);
|
||||
@@ -0,0 +1,30 @@
|
||||
-- Voegt een "Embedcode / scripts"-veld toe aan de bestaande collectie `paginas`.
|
||||
-- Doel: de SEO-/cookie-partij kan op een gewone CMS-pagina (bv. /cookieverklaring)
|
||||
-- een extern script of HTML-embed plakken dat 1-op-1 in de pagina-body wordt geladen
|
||||
-- en daadwerkelijk DRAAIT. Dit kan NIET via het normale inhoud-veld: PortableText
|
||||
-- escapet HTML (je ziet de code als tekst), een "Code"-blok toont code bewust als code,
|
||||
-- en het rauwe htmlBlock filtert <script> eruit (sanitize-html). Dit veld omzeilt dat
|
||||
-- bewust: de route rendert het via `set:html`, zonder sanitatie. Alleen vertrouwde
|
||||
-- admins bewerken het CMS, dus dat is verantwoord (WordPress "custom HTML"-patroon).
|
||||
--
|
||||
-- `paginas` bestaat al (geseed via de wizard) → ALTER TABLE + nieuwe _emdash_fields-rij,
|
||||
-- NIET via gen-cms-sql (dat maakt nieuwe tabellen). Toepassen op remote D1:
|
||||
-- node node_modules/wrangler/bin/wrangler.js d1 execute helvoirt-db --remote --file migrations/0013_paginas_embed.sql
|
||||
|
||||
ALTER TABLE "ec_paginas" ADD COLUMN "embed_code" TEXT;
|
||||
|
||||
INSERT INTO _emdash_fields
|
||||
(id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable, widget)
|
||||
VALUES (
|
||||
'01KVT537A3104R76DHMAV3WS5G',
|
||||
(SELECT id FROM _emdash_collections WHERE slug = 'paginas'),
|
||||
'embed_code',
|
||||
'Embedcode / scripts (geavanceerd: HTML/JS, bv. cookieverklaring)',
|
||||
'text',
|
||||
'TEXT',
|
||||
0,
|
||||
8,
|
||||
0,
|
||||
0,
|
||||
'textarea'
|
||||
);
|
||||
@@ -0,0 +1,38 @@
|
||||
-- FIX "Failed to publish" op locatie- en gewone pagina's (collecties `locaties` + `paginas`).
|
||||
--
|
||||
-- OORZAAK (deterministisch, geverifieerd): de setup-wizard maakte voor deze twee collecties
|
||||
-- een FTS5-zoekindex aan als EXTERNE-CONTENT-tabel (`content='ec_locaties'`), met sync-triggers
|
||||
-- op INSERT/UPDATE/DELETE. De UPDATE-trigger doet `DELETE FROM _emdash_fts_* WHERE rowid=OLD.rowid`.
|
||||
-- Bij een externe-content FTS5-tabel herberekent die DELETE de oude termen door de bron-tabel te
|
||||
-- lezen — maar tijdens AFTER UPDATE is die rij AL bijgewerkt, dus de index klopt niet meer en
|
||||
-- SQLite gooit "database disk image is malformed". De hele UPDATE rolt terug.
|
||||
--
|
||||
-- Publiceren roept intern `syncDataColumns` aan = `UPDATE ec_locaties SET <kolommen>` → trigger
|
||||
-- vuurt → fout → status wordt nooit 'published'. (De /maastricht-pagina staat alleen live omdat
|
||||
-- die als 'published' is GESEED via een INSERT, dus nooit het UPDATE-pad raakte.) Lokaal 1-op-1
|
||||
-- gereproduceerd met de exacte trigger-DDL: INSERT lukt, UPDATE faalt.
|
||||
--
|
||||
-- FIX: maak deze collecties non-searchable — net als alle ZELF-gegenereerde collecties
|
||||
-- (over_ons/diensten/homepage/contact_page/voorwaarden), die bewust geen FTS hebben en wél
|
||||
-- publiceren. We droppen de triggers + de FTS-virtualtabellen (incl. shadow-tabellen) en zetten
|
||||
-- searchable=0. De frontend gebruikt geen full-text-search (routes halen op via slug); alleen de
|
||||
-- admin-zoekbalk voor deze twee collecties valt weg. Pure DB-wijziging → geen redeploy nodig.
|
||||
--
|
||||
-- Toepassen op remote D1:
|
||||
-- node node_modules/wrangler/bin/wrangler.js d1 execute helvoirt-db --remote --file migrations/0014_fix_publish_drop_fts.sql
|
||||
|
||||
DROP TRIGGER IF EXISTS "_emdash_fts_locaties_insert";
|
||||
DROP TRIGGER IF EXISTS "_emdash_fts_locaties_update";
|
||||
DROP TRIGGER IF EXISTS "_emdash_fts_locaties_delete";
|
||||
DROP TABLE IF EXISTS "_emdash_fts_locaties";
|
||||
|
||||
DROP TRIGGER IF EXISTS "_emdash_fts_paginas_insert";
|
||||
DROP TRIGGER IF EXISTS "_emdash_fts_paginas_update";
|
||||
DROP TRIGGER IF EXISTS "_emdash_fts_paginas_delete";
|
||||
DROP TABLE IF EXISTS "_emdash_fts_paginas";
|
||||
|
||||
UPDATE _emdash_fields
|
||||
SET searchable = 0
|
||||
WHERE collection_id IN (
|
||||
SELECT id FROM _emdash_collections WHERE slug IN ('locaties', 'paginas')
|
||||
);
|
||||
@@ -0,0 +1,56 @@
|
||||
-- Rijkere opmaak voor locatiepagina's (collectie `locaties`): een optionele hero-foto,
|
||||
-- 4 vaste content-blokken (kop + alinea + optionele foto, om-en-om getoond) en een
|
||||
-- aanpasbare CTA-balk. Alles optioneel en door de SEO-partij zelf in te vullen; leeg = wordt
|
||||
-- niet getoond. Het bestaande veld `content` blijft bestaan en verschuift naar onderaan
|
||||
-- ("Extra lokale tekst"), zodat bestaande tekst (o.a. /maastricht) niet verloren gaat.
|
||||
--
|
||||
-- `locaties` bestaat al (geseed via de wizard) → ALTER TABLE + nieuwe _emdash_fields-rijen,
|
||||
-- NIET via gen-cms-sql (dat maakt nieuwe tabellen). Veilig dankzij 0014: de kapotte FTS-triggers
|
||||
-- op ec_locaties zijn weg, dus ALTER/UPDATE op deze tabel werkt nu probleemloos.
|
||||
-- Toepassen op remote D1:
|
||||
-- node node_modules/wrangler/bin/wrangler.js d1 execute helvoirt-db --remote --file migrations/0015_locaties_layout.sql
|
||||
|
||||
-- ---- 1. Nieuwe kolommen op de content-tabel ----
|
||||
ALTER TABLE "ec_locaties" ADD COLUMN "hero_image" TEXT;
|
||||
ALTER TABLE "ec_locaties" ADD COLUMN "blok1_heading" TEXT;
|
||||
ALTER TABLE "ec_locaties" ADD COLUMN "blok1_text" TEXT;
|
||||
ALTER TABLE "ec_locaties" ADD COLUMN "blok1_image" TEXT;
|
||||
ALTER TABLE "ec_locaties" ADD COLUMN "blok2_heading" TEXT;
|
||||
ALTER TABLE "ec_locaties" ADD COLUMN "blok2_text" TEXT;
|
||||
ALTER TABLE "ec_locaties" ADD COLUMN "blok2_image" TEXT;
|
||||
ALTER TABLE "ec_locaties" ADD COLUMN "blok3_heading" TEXT;
|
||||
ALTER TABLE "ec_locaties" ADD COLUMN "blok3_text" TEXT;
|
||||
ALTER TABLE "ec_locaties" ADD COLUMN "blok3_image" TEXT;
|
||||
ALTER TABLE "ec_locaties" ADD COLUMN "blok4_heading" TEXT;
|
||||
ALTER TABLE "ec_locaties" ADD COLUMN "blok4_text" TEXT;
|
||||
ALTER TABLE "ec_locaties" ADD COLUMN "blok4_image" TEXT;
|
||||
ALTER TABLE "ec_locaties" ADD COLUMN "cta_heading" TEXT;
|
||||
ALTER TABLE "ec_locaties" ADD COLUMN "cta_text" TEXT;
|
||||
|
||||
-- ---- 2. Bestaande velden herordenen + `content` herlabelen (schuift naar onderaan) ----
|
||||
UPDATE _emdash_fields SET sort_order = 18,
|
||||
label = 'Extra lokale tekst onderaan (optioneel — vrije tekst met koppen/lijsten)'
|
||||
WHERE collection_id = '01KV7XX3NQG57PFRGRDRB5571M' AND slug = 'content';
|
||||
UPDATE _emdash_fields SET sort_order = 19 WHERE collection_id = '01KV7XX3NQG57PFRGRDRB5571M' AND slug = 'meta_title';
|
||||
UPDATE _emdash_fields SET sort_order = 20 WHERE collection_id = '01KV7XX3NQG57PFRGRDRB5571M' AND slug = 'meta_description';
|
||||
UPDATE _emdash_fields SET sort_order = 21 WHERE collection_id = '01KV7XX3NQG57PFRGRDRB5571M' AND slug = 'canonical';
|
||||
UPDATE _emdash_fields SET sort_order = 22 WHERE collection_id = '01KV7XX3NQG57PFRGRDRB5571M' AND slug = 'og_image';
|
||||
UPDATE _emdash_fields SET sort_order = 23 WHERE collection_id = '01KV7XX3NQG57PFRGRDRB5571M' AND slug = 'noindex';
|
||||
|
||||
-- ---- 3. Velddefinities voor de nieuwe velden (widget NULL = emdash kiest op type) ----
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable, widget) VALUES
|
||||
('01KVYY23F7VXW2GD6YJTKQAGXZ', '01KV7XX3NQG57PFRGRDRB5571M', 'hero_image', 'Hero-foto bovenaan (optioneel — leeg = donkere balk zonder foto)', 'image', 'TEXT', 0, 3, 0, 1, NULL),
|
||||
('01KVYY23F82AW73NMKM3GD1W95', '01KV7XX3NQG57PFRGRDRB5571M', 'blok1_heading', 'Blok 1 · kop', 'string', 'TEXT', 0, 4, 0, 1, NULL),
|
||||
('01KVYY23F83WB37RTYF78W1YM7', '01KV7XX3NQG57PFRGRDRB5571M', 'blok1_text', 'Blok 1 · tekst (alinea)', 'text', 'TEXT', 0, 5, 0, 1, NULL),
|
||||
('01KVYY23F8BZNNMG8JZWD9ZYHV', '01KV7XX3NQG57PFRGRDRB5571M', 'blok1_image', 'Blok 1 · foto (optioneel)', 'image', 'TEXT', 0, 6, 0, 1, NULL),
|
||||
('01KVYY23F819SWRPR7MJPGQWNP', '01KV7XX3NQG57PFRGRDRB5571M', 'blok2_heading', 'Blok 2 · kop', 'string', 'TEXT', 0, 7, 0, 1, NULL),
|
||||
('01KVYY23F85JM1HRRGHPXNM17Y', '01KV7XX3NQG57PFRGRDRB5571M', 'blok2_text', 'Blok 2 · tekst (alinea)', 'text', 'TEXT', 0, 8, 0, 1, NULL),
|
||||
('01KVYY23F9ZRMGK2R3YCASMMSC', '01KV7XX3NQG57PFRGRDRB5571M', 'blok2_image', 'Blok 2 · foto (optioneel)', 'image', 'TEXT', 0, 9, 0, 1, NULL),
|
||||
('01KVYY23F93PJ841HQMT77QM77', '01KV7XX3NQG57PFRGRDRB5571M', 'blok3_heading', 'Blok 3 · kop', 'string', 'TEXT', 0, 10, 0, 1, NULL),
|
||||
('01KVYY23F9EES7PKTNS6PDQA4F', '01KV7XX3NQG57PFRGRDRB5571M', 'blok3_text', 'Blok 3 · tekst (alinea)', 'text', 'TEXT', 0, 11, 0, 1, NULL),
|
||||
('01KVYY23F9ES6G9CKCHC1J66MB', '01KV7XX3NQG57PFRGRDRB5571M', 'blok3_image', 'Blok 3 · foto (optioneel)', 'image', 'TEXT', 0, 12, 0, 1, NULL),
|
||||
('01KVYY23F9AAY29MV7JSBE02DF', '01KV7XX3NQG57PFRGRDRB5571M', 'blok4_heading', 'Blok 4 · kop', 'string', 'TEXT', 0, 13, 0, 1, NULL),
|
||||
('01KVYY23F93FQBK8XJVY34T59D', '01KV7XX3NQG57PFRGRDRB5571M', 'blok4_text', 'Blok 4 · tekst (alinea)', 'text', 'TEXT', 0, 14, 0, 1, NULL),
|
||||
('01KVYY23F9FCECQYJJA1XDEJN3', '01KV7XX3NQG57PFRGRDRB5571M', 'blok4_image', 'Blok 4 · foto (optioneel)', 'image', 'TEXT', 0, 15, 0, 1, NULL),
|
||||
('01KVYY23F98SHEN873B4GJMM0A', '01KV7XX3NQG57PFRGRDRB5571M', 'cta_heading', 'CTA-balk · kop (optioneel — blauwe oproepbalk)', 'string', 'TEXT', 0, 16, 0, 1, NULL),
|
||||
('01KVYY23F9D7QBQPF0PQRC54MZ', '01KV7XX3NQG57PFRGRDRB5571M', 'cta_text', 'CTA-balk · tekst (optioneel)', 'text', 'TEXT', 0, 17, 0, 1, NULL);
|
||||
@@ -0,0 +1,23 @@
|
||||
-- Fix de "Bekijk"-link (en auto-redirects) in de CMS-admin.
|
||||
--
|
||||
-- Probleem: emdash bouwt de publieke URL van een item standaard als `/{collectie}/{slug}`
|
||||
-- (bv. /locaties/maastricht). Onze locatie- en gewone pagina's draaien echter op de ROOT
|
||||
-- (`/maastricht`, /<slug>) via src/pages/[...slug].astro. Daardoor wees het "Bekijk"-oogje
|
||||
-- naar een 404-URL.
|
||||
--
|
||||
-- emdash heeft per collectie een `url_pattern` met {slug}-placeholder (registry leest
|
||||
-- `row.url_pattern`; leeg = de `/{collectie}/{slug}`-fallback). We zetten het juiste patroon
|
||||
-- per collectie zodat de admin-link én redirect-generatie (bij slug-wijziging) kloppen.
|
||||
-- Pure D1-wijziging: de manifest-API leest dit live → werkt na een herlaad van de admin,
|
||||
-- geen redeploy nodig. De publieke routing zelf gebruikt url_pattern niet (eigen Astro-routes).
|
||||
--
|
||||
-- Toepassen op remote D1:
|
||||
-- node node_modules/wrangler/bin/wrangler.js d1 execute helvoirt-db --remote --file migrations/0016_collection_url_patterns.sql
|
||||
|
||||
UPDATE _emdash_collections SET url_pattern = '/{slug}' WHERE slug = 'locaties';
|
||||
UPDATE _emdash_collections SET url_pattern = '/{slug}' WHERE slug = 'paginas';
|
||||
UPDATE _emdash_collections SET url_pattern = '/diensten/{slug}' WHERE slug = 'diensten';
|
||||
UPDATE _emdash_collections SET url_pattern = '/' WHERE slug = 'homepage';
|
||||
UPDATE _emdash_collections SET url_pattern = '/over-ons' WHERE slug = 'over_ons';
|
||||
UPDATE _emdash_collections SET url_pattern = '/contact' WHERE slug = 'contact_page';
|
||||
UPDATE _emdash_collections SET url_pattern = '/voorwaarden' WHERE slug = 'voorwaarden';
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Verwijder het veld "Extra lokale tekst onderaan" (slug `content`) van de collectie
|
||||
-- `locaties` — op klantverzoek (de locatiepagina's gebruiken de 4 blokken voor de inhoud;
|
||||
-- het losse tekstveld gaf onderaan een lege grijze balk en is overbodig).
|
||||
--
|
||||
-- Alleen de VELDDEFINITIE wordt verwijderd (admin toont het veld dan niet meer). De KOLOM
|
||||
-- `ec_locaties.content` laten we staan (data-behoud; ongebruikt, geen risico, en SQLite/D1
|
||||
-- DROP COLUMN is rommelig). De frontend rendert het al niet meer (zie [...slug].astro).
|
||||
-- Géén wijziging aan de collectie `paginas` — gewone pagina's houden hun inhoud-veld.
|
||||
--
|
||||
-- Toepassen op remote D1:
|
||||
-- node node_modules/wrangler/bin/wrangler.js d1 execute helvoirt-db --remote --file migrations/0017_locaties_remove_content.sql
|
||||
|
||||
DELETE FROM _emdash_fields
|
||||
WHERE slug = 'content'
|
||||
AND collection_id = (SELECT id FROM _emdash_collections WHERE slug = 'locaties');
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Verwijder de CTA-balk-velden (slug `cta_heading`, `cta_text`) van de collectie `locaties`.
|
||||
-- Op klantverzoek: de blauwe "Klaar om je gevel in {plaats} ..."-balk is van de locatiepagina's
|
||||
-- gehaald (er staat direct daaronder al het offerteformulier, dus de balk was dubbelop).
|
||||
--
|
||||
-- Alleen de velddefinities weg (admin toont ze niet meer); kolommen blijven staan (data-behoud,
|
||||
-- ongebruikt). De frontend rendert de balk al niet meer (zie [...slug].astro).
|
||||
--
|
||||
-- Toepassen op remote D1:
|
||||
-- node node_modules/wrangler/bin/wrangler.js d1 execute helvoirt-db --remote --file migrations/0018_locaties_remove_cta.sql
|
||||
|
||||
DELETE FROM _emdash_fields
|
||||
WHERE slug IN ('cta_heading', 'cta_text')
|
||||
AND collection_id = (SELECT id FROM _emdash_collections WHERE slug = 'locaties');
|
||||
@@ -0,0 +1,28 @@
|
||||
-- Maak de 4 content-blokken op locatiepagina's RICH TEXT (PortableText): kop blijft een los
|
||||
-- veld, maar 'Blok N - tekst' wordt een editor met opsommingen (lijsten), interne links, vet etc.
|
||||
-- Klantverzoek: opsommingen/stappen + interne linkstructuur waren niet mogelijk in het platte
|
||||
-- tekstveld. De admin-PortableTextEditor heeft een toolbar (links, bullet/genummerde lijsten);
|
||||
-- de frontend rendert via <PortableText> (astro-portabletext) als <ul>/<ol>/<a>, met .prose-opmaak.
|
||||
--
|
||||
-- Bestaande platte tekst wordt hieronder omgezet naar PortableText (paragrafen op lege regels,
|
||||
-- '- ' -> bullets, 'N. ' -> genummerde lijst, <a href> -> echte links). Daarna verandert het
|
||||
-- veldtype text->portableText (column_type JSON). Pure D1; geen FTS-triggers (al weg in 0014).
|
||||
-- node node_modules/wrangler/bin/wrangler.js d1 execute helvoirt-db --remote --file migrations/0019_locaties_blocks_portabletext.sql
|
||||
|
||||
UPDATE ec_locaties SET blok1_text = '[{"_type":"block","_key":"k1ecd3","style":"normal","markDefs":[],"children":[{"_type":"span","_key":"k0sp7x","text":"Korte lijnen, vakwerk en een eerlijke prijs vooraf. We komen langs, denken mee en leveren werk waar je trots op bent, voor particulieren en bedrijven.","marks":[]}]}]' WHERE id = '01KV7XX5TWF8EAJ7DX0HGSKVC0';
|
||||
UPDATE ec_locaties SET blok2_text = '[{"_type":"block","_key":"k3maii","style":"normal","markDefs":[],"children":[{"_type":"span","_key":"k2th3t","text":"Gevelreiniging en renovatie, voegwerkzaamheden, dak reinigen en coaten, schoorsteen-voegwerk en algemeen reinigen van terras, oprit en bestrating.","marks":[]}]}]' WHERE id = '01KV7XX5TWF8EAJ7DX0HGSKVC0';
|
||||
UPDATE ec_locaties SET blok1_text = '[{"_type":"block","_key":"k5o2lj","style":"normal","markDefs":[],"children":[{"_type":"span","_key":"k4zsf3","text":"Korte lijnen, vakwerk en een eerlijke prijs vooraf. We komen langs, denken mee en leveren werk waar je trots op bent, voor particulieren en bedrijven.","marks":[]}]}]' WHERE id = '01KVZ43P5KXEAH5E7045YTGRTY';
|
||||
UPDATE ec_locaties SET blok2_text = '[{"_type":"block","_key":"k7gf3p","style":"normal","markDefs":[],"children":[{"_type":"span","_key":"k6xg4f","text":"Gevelreiniging en renovatie, voegwerkzaamheden, dak reinigen en coaten, schoorsteen-voegwerk en algemeen reinigen van terras, oprit en bestrating.","marks":[]}]}]' WHERE id = '01KVZ43P5KXEAH5E7045YTGRTY';
|
||||
UPDATE ec_locaties SET blok1_text = '[{"_type":"block","_key":"k9z6yv","style":"normal","markDefs":[],"children":[{"_type":"span","_key":"k84jja","text":"Korte lijnen, vakwerk en een eerlijke prijs vooraf. We komen langs, denken mee en leveren werk waar je trots op bent, voor particulieren en bedrijven.","marks":[]}]}]' WHERE id = '01KVZ4Y19XZ0M45HAW9Q7000P0';
|
||||
UPDATE ec_locaties SET blok2_text = '[{"_type":"block","_key":"kb5127","style":"normal","markDefs":[],"children":[{"_type":"span","_key":"kawyeo","text":"Gevelreiniging en renovatie, voegwerkzaamheden, dak reinigen en coaten, schoorsteen-voegwerk en algemeen reinigen van terras, oprit en bestrating.","marks":[]}]}]' WHERE id = '01KVZ4Y19XZ0M45HAW9Q7000P0';
|
||||
UPDATE ec_locaties SET blok1_text = '[{"_type":"block","_key":"kdbn5z","style":"normal","markDefs":[],"children":[{"_type":"span","_key":"kcw54e","text":"Korte lijnen, vakwerk en een eerlijke prijs vooraf. We komen langs, denken mee en leveren werk waar je trots op bent, voor particulieren en bedrijven.","marks":[]}]}]' WHERE id = '01KVZ5F8E95181DHV3Y9ZPN0YJ';
|
||||
UPDATE ec_locaties SET blok2_text = '[{"_type":"block","_key":"kf95pu","style":"normal","markDefs":[],"children":[{"_type":"span","_key":"ke1aj8","text":"Gevelreiniging en renovatie, voegwerkzaamheden, dak reinigen en coaten, schoorsteen-voegwerk en algemeen reinigen van terras, oprit en bestrating.","marks":[]}]}]' WHERE id = '01KVZ5F8E95181DHV3Y9ZPN0YJ';
|
||||
UPDATE ec_locaties SET blok1_text = '[{"_type":"block","_key":"kkdjh3","style":"normal","markDefs":[{"_type":"link","_key":"khz58n","href":"https://helvoirtgevelreiniging.nl/diensten/voegwerk"}],"children":[{"_type":"span","_key":"kgoxzo","text":"Een vieze of beschadigde gevel lijkt misschien vooral een kwestie van uitstraling, maar er speelt vaak meer. Aanslag, mos, algen en oud ","marks":[]},{"_type":"span","_key":"kip69q","text":"voegwerk ","marks":["khz58n"]},{"_type":"span","_key":"kjjcmy","text":" houden vocht vast. Daardoor kunnen stenen, voegen en andere delen van de buitenkant langzaam achteruitgaan. Met goede gevelrenovatie in Limburg zorg je dus niet alleen voor een frisser huis of bedrijfspand, maar ook voor bescherming op de lange termijn.","marks":[]}]},{"_type":"block","_key":"km15yo","style":"normal","markDefs":[],"children":[{"_type":"span","_key":"klpxy6","text":"Helvoirt Gevelreiniging kiest altijd de aanpak die past bij jouw gevel. Niet iedere steen kan dezelfde behandeling hebben en niet iedere vervuiling vraagt om dezelfde methode. Soms is voorzichtig reinigen genoeg, soms moet beschadigd voegwerk worden hersteld en soms is impregneren een slimme extra stap. We kijken naar de staat van de gevel, geven eerlijk advies en zorgen dat je vooraf weet waar je aan toe bent. Als ervaren voegbedrijf in Limburg maken we de buitenkant weer strak, schoon en stevig. We werken netjes, dekken goed af en laten alles opgeruimd achter. Zo krijg je geen half werk, maar een gevel die er verzorgd bij staat en weer jaren mee kan.","marks":[]}]}]' WHERE id = '01KVZA95S478DQP136TRQKA3M8';
|
||||
UPDATE ec_locaties SET blok2_text = '[{"_type":"block","_key":"koeica","style":"normal","markDefs":[],"children":[{"_type":"span","_key":"knchaf","text":"Geen pand is hetzelfde. De ene gevel heeft vooral een goede reiniging nodig, terwijl bij de andere gevel het voegwerk aan vervanging toe is. Daarom bieden we meerdere diensten aan voor de hele buitenkant van je woning of bedrijfspand. Als voegersbedrijf in Limburg kunnen we zowel kleine herstelklussen als complete renovaties uitvoeren.","marks":[]}]},{"_type":"block","_key":"kqgc6e","style":"normal","markDefs":[],"children":[{"_type":"span","_key":"kpveqi","text":"We helpen onder andere met:","marks":[]}]},{"_type":"block","_key":"ksiq6d","style":"normal","markDefs":[],"children":[{"_type":"span","_key":"krwcoh","text":"gevelreiniging en gevelherstel;","marks":[]}],"listItem":"bullet","level":1},{"_type":"block","_key":"ku5tj6","style":"normal","markDefs":[],"children":[{"_type":"span","_key":"kt5ujl","text":"oud voegwerk uithakken en opnieuw invoegen;","marks":[]}],"listItem":"bullet","level":1},{"_type":"block","_key":"kwp05e","style":"normal","markDefs":[],"children":[{"_type":"span","_key":"kvz15t","text":"schoorsteen voegwerk, metselwerk en loodwerk;","marks":[]}],"listItem":"bullet","level":1},{"_type":"block","_key":"kyngae","style":"normal","markDefs":[],"children":[{"_type":"span","_key":"kxlls4","text":"dak reinigen en coaten;","marks":[]}],"listItem":"bullet","level":1},{"_type":"block","_key":"k10hm2i","style":"normal","markDefs":[],"children":[{"_type":"span","_key":"kzklr2","text":"terras, oprit en bestrating reinigen;","marks":[]}],"listItem":"bullet","level":1},{"_type":"block","_key":"k12l7ut","style":"normal","markDefs":[],"children":[{"_type":"span","_key":"k11a9sb","text":"impregneren tegen nieuwe vervuiling en vocht.","marks":[]}],"listItem":"bullet","level":1},{"_type":"block","_key":"k141vrc","style":"normal","markDefs":[],"children":[{"_type":"span","_key":"k13855v","text":"Goed voegwerk in Limburg is belangrijker dan veel mensen denken. Beschadigde of poreuze voegen laten sneller vocht door en maken de gevel kwetsbaarder. Door kapotte voegen op tijd te vervangen, voorkom je verdere schade en blijft je gevel netjes en waterdicht. Ook bij schoorstenen is goed onderhoud belangrijk. Loszittende voegen, slecht loodwerk of kleine scheuren kunnen lekkage veroorzaken. We bekijken wat er nodig is, herstellen waar nodig en zorgen dat het geheel weer stevig en verzorgd is. Zo heb je één vakman voor meerdere werkzaamheden aan de buitenkant.","marks":[]}]}]' WHERE id = '01KVZA95S478DQP136TRQKA3M8';
|
||||
UPDATE ec_locaties SET blok3_text = '[{"_type":"block","_key":"k16niao","style":"normal","markDefs":[],"children":[{"_type":"span","_key":"k15kqrl","text":"Klanten kiezen voor Helvoirt Gevelreiniging omdat we duidelijk, eerlijk en vakkundig werken. Onze voegers in Limburg kennen de materialen, weten welke methode bij welke gevel past en denken met je mee. Je krijgt geen standaardoplossing, maar een aanpak die klopt bij jouw woning, bedrijfspand en situatie.","marks":[]}]},{"_type":"block","_key":"k18s6j0","style":"normal","markDefs":[],"children":[{"_type":"span","_key":"k17qih1","text":"We houden het proces graag overzichtelijk:","marks":[]}]},{"_type":"block","_key":"k1a5mk9","style":"normal","markDefs":[],"children":[{"_type":"span","_key":"k195mve","text":"Inspectie en advies We bekijken de gevel, schoorsteen, het dak of de bestrating en letten op vervuiling, schade en de staat van het voegwerk.","marks":[]}],"listItem":"number","level":1},{"_type":"block","_key":"k1cimyt","style":"normal","markDefs":[],"children":[{"_type":"span","_key":"k1bakgq","text":"Duidelijke offerte Je ontvangt een heldere prijs vooraf. Geen verrassingen achteraf en geen onduidelijke afspraken.","marks":[]}],"listItem":"number","level":1},{"_type":"block","_key":"k1efs2l","style":"normal","markDefs":[],"children":[{"_type":"span","_key":"k1dlepn","text":"Werk op maat Bij gevel voegen in Limburg verwijderen we oud of beschadigd voegwerk zorgvuldig en brengen we nieuw voegwerk strak aan. Bij reiniging kiezen we een methode die schoonmaakt zonder onnodige schade aan de steen.","marks":[]}],"listItem":"number","level":1},{"_type":"block","_key":"k1g7wc2","style":"normal","markDefs":[],"children":[{"_type":"span","_key":"k1fwakj","text":"Netjes opleveren We spoelen na, ruimen op en lopen het werk samen met je na. Pas als alles netjes is, zijn wij klaar.","marks":[]}],"listItem":"number","level":1},{"_type":"block","_key":"k1irfve","style":"normal","markDefs":[],"children":[{"_type":"span","_key":"k1hb7qy","text":"Die manier van werken past bij wie we zijn: lokaal, betrokken en praktisch. We komen langs, kijken goed, geven eerlijk advies en doen wat we beloven. Of het nu gaat om een complete gevelrenovatie, een schoorsteen die aandacht nodig heeft of alleen herstel van een paar slechte voegen, je krijgt altijd vakwerk.","marks":[]}]}]' WHERE id = '01KVZA95S478DQP136TRQKA3M8';
|
||||
UPDATE ec_locaties SET blok4_text = '[{"_type":"block","_key":"k1k80xx","style":"normal","markDefs":[],"children":[{"_type":"span","_key":"k1j28iw","text":"Wil je jouw gevel laten reinigen, voegen laten herstellen of de buitenkant van je pand weer netjes laten maken? Neem dan contact op met ons voor gevelrenovatie in Limburg. We denken graag met je mee en plannen een moment om de situatie te bekijken. Ook wanneer je zoekt naar een eerlijk en ervaren voegbedrijf in Limburg, helpen we je graag verder. Je kunt telefonisch contact opnemen, een mail sturen of het contactformulier op de website invullen. Daarna ontvang je een duidelijke, vrijblijvende offerte, zodat je precies weet waar je aan toe bent.","marks":[]}]}]' WHERE id = '01KVZA95S478DQP136TRQKA3M8';
|
||||
|
||||
-- Veldtype text -> portableText (admin toont nu de rich-text editor; loader leest JSON).
|
||||
UPDATE _emdash_fields SET type = 'portableText', column_type = 'JSON'
|
||||
WHERE collection_id = (SELECT id FROM _emdash_collections WHERE slug = 'locaties')
|
||||
AND slug IN ('blok1_text', 'blok2_text', 'blok3_text', 'blok4_text');
|
||||
@@ -0,0 +1,29 @@
|
||||
-- 2 content-blokken (kop + rich-text + optionele foto) op de dienst-detailpagina's
|
||||
-- (/diensten/<slug>), ONDER de eerste alinea ("Wat we voor je doen"-sectie) en BOVEN de
|
||||
-- resultaat-galerij. Zelfde opzet als blok 1/2 op locatiepagina's: PortableText-editor met
|
||||
-- volledige opmaak (links, lijsten, vet), foto optioneel, om-en-om (zig-zag). Blok leeg =
|
||||
-- niet getoond. Bedoeld voor extra content op de Google Ads-landingspagina's.
|
||||
--
|
||||
-- `ec_diensten` bestaat al (0009) -> ALTER TABLE + nieuwe _emdash_fields-rijen. De velden
|
||||
-- komen in het admin-formulier net na "included" (sort_order 16-21); de bestaande velden
|
||||
-- vanaf resultaat schuiven 6 op. Toepassen op remote D1:
|
||||
-- node node_modules/wrangler/bin/wrangler.js d1 execute helvoirt-db --remote --file migrations/0020_diensten_blocks.sql
|
||||
|
||||
ALTER TABLE "ec_diensten" ADD COLUMN "blok1_heading" TEXT;
|
||||
ALTER TABLE "ec_diensten" ADD COLUMN "blok1_text" TEXT;
|
||||
ALTER TABLE "ec_diensten" ADD COLUMN "blok1_image" TEXT;
|
||||
ALTER TABLE "ec_diensten" ADD COLUMN "blok2_heading" TEXT;
|
||||
ALTER TABLE "ec_diensten" ADD COLUMN "blok2_text" TEXT;
|
||||
ALTER TABLE "ec_diensten" ADD COLUMN "blok2_image" TEXT;
|
||||
|
||||
-- maak ruimte: schuif resultaat/aanpak/faq/seo 6 plekken op.
|
||||
UPDATE _emdash_fields SET sort_order = sort_order + 6
|
||||
WHERE collection_id = '01KVA99538R5JTKEVHDYN32XD3' AND sort_order >= 16;
|
||||
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable, widget) VALUES
|
||||
('01KVZHZNC600G5M8DF1PZ2G3FB', '01KVA99538R5JTKEVHDYN32XD3', 'blok1_heading', 'Blok 1 · kop (optioneel)', 'string', 'TEXT', 0, 16, 0, 1, NULL),
|
||||
('01KVZHZNC8TCCFMSYP0N4G3X8X', '01KVA99538R5JTKEVHDYN32XD3', 'blok1_text', 'Blok 1 · tekst (alinea)', 'portableText', 'JSON', 0, 17, 0, 1, NULL),
|
||||
('01KVZHZNC8KKJAKEYHE2WZ3QKY', '01KVA99538R5JTKEVHDYN32XD3', 'blok1_image', 'Blok 1 · foto (optioneel)', 'image', 'TEXT', 0, 18, 0, 1, NULL),
|
||||
('01KVZHZNC8202T54FVCTBKPSGX', '01KVA99538R5JTKEVHDYN32XD3', 'blok2_heading', 'Blok 2 · kop (optioneel)', 'string', 'TEXT', 0, 19, 0, 1, NULL),
|
||||
('01KVZHZNC8N41RA1FNQQHJ5FFD', '01KVA99538R5JTKEVHDYN32XD3', 'blok2_text', 'Blok 2 · tekst (alinea)', 'portableText', 'JSON', 0, 20, 0, 1, NULL),
|
||||
('01KVZHZNC87ZXZH0DWZ0TQ9C11', '01KVA99538R5JTKEVHDYN32XD3', 'blok2_image', 'Blok 2 · foto (optioneel)', 'image', 'TEXT', 0, 21, 0, 1, NULL);
|
||||
@@ -0,0 +1,714 @@
|
||||
# Nieuwe website bouwen & live zetten — runbook (De Websmid)
|
||||
|
||||
> **Hoe je dit gebruikt:** geef dit bestand aan Claude aan het begin van een nieuw
|
||||
> website-project ("bouw een nieuwe site zoals Behangerbrabant, volg `newsite.md`").
|
||||
> Het beschrijft de **architectuur, tools, proces, mail, DNS en livegang** — niet de
|
||||
> lay-out/het design (dat doe je per klant met `/impeccable` + de klant-content).
|
||||
>
|
||||
> **Referentie-template = dit project (`behangerbrabant`)** of de zustersite
|
||||
> `../exterios`. Kopieer daar de patronen en de concrete bestanden uit (zie hieronder
|
||||
> welke). Dit is een onepage-marketingsite-recept; voor een webshop/blog wijkt het af.
|
||||
|
||||
---
|
||||
|
||||
## 0. TL;DR — de stack in één zin
|
||||
|
||||
Server-rendered **Astro 6** onepager + **emdash** headless CMS, gehost op **Cloudflare
|
||||
Workers** met **D1** (content/leads), **R2** (media) en **KV** (sessies). Mail loopt via
|
||||
de **eigen DirectAdmin-mailbox** op de hostingserver (authenticated SMTP vanuit de
|
||||
Worker), **niet** via Cloudflare Email Routing. Domein-DNS staat op Cloudflare.
|
||||
|
||||
---
|
||||
|
||||
## 1. Software-architectuur
|
||||
|
||||
```
|
||||
Browser ──► Cloudflare Worker (Astro SSR)
|
||||
├─► / ─► pages/index.astro ─► getEmDashCollection() ─► D1
|
||||
├─► /api/contact ─► honeypot + time-trap + validatie
|
||||
│ ├─► opslaan in D1 (vangnet, contact_submissions)
|
||||
│ └─► mail via authenticated SMTP ─► srv8 (DirectAdmin)
|
||||
├─► /_emdash/admin ─► emdash admin (Portable Text editor, media library)
|
||||
│ └─► writes ─► D1 + R2
|
||||
└─► src/middleware.ts ─► forceert http ─► https (301)
|
||||
```
|
||||
|
||||
- **Output:** `output: "server"` (SSR per request). Geen statische pre-render van de
|
||||
homepage (content komt live uit D1, zodat de klant alles zelf kan beheren).
|
||||
- **Content:** elke sectie leest uit een emdash-collectie via `getEmDashCollection(...)`
|
||||
met **inline fallback** in de component (lage blast-radius; site blijft staan als D1
|
||||
even faalt of nog niet geseed is).
|
||||
- **Per-collectie tabellen in D1:** emdash slaat elke collectie op als tabel `ec_<slug>`
|
||||
waarin **elk veld een echte kolom** is (titel, locatie, prijs, ...). Plus systeem-
|
||||
kolommen (id, slug, status, created_at, ...). Belangrijk voor directe SQL-edits.
|
||||
- **CMS-login = passwordless** (magic-link via een eigen e-mail-provider-plugin op dezelfde
|
||||
srv8-mailbox; geen wachtwoorden). Gebruikers worden als rij in `users` aangemaakt. Zie §6b.
|
||||
|
||||
### Stack & versies (uit `package.json`)
|
||||
| Laag | Pakket | Reden |
|
||||
|---|---|---|
|
||||
| Framework | `astro` ^6 | SSR onepager, snelle TTFB |
|
||||
| Adapter | `@astrojs/cloudflare` ^13 | Workers SSR |
|
||||
| CMS | `emdash` + `@emdash-cms/cloudflare` ^0.1 | klant beheert teksten/foto's/prijzen zelf |
|
||||
| React | `react`/`react-dom` ^19 | emdash admin-UI dependency |
|
||||
| DB | Cloudflare **D1** | content + leads (`contact_submissions`) |
|
||||
| Storage | Cloudflare **R2** | media uploads |
|
||||
| Sessies | Cloudflare **KV** | auto-provisioned (`SESSION`) |
|
||||
| Images | Cloudflare **Images** | auto-provisioned (`IMAGES`) |
|
||||
| Styling | scoped CSS + CSS custom properties | geen Tailwind, lichte payload |
|
||||
|
||||
---
|
||||
|
||||
## 2. Tools & accounts (eenmalig per machine / per project)
|
||||
|
||||
- **Cloudflare-account** De Websmid: `CLOUDFLARE_ACCOUNT_ID=41972f4905f2c83f51111b80283e030b`
|
||||
(Mike@dewebsmid.nl). `wrangler whoami` om in te loggen/checken.
|
||||
- **Node + wrangler** (in repo). Op deze Windows/corporate-machine roep je tools aan via
|
||||
`node node_modules/<tool>/bin/...` (zie §9, de `.bin`-shims ontbreken door
|
||||
`--ignore-scripts`).
|
||||
- **Python + Pillow** (systeem-python) voor beeld-conversie naar WebP (`sharp` is kapot
|
||||
door `--ignore-scripts`). Installeer libs met
|
||||
`pip install --trusted-host pypi.org --trusted-host files.pythonhosted.org <pkg>`.
|
||||
- **Hosting (DirectAdmin) bij de collega** voor de mailbox + DKIM. Server bv.
|
||||
`srv8.dewebsmid.nl`.
|
||||
- **Domeinregistrar** — nameservers mogen naar Cloudflare.
|
||||
|
||||
---
|
||||
|
||||
## 3. Repo scaffolden
|
||||
|
||||
Kopieer de structuur van dit project / `../exterios`. Kernbestanden:
|
||||
|
||||
```
|
||||
package.json name -> <project>, scripts (dev/build/deploy/typecheck)
|
||||
tsconfig.json extends astro/tsconfigs/strict
|
||||
astro.config.mjs output:server, adapter cloudflare(), emdash({database:d1, storage:r2}), react()
|
||||
wrangler.jsonc name, main ./src/worker.ts, compat nodejs_compat, D1+R2 bindings, routes
|
||||
src/worker.ts re-export @astrojs/cloudflare server handler + emdash PluginBridge
|
||||
src/live.config.ts emdash live collections
|
||||
src/cloudflare-env.d.ts minimale types voor `cloudflare:workers` (env) + `cloudflare:sockets`
|
||||
src/middleware.ts http->https redirect (kopieer 1-op-1, zie §7)
|
||||
src/lib/smtp.ts SMTP-client (kopieer 1-op-1, zie §6)
|
||||
src/pages/api/contact.ts contactformulier-handler (kopieer + pas adressen aan, zie §6)
|
||||
seed/seed.json emdash schema + demo content
|
||||
public/robots.txt, public/favicon.svg, public/_headers, public/fonts/<font>.woff2
|
||||
```
|
||||
|
||||
Installeren (Windows/corporate): `NODE_OPTIONS=--use-system-ca npm install --ignore-scripts`
|
||||
(zonder die env hangt npm op het corporate-cert; `--ignore-scripts` want `better-sqlite3`
|
||||
compileert hier niet).
|
||||
|
||||
---
|
||||
|
||||
## 4. Cloudflare resources aanmaken
|
||||
|
||||
```powershell
|
||||
$env:CLOUDFLARE_ACCOUNT_ID="41972f4905f2c83f51111b80283e030b"; $env:NODE_OPTIONS="--use-system-ca"
|
||||
node node_modules/wrangler/bin/wrangler.js d1 create <project>-db # regio WEUR
|
||||
node node_modules/wrangler/bin/wrangler.js r2 bucket create <project>-media
|
||||
```
|
||||
- Zet de **D1 `database_id`** + R2-bucketnaam in `wrangler.jsonc` (binding D1 = `DB`,
|
||||
R2 = `MEDIA`). KV (`SESSION`) en Images (`IMAGES`) worden auto-geprovisioned bij deploy.
|
||||
|
||||
---
|
||||
|
||||
## 5. Content-model + seed (emdash)
|
||||
|
||||
- Modelleer 1 singleton per onepage-blok + flat collections waar herhaling logisch is
|
||||
(zie `architecture.md` voor het volledige datamodel: `site_settings`, `hero`, `usps`,
|
||||
`pakketten`, `arte_showcase`, `segmenten`, `projecten`, `werkwijze`, `reviews`, `faq`).
|
||||
- **Beeldvelden**: zie **§5a (Beeld & media koppelen)** hieronder — leeg laten werkt
|
||||
alléén als de component het veld óók uitleest (anders verandert een upload niks).
|
||||
- **Seeden gaat via de setup-wizard, niet via CLI.** `emdash seed` (CLI) werkt hier NIET
|
||||
(better-sqlite3 niet gecompileerd). Draai de wizard op
|
||||
`https://<host>/_emdash/admin/setup` → "Include sample content".
|
||||
- **Content achteraf direct aanpassen** (bv. demo-tekst overschrijven) kan met SQL op de
|
||||
remote D1 — elk veld is een kolom in `ec_<collectie>`:
|
||||
```powershell
|
||||
node node_modules/wrangler/bin/wrangler.js d1 execute <project>-db --remote `
|
||||
--command "SELECT id,slug,title FROM ec_projecten ORDER BY created_at;" --json
|
||||
node node_modules/wrangler/bin/wrangler.js d1 execute <project>-db --remote `
|
||||
--command "UPDATE ec_projecten SET title='...' WHERE id='<ulid>';" --json
|
||||
```
|
||||
De publieke site leest deze kolommen direct (geen rebuild nodig). Let op revisie-
|
||||
kolommen `live_revision_id`/`draft_revision_id`: bij geseede content zijn die `null`,
|
||||
dan is een directe kolom-update schoon (admin-editor leest dezelfde kolom).
|
||||
|
||||
> ⚠️ **Schema leeft in D1, niet in seed.json (op runtime).** Collecties/velden staan in
|
||||
> `_emdash_collections` + `_emdash_fields`; `seed.json` is alleen de **éénmalige** seed.
|
||||
> Een veld toevoegen aan een **live** site = D1-migratie (`ALTER TABLE ec_<slug> ADD
|
||||
> COLUMN ...` + `INSERT INTO _emdash_fields (id=ULID, collection_id, slug, label, type,
|
||||
> column_type, sort_order)`), én `seed.json` bijwerken voor verse installs. Alléén
|
||||
> seed.json aanpassen laat het veld NIET in de live-admin verschijnen. Zie memory
|
||||
> `emdash-schema-lives-in-d1`.
|
||||
|
||||
### 5a. Beeld & media koppelen
|
||||
|
||||
Emdash provisioned image-**velden**, maar de **componenten moeten ze ook uitlezen** — een
|
||||
leeg/ongekoppeld veld betekent dat een admin-upload niets verandert op de site.
|
||||
|
||||
- **Opslagvorm (local provider op R2):** een image-veld is JSON
|
||||
`{ id, provider:"local", filename, mimeType, width, height, alt, meta:{ storageKey } }`.
|
||||
`src` wordt op displaytijd afgeleid uit `storageKey`; geserveerd via
|
||||
`/_emdash/api/media/file/<storageKey>` (R2, immutable cached). `astro.config` gebruikt
|
||||
`storage: r2({ binding:"MEDIA" })` → local provider (géén Cloudflare-Images-resizing).
|
||||
- **Bouw het uitlezen vanaf het begin in.** Helper `src/lib/images.ts#resolveCmsImage(value)`
|
||||
→ `{ src, width?, height?, alt? }` (handelt local/extern/JSON-string af). Component:
|
||||
lees `entry.data.<veld>`, gebruik `.src`, en **val terug op de statische placeholder-webp**
|
||||
(mét responsive srcset) als het veld leeg is — nul risico, nul zichtbare verandering tot
|
||||
iemand upload.
|
||||
- **Galerij met vast aantal beelden** (bv. 4 stalen) = **losse `image`-velden**
|
||||
(`image_1..image_4`, nette picker per slot), **niet** een ruw `json`-veld (slechte UX).
|
||||
- **Placeholders alvast in de media-library zetten** (anders ziet de klant lege
|
||||
beeldvelden): R2-`object put` + rij in `media` + veld-JSON. Zie memory
|
||||
`emdash-media-population` (+ `scripts/migrate-images.sql` in dit project).
|
||||
- **Decoratieve velden** (`logo`, `favicon`) worden vaak níét gerenderd (bespoke SVG-mark
|
||||
in de header); laat die met rust of verwijder het veld om de admin niet te misleiden.
|
||||
- **Responsive:** local provider = één `src` per beeld. Voor automatische maatvarianten
|
||||
(ook voor klantuploads) zou je de **Cloudflare-Images-provider** moeten aanzetten — niet
|
||||
standaard nodig, wel een nette upgrade. ⚠️ **Upload de juiste grootte** (CMS = één formaat):
|
||||
een `-1280`-variant voor hero/cards, kleiner voor beelden boven de vouw — niet het 2752px-origineel.
|
||||
Een `srcsetFor()`-helper hoort `undefined` te geven voor CMS-paden (`/_emdash/api/media/file/...`),
|
||||
zodat een leeg srcset geen kapotte `-768`-URL's bouwt; de code-fallback (statische webp) houdt z'n srcset.
|
||||
- **Media op schaal koppelen** (veel beeldvelden, bv. 25 dienst-foto's): genereer R2-uploads +
|
||||
`media`-rijen + veld-JSON met een script (zoals `scripts/gen-cms-sql.mjs`, die je **importeerbaar**
|
||||
maakt door de CLI achter een `import.meta.url===…`-guard te zetten). **Dedupliceer**: één `media`-rij
|
||||
per unieke foto, meerdere velden mogen dezelfde `storageKey` delen → schone mediabibliotheek.
|
||||
|
||||
---
|
||||
|
||||
### 5b. Collecties toevoegen aan een AL-geseede site + admin-UX (geleerd bij Helvoirt)
|
||||
|
||||
Na de eerste seed leeft het schema in D1. Een collectie/veld toevoegen kan zónder de wizard
|
||||
opnieuw te draaien (die reset auth + herintroduceert defaults), via **SQL die emdash's eigen
|
||||
tabelstructuur spiegelt**:
|
||||
- Lees de echte DDL van een bestaande `ec_<collectie>` uit `sqlite_master` en de kolommen van
|
||||
`_emdash_collections`/`_emdash_fields`. Genereer dan: `INSERT _emdash_collections` +
|
||||
`CREATE TABLE ec_<slug>` (zelfde systeemkolommen) + per veld `INSERT _emdash_fields` +
|
||||
content-`INSERT`s. **Laat search/FTS weg** (supports zonder `search`, geen `searchable`-velden)
|
||||
→ geen FTS-tabellen/triggers nodig, dus de SQL blijft compleet en veilig. Veld→`column_type`:
|
||||
string/text/select/datetime/slug/image/file/reference→TEXT, integer/boolean→INTEGER,
|
||||
number→REAL, portableText/json/multiSelect/repeater→JSON. ids = ULID (`ulidx`).
|
||||
Patroon in dit project: `scripts/gen-cms-sql.mjs <model> <out.sql>` + `scripts/cms-models/*`.
|
||||
- **`emdash`-defaults `pages`/`posts`** worden **alléén bij setup** aangemaakt (niet per boot) →
|
||||
veilig te verwijderen (`DROP TABLE ec_*` + `_emdash_fts_*` + rijen uit `_emdash_collections`/
|
||||
`_emdash_fields`); ze blijven weg zolang de wizard niet opnieuw draait.
|
||||
- **"Bestaande hardcoded pagina's → CMS" migreren:** lees elke pagina uit het CMS **met de
|
||||
huidige tekst als fallback** (`getEmDashEntry`/`getEmDashCollection` in try/catch). Zo breekt
|
||||
de site nooit, ook tijdens de migratie. **Verifieer per stap met een sentinel:** zet een
|
||||
herkenbare waarde via `d1 execute --remote`, fetch de live pagina, zet terug.
|
||||
|
||||
**Emdash admin-UX (v0.1.x) — wat wel/niet native is:**
|
||||
- **Dupliceren** van een entry = **native** (kopieer-icoon in de lijst → conceptkopie van alle
|
||||
velden + SEO). Geen eigen knop nodig.
|
||||
- **Repeater-velden** = inline rijen **toevoegen/verwijderen/verslepen** (WP-Bakery-gevoel).
|
||||
Definitie via `_emdash_fields.validation` = `{subFields:[{slug,type,label,required?}], minItems,
|
||||
maxItems}`. ⚠️ **Sub-velden kunnen GEEN `image` zijn** → beeld-rijen modelleer je als een
|
||||
**aparte collectie** met een image-veld (collectie heeft Add New + delete per rij).
|
||||
- **"(optional)"** bij niet-verplichte velden is **niet globaal uit te zetten** (geen branding/
|
||||
custom-CSS-hook in deze beta). Enige nette weg: het veld **verplicht** maken
|
||||
(`UPDATE _emdash_fields SET required=1 ...`). Velden hebben **geen** `description`/help-tekst —
|
||||
zet uitleg in het **label** (bv. "SEO · paginatitel").
|
||||
- **Media handmatig in de library**: `wrangler r2 object put <bucket>/<storageKey> --file ... --remote`
|
||||
(storage_key == exacte R2-key), + rij in `media` (id=ULID, filename, mime_type, width, height,
|
||||
alt, storage_key, status='ready'), + image-veld-JSON `{id, provider:"local", filename, mimeType,
|
||||
width, height, alt, meta:{storageKey}}` (geen `src`; wordt op displaytijd afgeleid). NB: CMS-
|
||||
beeld = één formaat (geen auto-srcset); kies een passende variant of de Cloudflare-Images-provider.
|
||||
|
||||
## 6. Contactformulier + MAIL (belangrijkste sectie)
|
||||
|
||||
### Architectuurbesluit (geleerd bij Behangerbrabant)
|
||||
De klant krijgt een **echte mailbox op de hostingserver** (DirectAdmin, bv.
|
||||
`info@<domein>` op `srv8.dewebsmid.nl`). Daardoor moet **MX naar srv8** wijzen, en dat
|
||||
sluit **Cloudflare Email Routing uit** (die eist MX naar Cloudflare — wederzijds
|
||||
uitsluitend). Dus:
|
||||
|
||||
- **De Worker stuurt de formuliermelding via authenticated SMTP** rechtstreeks naar de
|
||||
hosting-SMTP (poort 465, implicit TLS). Workers kunnen geen poort 25 en geen nodemailer;
|
||||
daarom een **hand-geschreven SMTP-client** op `cloudflare:sockets` → **`src/lib/smtp.ts`**
|
||||
(kopieer 1-op-1).
|
||||
- Aflevering aan `info@<domein>` is **lokaal op srv8**, dus de melding werkt **al vóór de
|
||||
DNS rond is** (auth-SMTP, niet afhankelijk van MX/SPF/DKIM).
|
||||
|
||||
### Wat `src/pages/api/contact.ts` doet
|
||||
1. **Honeypot** (`website_url`) + **time-trap** (`ts` hidden field; <2s of >6h = stille 200).
|
||||
2. **Validatie** (naam/e-mail/bericht), 422 met veld-errors.
|
||||
3. **Opslaan in D1** (`contact_submissions`, migratie `migrations/0001_*.sql`) = vangnet.
|
||||
4. **Twee mails** via `Promise.allSettled` (één fout blokkeert de ander niet):
|
||||
- **Melding naar de klant**: `To = info@<domein>`, `Reply-To = bezoeker`, house-style
|
||||
tekst ("Beste, ... Naam/Telefoonnummer/E-mailadres/Bericht").
|
||||
- **Bevestiging (autoreply) naar de aanvrager**: `To = bezoeker`, `Reply-To = info@`,
|
||||
ondertekend met de **contactpersoon** van de klant (bv. "Joey") + T/E.
|
||||
- **`From`-header** = `Bedrijfsnaam <website@<domein>>` (house-style); **envelope
|
||||
(`MAIL FROM`)** blijft de geauthenticeerde mailbox (`info@`) zodat srv8 niet weigert.
|
||||
`smtp.ts` ondersteunt hiervoor een aparte `envelopeFrom`.
|
||||
|
||||
### Config & secret
|
||||
Niet-geheime config zit als constant/env met fallback in `contact.ts`
|
||||
(`CONTACT_SMTP_HOST` srv8, `CONTACT_SMTP_PORT` 465, `CONTACT_SMTP_USER` info@,
|
||||
`CONTACT_EMAIL_TO` info@, `CONTACT_EMAIL_FROM` website@). **Alleen het wachtwoord is een
|
||||
secret**, nooit in de repo:
|
||||
|
||||
```bash
|
||||
# ZET SECRETS VIA BASH, NIET VIA POWERSHELL-PIPE.
|
||||
printf '%s' '<mailbox-wachtwoord>' | NODE_OPTIONS=--use-system-ca \
|
||||
node node_modules/wrangler/bin/wrangler.js secret put CONTACT_SMTP_PASS
|
||||
```
|
||||
> ⚠️ **BOM-valkuil:** `"x" | wrangler secret put` vanuit **Windows PowerShell** plakt een
|
||||
> UTF-8 BOM vóór de waarde → SMTP-auth faalt stil met `535 Incorrect authentication data`.
|
||||
> Gebruik **bash `printf '%s'`** of het Cloudflare-dashboard. `contact.ts` stript
|
||||
> defensief een leidende BOM (`charCodeAt(0)===0xFEFF`), maar zet hem gewoon goed.
|
||||
|
||||
### ⚠️ SMTP-host: gebruik de échte serverhostname, niet `mail.<domein>` (geleerd bij Helvoirt)
|
||||
Na de NS-verhuizing naar Cloudflare **resolvet `mail.<domein>` vaak niet** (de mail-records
|
||||
gingen niet mee bij de overstap → NXDOMAIN). De Worker geeft dan `Error: Stream was cancelled`
|
||||
(een `cloudflare:sockets`-verbindingsfout: host onvindbaar). Verbinden via een **kaal IP** lukt
|
||||
óók niet (het TLS-cert matcht de hostname, niet het IP).
|
||||
|
||||
**Oplossing:** zet `CONTACT_SMTP_HOST` op de **echte hostname van de DirectAdmin-server**
|
||||
(bv. `srv8.dewebsmid.nl`) — die resolvet wél los van het klantdomein én heeft een geldig cert.
|
||||
`CONTACT_SMTP_USER`/`EMAIL_TO`/`EMAIL_FROM` blijven `info@<domein>` (alleen de TCP-verbinding
|
||||
gaat naar srv8). Zet 'm in `wrangler.jsonc` `vars` (niet-geheim, dus geen secret):
|
||||
```jsonc
|
||||
"vars": { "CONTACT_SMTP_HOST": "srv8.dewebsmid.nl" }
|
||||
```
|
||||
> Doe dit meteen — laat de code-default niet op `mail.<domein>` staan, dat kost een debug-ronde.
|
||||
|
||||
**Serverhostname onbekend?** Vis 'm uit de `/webmail`-redirect van het klantdomein:
|
||||
`curl.exe -sk -D - https://<domein>/webmail` → `Location: https://srv8.dewebsmid.nl/roundcube`.
|
||||
|
||||
> **Outbound werkt vóór de mail-DNS:** de melding naar `info@<domein>` wordt **lokaal op srv8**
|
||||
> afgeleverd (de mailbox staat daar), dus die komt aan zonder MX. De **autoreply** naar de
|
||||
> bezoeker heeft SPF/DKIM/DMARC wél nodig voor de afleverbaarheid (anders spam) — zie §7.
|
||||
|
||||
### Testen (na deploy)
|
||||
Start `wrangler tail` in de achtergrond, doe een test-POST, en kijk naar de logs:
|
||||
```
|
||||
[contact] new-submission
|
||||
[contact] mail-sent melding-klant
|
||||
[contact] mail-sent bevestiging-aanvrager
|
||||
```
|
||||
POST-voorbeeld (Origin-header is nodig wegens Astro `checkOrigin`, `ts` tussen 2s en 6h):
|
||||
```powershell
|
||||
$ts=[DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()-5000
|
||||
curl.exe -sk --resolve <domein>:443:<cf-ip> -H "Origin: https://<domein>" `
|
||||
-H "Content-Type: application/x-www-form-urlencoded" `
|
||||
-d "name=Test&email=jij%40mail.nl&message=test&website_url=&ts=$ts" https://<domein>/api/contact
|
||||
```
|
||||
**Ruim testrijen op** uit `contact_submissions` na het testen. Bevestig bij de collega dat
|
||||
de mail echt in de inbox landt (250 = aangenomen door srv8, niet per se afgeleverd).
|
||||
|
||||
---
|
||||
|
||||
## 6b. CMS-login & gebruikers (auth)
|
||||
|
||||
Hoe logt de **klant** in op `/_emdash/admin`? Dit is niet triviaal in deze emdash-beta:
|
||||
|
||||
- **Emdash is passwordless** — er is **geen e-mail+wachtwoord** (de `users`-tabel heeft geen
|
||||
`password_hash`). Methodes: passkey (WebAuthn), magic-link (e-mail) en OAuth (Google/GitHub
|
||||
via secrets `EMDASH_OAUTH_GOOGLE_CLIENT_ID`/`_SECRET`; redirect-URI
|
||||
`/_emdash/api/auth/oauth/<p>/callback`; koppelt op geverifieerd e-mailadres).
|
||||
- ⚠️ **De admin-knop "Invite User" is stuk:** de invite-link wijst naar het API-endpoint
|
||||
`/_emdash/api/auth/invite/accept` (geeft JSON) i.p.v. een pagina die een passkey
|
||||
registreert. Een invite kan dus niet afgerond worden → **niet gebruiken.**
|
||||
- ⚠️ **De ingebouwde "Sign in with email link" (magic-link) geeft 503** zolang er geen
|
||||
**e-mail-provider-plugin** is (de `email:deliver`-hook). De GitHub/Google-knoppen werken
|
||||
niet zonder OAuth-secrets.
|
||||
|
||||
### Oplossing: e-mail-provider-plugin (hergebruikt de srv8-mailbox)
|
||||
|
||||
Maakt de ingebouwde magic-link werkend, met dezelfde `src/lib/smtp.ts` als §6.
|
||||
|
||||
1. **`src/plugins/smtp-email.ts`** — standard-formaat plugin: `export default { hooks: {
|
||||
"email:deliver": { exclusive: true, handler } } }` (géén emdash-import nodig). De handler
|
||||
leest de secret uit `cloudflare:workers` env en levert af via `sendMail`. Gooi bij fout
|
||||
(zodat de pipeline het meldt, niet stil "verstuurd").
|
||||
2. **Registreren in `astro.config.mjs`:**
|
||||
```js
|
||||
import { fileURLToPath } from "node:url";
|
||||
const smtpEntry = fileURLToPath(new URL("./src/plugins/smtp-email.ts", import.meta.url)).replace(/\\/g, "/");
|
||||
// ...
|
||||
emdash({
|
||||
database: d1({ binding: "DB", session: "auto" }),
|
||||
storage: r2({ binding: "MEDIA" }),
|
||||
plugins: [{ id: "<project>-smtp-email", version: "1.0.0", entrypoint: smtpEntry, format: "standard", capabilities: ["email:provide"] }],
|
||||
})
|
||||
```
|
||||
⚠️ **Entrypoint = absoluut pad met forward slashes** — emdash genereert er een `import`
|
||||
mee, dus een resolvebare specifier is nodig (backslashes breken het).
|
||||
3. Config-plugins zijn standaard **enabled**; als **enige** `email:deliver`-provider wordt
|
||||
'ie bij runtime-init **auto-geselecteerd + gepersisteerd** (optie
|
||||
`emdash:exclusive_hook:email:deliver`). ⚠️ In **dev** registreert emdash óók een
|
||||
console-provider → twee providers → geen auto-select; alleen lokaal relevant, productie
|
||||
heeft alleen de plugin.
|
||||
|
||||
Daarna werkt `/_emdash/admin/login` → "Sign in with email link" → e-mail → klik → sessie
|
||||
(KV) → `/_emdash/admin`. **Test** (na deploy): `POST /_emdash/api/auth/magic-link/send`
|
||||
`{"email":"..."}` → 200 + een rij in `auth_tokens` (`type='magic_link'`) + `wrangler tail`
|
||||
zonder SMTP-fout. Zie memory `emdash-passwordless-magic-link`.
|
||||
|
||||
### ⚠️ Login kapot na domeinkoppeling — en handmatig inloggen (geleerd bij Helvoirt)
|
||||
**Passkeys zijn origin-gebonden.** Registreert iemand een passkey/Windows-Hello-pincode op de
|
||||
**preview** (`*.workers.dev`), dan werkt die **niet** meer op het echte domein na de koppeling.
|
||||
Windows toont dan alleen "beveiligingssleutel / telefoon", niet de eigen pincode → de klant kan
|
||||
niet meer in. Oplossing: een **nieuwe passkey op het echte domein** registreren (kan pas ná
|
||||
inloggen) — dus vermijd passkeys op de preview, of regel eerst een andere login.
|
||||
|
||||
**Vastgelopen (passkey dood én mailbox nog niet klaar)? Mint zelf een magic-link-token** (werkt
|
||||
zonder mailbox). Token = `base64url(32 random bytes)`; in `auth_tokens` staat alleen de
|
||||
**SHA-256-hash** ervan:
|
||||
```powershell
|
||||
node -e "const c=require('crypto');const r=c.randomBytes(32);console.log('TOKEN='+r.toString('base64url'));console.log('HASH='+c.createHash('sha256').update(r).digest('base64url'))"
|
||||
# zet de HASH in auth_tokens (hash = PK, type='magic_link', 48u geldig) + e-mail geverifieerd:
|
||||
node node_modules/wrangler/bin/wrangler.js d1 execute <project>-db --remote --command `
|
||||
"INSERT INTO auth_tokens (hash,user_id,email,type,expires_at,created_at) VALUES `
|
||||
('<HASH>','<user-id>','<email>','magic_link',strftime('%Y-%m-%dT%H:%M:%fZ','now','+2 days'),strftime('%Y-%m-%dT%H:%M:%fZ','now')); `
|
||||
UPDATE users SET email_verified=1 WHERE id='<user-id>';"
|
||||
```
|
||||
Geef de gebruiker dan: `https://<domein>/_emdash/api/auth/magic-link/verify?token=<TOKEN>`
|
||||
(single-use, verloopt op `expires_at`; `verifyMagicLink` hasht de token en zoekt op de hash).
|
||||
|
||||
### ⚠️ Inlogmail-link geeft Cloudflare-placeholder "There is nothing here yet" (geleerd bij Helvoirt)
|
||||
**Symptoom:** de klant vraagt "Sign in with email link" aan, krijgt netjes een mail, maar de
|
||||
**link in die mail** leidt naar de Cloudflare-placeholderpagina *"There is nothing here yet —
|
||||
Powered by Cloudflare"* in plaats van de admin. Inloggen lukt nooit.
|
||||
|
||||
**Oorzaak:** emdash bouwt elke uitgaande systeemlink (magic-link én e-mailverificatie) op uit de
|
||||
**opgeslagen** optie **`emdash:site_url`**, **niet** uit de request-Host (bewust — anti-spoofing,
|
||||
zie `node_modules/emdash/src/api/site-url.ts#getSiteBaseUrl`). Die optie wordt **één keer gezet
|
||||
door de setup-wizard**, en dat gebeurt op de **preview-origin** (`*.workers.dev`). Na de
|
||||
domeinkoppeling schakelt de deploy `workers.dev` **uit** (§7) → de opgeslagen URL wijst naar een
|
||||
**dode subdomein** → Cloudflare serveert de placeholder. De rest van de site werkt prima (die
|
||||
draait op de request-host), alleen de inlogmail is stuk.
|
||||
|
||||
**Fix** (data op de live D1, **geen redeploy** nodig). De waarde is een **JSON-string**, dus
|
||||
inclusief de dubbele quotes; tabel `options`, kolommen `name`/`value`:
|
||||
```bash
|
||||
node node_modules/wrangler/bin/wrangler.js d1 execute <project>-db --remote \
|
||||
--command "UPDATE options SET value = '\"https://<domein>\"' WHERE name = 'emdash:site_url';"
|
||||
# verifieer:
|
||||
node node_modules/wrangler/bin/wrangler.js d1 execute <project>-db --remote \
|
||||
--command "SELECT name, value FROM options WHERE name = 'emdash:site_url';" --json
|
||||
```
|
||||
`getSiteBaseUrl` plakt er zelf `/_emdash` achter, dus zet **alleen het kale domein** (geen pad,
|
||||
geen trailing slash). Daarna een **nieuwe** inlog-link aanvragen — mails van vóór de fix bevatten
|
||||
nog de dode link.
|
||||
|
||||
**Preventie:** zet `emdash:site_url` **meteen op het echte domein** zodra je de custom domain
|
||||
koppelt (§7 / go-live stap 8), nog vóór je de klant laat inloggen. Controleer 'm met de
|
||||
`SELECT` hierboven; staat er nog `*.workers.dev`, dan is élke inlogmail dood.
|
||||
|
||||
### Gebruikers aanmaken (provisioning)
|
||||
|
||||
Geen werkende UI (invite stuk) → rij in `users` via SQL. Magic-link mailt **alleen naar
|
||||
bestaande** users (geen open signup).
|
||||
```powershell
|
||||
node node_modules/wrangler/bin/wrangler.js d1 execute <project>-db --remote --command `
|
||||
"INSERT INTO users (id, email, role, email_verified, disabled, created_at, updated_at) VALUES `
|
||||
('<ULID>','<email-lowercase>',50,1,0,strftime('%Y-%m-%dT%H:%M:%fZ','now'),strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
|
||||
```
|
||||
- **Rollen:** 50=ADMIN, 40=EDITOR, 30=AUTHOR. Voor een klant is **EDITOR** vaak genoeg
|
||||
(alle content, geen gebruikers/instellingen). ULID via
|
||||
`node --input-type=module -e "import {ulid} from 'ulidx'; console.log(ulid())"`.
|
||||
- **Verwijderen:** `DELETE` uit `auth_tokens` / `oauth_accounts` / `credentials`
|
||||
(WHERE user_id …) + `users`.
|
||||
|
||||
### Klant-handover
|
||||
|
||||
Stuur de klant een mail met inlog-uitleg (URL, "Sign in with email link", waar content +
|
||||
SEO-velden staan). ⚠️ De **inlogmail die de klant ontvangt is Engelstalig** ("Sign in to
|
||||
…") — emdash's standaardtemplate; benoem de knop in je handover-mail. Voorbeeld-handover:
|
||||
zie de mail die we voor Behangerbrabant schreven (in `todo.md`/sessielog).
|
||||
|
||||
---
|
||||
|
||||
## 7. DNS (op Cloudflare) + HTTPS
|
||||
|
||||
### Zone activeren + nameservers (geleerd bij Helvoirt)
|
||||
"De klant heeft de DNS omgezet" ≠ klaar. **Verifieer dat de zone op Cloudflare `active` is**
|
||||
(niet `pending`): de registrar moet de nameservers naar de **Cloudflare-toegewezen** NS wijzen
|
||||
(bv. `cesar`/`priscilla.ns.cloudflare.com`), niet naar de hoster (bv. `dns1.hosted.nl`). Wijzen
|
||||
ze nog naar de hoster, dan **SERVFAILt het domein overal** ("lame delegation" in de DoH-respons)
|
||||
en blijft de zone `pending`. Check de status + de toegewezen NS via de API (token uit de
|
||||
wrangler-config):
|
||||
```powershell
|
||||
$cfg = "$env:APPDATA\xdg.config\.wrangler\config\default.toml"
|
||||
$tok = ((Get-Content $cfg | Where-Object {$_ -match 'oauth_token'}) -replace '.*=\s*"?([^"]+)"?.*','$1').Trim()
|
||||
(Invoke-RestMethod "https://api.cloudflare.com/client/v4/zones?name=<domein>" -Headers @{Authorization="Bearer $tok"}).result |
|
||||
Select-Object name,status,@{n='ns';e={$_.name_servers -join ', '}}
|
||||
```
|
||||
Pas custom-domain koppelen (routes + rebuild) als de zone **active** is.
|
||||
|
||||
### Mail-DNS (door de collega aan te leveren / via DNS-token toe te voegen)
|
||||
| Type | Naam | Waarde | Prio |
|
||||
|---|---|---|---|
|
||||
| MX | `@` | `srv8.dewebsmid.nl` | 10 |
|
||||
| TXT (SPF) | `@` | `v=spf1 mx include:_spf.dewebsmid.nl ~all` | — |
|
||||
| TXT (DMARC) | `_dmarc` | `v=DMARC1; p=none; rua=mailto:info@<domein>` | — |
|
||||
| TXT (DKIM) | `x._domainkey` | public key uit DirectAdmin (selector `x` = default) | — |
|
||||
|
||||
- MX = **DNS-only** (mail gaat nooit door de Cloudflare-proxy). De apex-A blijft proxied
|
||||
voor de site — MX staat daar los van.
|
||||
- **DKIM-key komt uit DirectAdmin** (E-mail → DKIM). Mail werkt zonder, maar belandt
|
||||
vaker in spam.
|
||||
- **Verifieer via DoH** (corporate-DNS blokkeert poort 53):
|
||||
`curl -sk "https://dns.google/resolve?name=<domein>&type=MX"` (en TXT, `_dmarc`,
|
||||
`x._domainkey`).
|
||||
|
||||
> ⚠️ **Wie zet de DNS?** De `wrangler`-OAuth-login kan zones **lezen** (`/zones?name=` werkt),
|
||||
> maar geeft **403** op **DNS-records** (list/edit) én op **zone-instellingen** (SSL-modus,
|
||||
> Always Use HTTPS, HSTS). Dus die gaan via het **dashboard** of via een aparte **API-token met
|
||||
> Zone.DNS:Edit** (My Profile → API Tokens → "Edit zone DNS" → specifieke zone). **Wel** werkt
|
||||
> het **aanmaken van het custom domain** via `wrangler deploy` (Workers-scope). Vraag de
|
||||
> DNS-token aan de klant/Mike als je de mail-DNS/HTTPS zelf via de API wilt zetten.
|
||||
|
||||
> ⚠️ **Mail-DNS van een zustersite kopiëren?** Pas álle domeinverwijzingen aan — vooral de
|
||||
> DMARC **`rua`** (en evt. de DKIM-key). Bij Helvoirt stond de `rua` per ongeluk nog op
|
||||
> `info@behangerbrabant.nl` (copy-paste). De MX mag direct naar `srv8.dewebsmid.nl` (een los
|
||||
> `mail.<domein>`-A-record is optioneel; alleen nodig als het mailprogramma die hostname gebruikt).
|
||||
|
||||
### HTTPS / redirect / security
|
||||
- **http→https**: `src/middleware.ts` doet een 301 op basis van de `cf-visitor`-header
|
||||
(kopieer 1-op-1; doet niets zonder die header, dus dev breekt niet). **Plus** optioneel
|
||||
in het dashboard: **SSL/TLS → Edge Certificates → "Always Use HTTPS"** aan (edge-niveau,
|
||||
maakt de middleware een no-op) en daarna **HSTS** aan.
|
||||
- **SSL-modus** op **Full** of **Full (strict)** (niet Flexible → anders redirect-loop).
|
||||
- **Spam-bescherming** = honeypot + time-trap (server-side), **geen reCAPTCHA**.
|
||||
- **Security headers** zijn al gezet (Permissions-Policy, X-Content-Type-Options nosniff,
|
||||
X-Frame-Options, Referrer-Policy). CSP optioneel.
|
||||
- Custom domain koppelen via `wrangler.jsonc` `routes` (apex + `www`, beide
|
||||
`custom_domain: true`). ⚠️ **Rebuild ná elke `wrangler.jsonc`-wijziging**, anders deployt
|
||||
wrangler een stale config (de adapter genereert `dist/server/wrangler.json`).
|
||||
⚠️ Bij het toevoegen van `routes` **schakelt de deploy `workers.dev` uit** (custom-domain-only
|
||||
— goed tegen duplicate content, maar de **preview-URL stopt** en open passkeys op die URL
|
||||
breken, zie §6b). De middleware regelt `www→apex` + `http→https` al op worker-niveau, dus de
|
||||
redirects werken ook zonder de dashboard-instellingen.
|
||||
⚠️ **Update meteen ook `emdash:site_url`** naar het echte domein (de setup-wizard zette 'm op
|
||||
de `*.workers.dev`-preview die nu uit gaat) — anders wijst élke inlogmail-link naar een dode
|
||||
subdomein en krijgt de klant de Cloudflare-placeholder. Zie §6b.
|
||||
|
||||
---
|
||||
|
||||
## 8. SEO / performance / afronding basics
|
||||
|
||||
- **`public/favicon.svg` MOET bestaan** (Base.astro verwijst ernaar; ontbreekt = 404).
|
||||
Brand-mark als SVG met eigen donkere achtergrond.
|
||||
- **`public/robots.txt`**: `User-agent: *` / `Allow: /` / `Disallow: /_emdash/` +
|
||||
`Disallow: /api/` / `Sitemap: https://<domein>/sitemap.xml`. **Nooit** `Disallow: /`.
|
||||
- **`sitemap.xml`** endpoint + **canonical naar apex (non-www)** via `site:` in
|
||||
`astro.config.mjs`.
|
||||
- Meta `title` + `description` in `Base.astro`; JSON-LD `LocalBusiness` + `FAQPage`.
|
||||
- Beelden: WebP, responsive maten via Pillow. **Nieuwe bestandsnamen bij vervanging**
|
||||
(bv. `-v2-`) om cache-staleness te vermijden; `width`/`height` op `<img>` matchen om CLS
|
||||
te voorkomen.
|
||||
- **Performance (mobiele Lighthouse-winst, geverifieerd bij Behangerbrabant):**
|
||||
- **Host fonts zelf** i.p.v. Google Fonts: één variabel woff2 (latin-subset) in
|
||||
`public/fonts/`, `@font-face` met `font-display: swap` in `tokens.css`, en de externe
|
||||
`fonts.googleapis.com`/`gstatic.com`-links eruit. Schrapt een render-blocking stylesheet
|
||||
+ 2 externe origins. Download het woff2 met een Chrome-UA via de `css2`-URL (zelfde font
|
||||
= identiek uiterlijk). Evt. `<link rel="preload" as="font" ... crossorigin>`.
|
||||
⚠️ **Niet elk font is self-te-hosten via de `css2`-API:** **width-varianten** (bv.
|
||||
*Archivo Expanded*) zijn door Google samengevoegd in de variabele basisfamilie →
|
||||
`family=Archivo+Expanded` levert **geen** `@font-face`. Dan: of de juiste axis/instance
|
||||
opvragen, of het font gewoon op Google Fonts laten (preconnect + `display=swap`) i.p.v.
|
||||
de display-typografie te riskeren. Geverifieerd bij Helvoirt.
|
||||
- **OG-image (1200×630) genereren** kan deterministisch met Pillow (`scripts/make-og.py`):
|
||||
echte foto cover-croppen + petrol-gradient + wordmark + 4,9-Google. ⚠️ Het **ster-glyph**
|
||||
(`★`, U+2605) zit niet in Arial → gebruik `seguisym.ttf` (Segoe UI Symbol) voor de sterren.
|
||||
- **`public/_headers`** voor far-future immutable caching op `/fonts/*` en `/images/*`
|
||||
(versie-vaste namen) — Workers Static Assets honoreert dit; lost de "efficient cache
|
||||
policy"-melding op. HTML niet cachen (SSR).
|
||||
- LCP-beeld preloaden (`fetchpriority="high"`), non-hero `loading="lazy"`.
|
||||
- **Lighthouse meten:** er is geen Lighthouse-MCP; de keyless PageSpeed-API is vaak
|
||||
`429` (dagquota). Meet met een **eigen Google PSI API-key**
|
||||
(`...runPagespeed?url=...&strategy=mobile&key=<KEY>`) of via de PageSpeed-website.
|
||||
|
||||
### 8a. Analytics / Google Tag Manager (GTM)
|
||||
|
||||
De klant moet z'n **eigen GTM-container** kunnen koppelen zonder code aan te raken.
|
||||
|
||||
> ⚠️ **Twee "settings" in emdash — verwar ze niet.** De **ingebouwde** pagina
|
||||
> **Settings → SEO** heeft een **vaste, hardcoded** veldenlijst (`SiteSettings.seo` in
|
||||
> `node_modules/emdash/src/settings/types.ts`: title-separator, default OG-image, robots.txt,
|
||||
> Google Search Console-verificatie, Bing-verificatie). Daar kun je **geen** veld aan
|
||||
> toevoegen, en `Base.astro` leest die ook niet. Site-brede klant-instellingen (SEO-titel/
|
||||
> description, GTM, NAW) horen in de **`site_settings`-collectie** (label "Instellingen") —
|
||||
> dáár leest `Base.astro` uit. De klant zoekt GTM vaak op de verkeerde (ingebouwde) pagina.
|
||||
|
||||
1. **Veld toevoegen** — live = D1-migratie (zie §5 ⚠️ + memory `emdash-schema-lives-in-d1`):
|
||||
- `ALTER TABLE ec_site_settings ADD COLUMN gtm TEXT;`
|
||||
- `INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, sort_order)`
|
||||
met `id=ULID`, `collection_id=<site_settings-id>`, `slug='gtm'`,
|
||||
`label='Google Tag Manager ID (bv. GTM-XXXXXX)'`, `type='string'`, `column_type='TEXT'`.
|
||||
- `seed.json` óók bijwerken (veld-def + lege startwaarde) voor verse installs.
|
||||
- De admin leest de veldenlijst **live uit D1** (`SchemaRegistry.listFields`, géén cache),
|
||||
dus het veld verschijnt direct in de Instellingen-editor — **geen rebuild nodig**.
|
||||
2. **Renderen in `Base.astro`** — vraag alleen de **container-ID** (`GTM-XXXXXX`), niet het
|
||||
hele snippet, en bouw zelf de officiële code. Robuust uitlezen (vist de ID ook uit een
|
||||
per ongeluk geplakt volledig snippet; matcht bewust alleen `GTM-`, niet GA4's `G-XXXX`):
|
||||
```ts
|
||||
const gtmId = (((s.gtm as string|undefined) ?? "").match(/GTM-[A-Z0-9]+/i)?.[0] ?? "").toUpperCase();
|
||||
```
|
||||
- **Head** (zo hoog mogelijk in `<head>`), alleen als `gtmId` gezet is: het officiële
|
||||
GTM-`<script>` via `is:inline define:vars={{ gtmId }}` (de IIFE eindigend op
|
||||
`'dataLayer', gtmId`).
|
||||
- **Body** (direct na `<body>`): het `<noscript>`-`<iframe src=...ns.html?id=${gtmId}>`.
|
||||
- Leeg veld → **niets** geïnjecteerd (`{gtmId && (...)}`-guard).
|
||||
3. **Verifiëren** (na deploy): zet tijdelijk een nep-ID (`GTM-TEST000` — niet-bestaande
|
||||
container = laadt niets) op de remote D1, fetch de live HTML (`gtm.js?id=` in de head +
|
||||
`ns.html?id=` na de body), en zet 'm daarna **terug op leeg**.
|
||||
|
||||
> ⚠️ **GTM laadt zelf geen cookies** tot er in de container een tag vuurt. Komt er tracking
|
||||
> met cookies, dan is er apart een **cookie-consent** nodig (staat niet standaard op de site).
|
||||
|
||||
Concreet voorbeeld in dit project: `scripts/migrate-gtm-field.sql` + het GTM-blok in
|
||||
`src/layouts/Base.astro` (frontmatter `gtmId` + de twee `{gtmId && ...}`-blokken).
|
||||
|
||||
---
|
||||
|
||||
## 9. Bouwen & deployen (Windows/corporate)
|
||||
|
||||
```powershell
|
||||
$env:CLOUDFLARE_ACCOUNT_ID="41972f4905f2c83f51111b80283e030b"
|
||||
$env:NODE_OPTIONS="--use-system-ca" # corporate-cert workaround (anders hangt het)
|
||||
node node_modules/astro/bin/astro.mjs check # typecheck: 0/0/0
|
||||
node node_modules/astro/bin/astro.mjs build
|
||||
node node_modules/wrangler/bin/wrangler.js deploy
|
||||
```
|
||||
- Roep tools aan via **`node node_modules/<tool>/bin/...`** (de `.bin`-shims ontbreken door
|
||||
`--ignore-scripts`; `npx` wil dan een fresh install starten).
|
||||
- **Secrets blijven** bestaan over deploys heen (los van de gegenereerde config).
|
||||
- **Verifiëren vanaf deze machine:** corporate-DNS kan het domein niet resolven → forceer
|
||||
de Cloudflare-IP: `curl -sk --resolve <domein>:443:<cf-ip> https://<domein>/`. IP ophalen
|
||||
via DoH: `curl -sk "https://dns.google/resolve?name=<domein>&type=A"`.
|
||||
- **Edge-cache na deploy:** de HTML kan een paar seconden een stale versie geven; poll
|
||||
opnieuw na ~5s vóór je concludeert dat een wijziging niet live is.
|
||||
- Live-logs: `node node_modules/wrangler/bin/wrangler.js tail <project> --format json`
|
||||
(vang via een PowerShell `Start-Job` + `Receive-Job` als je niet interactief bent).
|
||||
|
||||
---
|
||||
|
||||
## 10. Checklist invullen
|
||||
|
||||
De oude standaard-checklist is **WordPress-gericht** en is vervangen door een schone,
|
||||
stack-eigen versie. **Begin vanaf het lege template**
|
||||
`Checklist - Websites (template, leeg).xlsx` (72 punten, WP-vrij, dropdown op
|
||||
"Uitgevoerd" = Ja/Nee/N.v.t./Deels/Actie) en lever per project een ingevulde `.xlsx` op.
|
||||
Vul de kolommen **Uitgevoerd / Wie / Wanneer / Opmerking**. Voorbeeld van een ingevulde
|
||||
versie: `Checklist - Websites - behangerbrabant (ingevuld).xlsx`.
|
||||
|
||||
> De checklist heeft 9 secties: 1) Voorbereiding 2) Algemeen/inhoud 3) Techniek &
|
||||
> beveiliging 4) Contactformulier & mail 5) DNS/hosting/mail-infra 6) Performance &
|
||||
> toegankelijkheid 7) Browsers & responsive 8) SEO & vindbaarheid 9) Na livegang.
|
||||
> Genereren/lezen van `.xlsx` met `openpyxl`; het oude WP-bron-`.xls` is OLE2 (lezen met `xlrd`).
|
||||
|
||||
Kern van de WP→stack-vertaling: plugins/iThemes/wp-login/xmlrpc/Duplicator/themes-FTP/
|
||||
PHP-versie/cache-plugin/reCAPTCHA = **N.v.t.** (equivalent: Cloudflare + Astro: checkOrigin,
|
||||
honeypot+time-trap, edge-caching, Worker `compatibility_date`); formulieren "opgeslagen in
|
||||
beheer" = **D1 `contact_submissions`**; redirect via DirectAdmin = **middleware + Always Use
|
||||
HTTPS**.
|
||||
|
||||
---
|
||||
|
||||
## 11. Per-klant te verzamelen (vóór livegang)
|
||||
|
||||
- Telefoon, e-mail, NAW, KvK/BTW, openingstijden → in `site_settings` (admin).
|
||||
- **Mailbox**: adres + wachtwoord + in/uit-server + IP (van de collega). Contactpersoon
|
||||
voor de autoreply-handtekening.
|
||||
- **DKIM-record** uit DirectAdmin (collega).
|
||||
- **CMS-login**: e-mailadres(sen) van de klant + gewenste rol (ADMIN/EDITOR) → gebruiker(s)
|
||||
aanmaken (§6b).
|
||||
- Echte foto's (hero/projecten/Arte), logo (SVG), favicon/og-image, echte reviews.
|
||||
- **GTM-container-ID** (`GTM-XXXXXX`) van de klant, indien ze Tag Manager gebruiken → in
|
||||
`site_settings` (§8a). Evt. ook Search Console-/Bing-verificatie (ingebouwde Settings → SEO).
|
||||
- Akkoord op definitieve teksten + moment van livegang.
|
||||
|
||||
---
|
||||
|
||||
## 12. Go-live volgorde (samengevat)
|
||||
|
||||
1. Scaffold + `npm install` (corporate env).
|
||||
2. Cloudflare D1 + R2 aanmaken, IDs in `wrangler.jsonc`.
|
||||
3. Componenten + content-model bouwen; `seed/seed.json`. **Lees image-velden meteen uit**
|
||||
(helper + statische fallback), galerij = **losse `image`-velden** (§5a).
|
||||
4. `npm run build` + `deploy` → eerste Worker op `*.workers.dev`.
|
||||
5. Setup-wizard draaien → content seeden; demo-tekst evt. via SQL bijwerken; **placeholders
|
||||
in de media-library zetten + beeldvelden koppelen** (§5a).
|
||||
6. **Mail**: `src/lib/smtp.ts` + `contact.ts` koppelen; `CONTACT_SMTP_HOST` op de
|
||||
**serverhostname** (srv8), NIET `mail.<domein>` (§6); secret `CONTACT_SMTP_PASS` zetten
|
||||
(bash!); testen via `wrangler tail` → 2× `mail-sent` + alle paden (422/honeypot/time-trap).
|
||||
7. **CMS-login** (§6b): e-mail-provider-plugin registreren (hergebruikt `smtp.ts`) +
|
||||
klant-gebruiker(s) in `users` aanmaken; magic-link testen (send → token → tail). Vermijd een
|
||||
passkey op de preview-URL (breekt na de domeinkoppeling, §6b).
|
||||
8. Zone op Cloudflare + nameservers om → **verifieer dat de zone `active` is** (niet `pending`;
|
||||
NS naar de CF-NS, niet de hoster — §7); custom domain via `routes` (**rebuild!** → workers.dev
|
||||
gaat uit). **Zet meteen `emdash:site_url` op het echte domein** (anders zijn de inlogmails dood — §6b).
|
||||
9. **Mail-DNS** (MX/SPF/DKIM/DMARC) zetten (dashboard of DNS-token) + via DoH verifiëren.
|
||||
10. HTTPS: middleware + "Always Use HTTPS" + SSL Full(strict) + HSTS.
|
||||
11. SEO + perf-basics: favicon, robots, sitemap, canonical, meta, JSON-LD, self-hosted font,
|
||||
`_headers`-caching.
|
||||
12. **Checklist invullen** (§10) en met de klant aflopen.
|
||||
13. **Klant-handover**: inlog-uitleg mailen (§6b) → klant-akkoord → live; daarna Search
|
||||
Console + Analytics + PageSpeed-meting.
|
||||
|
||||
---
|
||||
|
||||
## 13. Bekende valkuilen (Windows/corporate) — niet opnieuw intrappen
|
||||
|
||||
- **`NODE_OPTIONS=--use-system-ca`** overal nodig (npm, astro, wrangler), anders hangt het
|
||||
stil op het corporate-MITM-cert.
|
||||
- **Tools via `node node_modules/.../bin`** (geen `.bin`-shims door `--ignore-scripts`).
|
||||
- **Corporate-DNS resolvet je nieuwe domein niet** → forceer CF-IP met `curl --resolve`,
|
||||
en check publieke DNS via **DoH** (`dns.google/resolve`), niet `nslookup`/poort 53.
|
||||
- **Secrets via bash `printf`**, nooit via PowerShell-pipe (BOM → 535).
|
||||
- **Rebuild ná elke `wrangler.jsonc`-wijziging** (adapter genereert eigen
|
||||
`dist/server/wrangler.json`).
|
||||
- **`emdash seed`-CLI werkt niet** (better-sqlite3) → seed via wizard.
|
||||
- **Dev-server (Vite) is instabiel** (dep-optimizer 500's): vers herstarten + evt.
|
||||
`node_modules/.vite` wissen; poll op *content*, niet alleen HTTP 200. Productie-build
|
||||
heeft dit niet — testen kan het betrouwbaarst gewoon op een deploy.
|
||||
- **Bestanden kunnen op deze machine verdwijnen** (antivirus/sync wist o.a. `node_modules`-
|
||||
`.js` en losse bestanden). Herstel `node_modules` met `npm ci --ignore-scripts` (met env);
|
||||
controleer na het aanmaken van losse bestanden of ze nog bestaan.
|
||||
- **Emdash-login werkt niet out-of-the-box**: géén e-mail+wachtwoord, "Invite User" is een
|
||||
dood JSON-endpoint, en de magic-link-knop geeft 503 zonder e-mail-provider-plugin. Los op
|
||||
met de plugin + gebruikers via SQL (§6b). Reken er niet op dat de admin-UI dit regelt.
|
||||
- **Een leeg image-veld doet niks** tenzij de component het uitleest (helper + fallback,
|
||||
§5a). Upload-in-admin ≠ zichtbaar-op-site zolang de koppeling ontbreekt.
|
||||
- **Twee "settings" in emdash:** de ingebouwde **Settings → SEO**-pagina is hardcoded en
|
||||
niet uitbreidbaar; site-brede klant-velden (SEO-titel/description, **GTM**, NAW) horen in
|
||||
de **`site_settings`-collectie** ("Instellingen"), waar `Base.astro` uit leest. De klant
|
||||
zoekt GTM vaak op de verkeerde pagina. Zie §8a.
|
||||
- **Passkey is origin-gebonden:** een passkey op `*.workers.dev` werkt NIET op het echte domein
|
||||
→ login breekt na de domeinkoppeling. Nieuwe passkey op het echte domein, of mint handmatig een
|
||||
magic-link-token (§6b). Vermijd passkeys op de preview.
|
||||
- **`emdash:site_url` blijft op de preview staan:** de inlogmail-link wijst dan naar de uitgezette
|
||||
`*.workers.dev` → de klant krijgt Cloudflares **"There is nothing here yet"**-placeholder. Update
|
||||
de optie (`UPDATE options SET value='"https://<domein>"' ...`) bij de domeinkoppeling. Zie §6b.
|
||||
- **`mail.<domein>` resolvet vaak niet** na de NS-verhuizing → `CONTACT_SMTP_HOST` = de
|
||||
serverhostname (srv8), te vinden via de `/webmail`-redirect. `Error: Stream was cancelled` =
|
||||
Worker-SMTP-verbindingsfout (host onvindbaar / cert-mismatch bij kaal IP). Zie §6.
|
||||
- **"DNS is omgezet" ≠ klaar:** check dat de Cloudflare-zone `active` is (niet `pending`); NS
|
||||
moeten naar de CF-NS, niet de hoster (anders SERVFAIL / lame delegation). Zie §7.
|
||||
- **wrangler-OAuth-token:** **403** op DNS-records én zone-instellingen (SSL/HSTS); custom-domain-
|
||||
creatie werkt wél. Mail-DNS/HTTPS via dashboard of `Zone.DNS:Edit`-token. Zie §7.
|
||||
- **Mail-DNS van een zuster-zone kopiëren?** Pas de DMARC **`rua`** (en DKIM) aan naar het nieuwe
|
||||
domein — copy-paste-valkuil (Helvoirt stond op behangerbrabant).
|
||||
- **PowerShell-valkuilen:** `$HOME` is **read-only** (gebruik een andere variabelenaam); native-exe-
|
||||
stderr laat het tool vaak "exit 255/1" melden terwijl de actie **slaagde** (check de echte
|
||||
output op `success`); `curl.exe` met een **JSON-body** breekt op de quoting → gebruik
|
||||
`Invoke-WebRequest -Body '{"...":"..."}'` voor JSON-POSTs.
|
||||
|
||||
---
|
||||
|
||||
## 14. Verwijzingen
|
||||
|
||||
- `architecture.md` — datamodel, sectie-volgorde, repo-layout (deels achterhaald op detail).
|
||||
- `todo.md` — actuele projectstatus + alle besluiten/gotchas per sessie.
|
||||
- `Checklist - Websites (template, leeg).xlsx` — **leeg herbruikbaar template** (start hier).
|
||||
- `Checklist - Websites - behangerbrabant (ingevuld).xlsx` — voorbeeld van een ingevulde
|
||||
checklist.
|
||||
- Memory `wrangler-secret-bom-footgun` — de BOM-valkuil (geldt voor alle zustersites).
|
||||
- Memory `emdash-schema-lives-in-d1` — schema in D1; veld toevoegen / collectie verwijderen op een live site.
|
||||
- Memory `emdash-media-population` — image-veld opslag/serveer-model + zelf media in de library zetten.
|
||||
- Memory `emdash-passwordless-magic-link` — passwordless auth, invite-bug, en de e-mail-provider-plugin voor magic-link.
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "helvoirt-gevelreiniging",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"deploy": "wrangler deploy",
|
||||
"typecheck": "astro check",
|
||||
"postinstall": "patch-package"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "^13.1.6",
|
||||
"@astrojs/react": "^5.0.0",
|
||||
"@emdash-cms/cloudflare": "^0.1.0",
|
||||
"astro": "^6.1.2",
|
||||
"emdash": "^0.1.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/check": "^0.9.7",
|
||||
"@cloudflare/workers-types": "^4.20260401.1",
|
||||
"patch-package": "^8.0.1",
|
||||
"wrangler": "^4.79.0"
|
||||
},
|
||||
"emdash": {
|
||||
"label": "Helvoirt Gevelreiniging",
|
||||
"seed": "seed/seed.json"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
diff --git a/node_modules/@emdash-cms/admin/dist/index.js b/node_modules/@emdash-cms/admin/dist/index.js
|
||||
index f3735f4..de2e843 100644
|
||||
--- a/node_modules/@emdash-cms/admin/dist/index.js
|
||||
+++ b/node_modules/@emdash-cms/admin/dist/index.js
|
||||
@@ -6569,18 +6569,22 @@ function PortableTextEditor({ value, onChange, placeholder = "Start writing...",
|
||||
function EditorBubbleMenu({ editor }) {
|
||||
const [showLinkInput, setShowLinkInput] = React.useState(false);
|
||||
const [linkUrl, setLinkUrl] = React.useState("");
|
||||
+ const [newTab, setNewTab] = React.useState(false);
|
||||
const inputRef = React.useRef(null);
|
||||
React.useEffect(() => {
|
||||
if (showLinkInput) {
|
||||
- setLinkUrl(editor.getAttributes("link").href || "");
|
||||
+ const onLink = editor.isActive("link");
|
||||
+ setLinkUrl(onLink ? (editor.getAttributes("link").href || "") : "");
|
||||
+ setNewTab(onLink && editor.getAttributes("link").target === "_blank");
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
}
|
||||
}, [showLinkInput, editor]);
|
||||
const handleSetLink = () => {
|
||||
if (linkUrl.trim() === "") editor.chain().focus().extendMarkRange("link").unsetLink().run();
|
||||
- else editor.chain().focus().extendMarkRange("link").setLink({ href: linkUrl.trim() }).run();
|
||||
+ else editor.chain().focus().extendMarkRange("link").setLink({ href: linkUrl.trim(), target: newTab ? "_blank" : null }).run();
|
||||
setShowLinkInput(false);
|
||||
setLinkUrl("");
|
||||
+ setNewTab(false);
|
||||
};
|
||||
const handleRemoveLink = () => {
|
||||
editor.chain().focus().extendMarkRange("link").unsetLink().run();
|
||||
@@ -6616,7 +6620,15 @@ function EditorBubbleMenu({ editor }) {
|
||||
value: linkUrl,
|
||||
onChange: (e) => setLinkUrl(e.target.value),
|
||||
onKeyDown: handleKeyDown,
|
||||
- className: "h-8 w-48 text-sm"
|
||||
+ className: "h-8 w-44 text-sm"
|
||||
+ }),
|
||||
+ /* @__PURE__ */ jsxs("label", {
|
||||
+ className: "flex items-center gap-1 px-1 text-xs whitespace-nowrap select-none cursor-pointer",
|
||||
+ title: "Openen in nieuw tabblad",
|
||||
+ children: [
|
||||
+ /* @__PURE__ */ jsx("input", { type: "checkbox", checked: newTab, onChange: (e) => setNewTab(e.target.checked) }),
|
||||
+ "nieuw tab"
|
||||
+ ]
|
||||
}),
|
||||
/* @__PURE__ */ jsx(Button, {
|
||||
type: "button",
|
||||
@@ -6702,6 +6714,7 @@ function EditorToolbar({ editor, focusMode, onFocusModeChange }) {
|
||||
const [mediaPickerOpen, setMediaPickerOpen] = React.useState(false);
|
||||
const [showLinkPopover, setShowLinkPopover] = React.useState(false);
|
||||
const [linkUrl, setLinkUrl] = React.useState("");
|
||||
+ const [newTab, setNewTab] = React.useState(false);
|
||||
const toolbarRef = React.useRef(null);
|
||||
const linkInputRef = React.useRef(null);
|
||||
const editorState = useEditorState({
|
||||
@@ -6729,15 +6742,18 @@ function EditorToolbar({ editor, focusMode, onFocusModeChange }) {
|
||||
});
|
||||
React.useEffect(() => {
|
||||
if (showLinkPopover) {
|
||||
- setLinkUrl(editor.getAttributes("link").href || "");
|
||||
+ const onLink = editor.isActive("link");
|
||||
+ setLinkUrl(onLink ? (editor.getAttributes("link").href || "") : "");
|
||||
+ setNewTab(onLink && editor.getAttributes("link").target === "_blank");
|
||||
setTimeout(() => linkInputRef.current?.focus(), 0);
|
||||
}
|
||||
}, [showLinkPopover, editor]);
|
||||
const handleSetLink = () => {
|
||||
if (linkUrl.trim() === "") editor.chain().focus().extendMarkRange("link").unsetLink().run();
|
||||
- else editor.chain().focus().extendMarkRange("link").setLink({ href: linkUrl.trim() }).run();
|
||||
+ else editor.chain().focus().extendMarkRange("link").setLink({ href: linkUrl.trim(), target: newTab ? "_blank" : null }).run();
|
||||
setShowLinkPopover(false);
|
||||
setLinkUrl("");
|
||||
+ setNewTab(false);
|
||||
};
|
||||
const handleRemoveLink = () => {
|
||||
editor.chain().focus().extendMarkRange("link").unsetLink().run();
|
||||
@@ -6978,6 +6994,14 @@ function EditorToolbar({ editor, focusMode, onFocusModeChange }) {
|
||||
className: "h-8 w-52 text-sm"
|
||||
})
|
||||
}),
|
||||
+ /* @__PURE__ */ jsxs("label", {
|
||||
+ className: "flex items-center gap-1.5 text-xs select-none cursor-pointer",
|
||||
+ title: "Openen in nieuw tabblad",
|
||||
+ children: [
|
||||
+ /* @__PURE__ */ jsx("input", { type: "checkbox", checked: newTab, onChange: (e) => setNewTab(e.target.checked) }),
|
||||
+ "Openen in nieuw tabblad"
|
||||
+ ]
|
||||
+ }),
|
||||
/* @__PURE__ */ jsxs("div", {
|
||||
className: "flex justify-between",
|
||||
children: [/* @__PURE__ */ jsx(Button, {
|
||||
@@ -7892,16 +7916,20 @@ function ContentEditor({ collection, collectionLabel, item, fields, isNew, isSav
|
||||
})) ?? []
|
||||
}));
|
||||
const itemDataString = React.useMemo(() => item ? JSON.stringify(item.data) : "", [item?.data]);
|
||||
+ const loadedIdRef = React.useRef(null);
|
||||
React.useEffect(() => {
|
||||
if (item) {
|
||||
- setFormData(item.data);
|
||||
- setSlug(item.slug || "");
|
||||
- setSlugTouched(!!item.slug);
|
||||
- setStatus(item.status);
|
||||
- setInternalBylines(item.bylines?.map((entry) => ({
|
||||
- bylineId: entry.byline.id,
|
||||
- roleLabel: entry.roleLabel
|
||||
- })) ?? []);
|
||||
+ if (item.id !== loadedIdRef.current) {
|
||||
+ loadedIdRef.current = item.id;
|
||||
+ setFormData(item.data);
|
||||
+ setSlug(item.slug || "");
|
||||
+ setSlugTouched(!!item.slug);
|
||||
+ setStatus(item.status);
|
||||
+ setInternalBylines(item.bylines?.map((entry) => ({
|
||||
+ bylineId: entry.byline.id,
|
||||
+ roleLabel: entry.roleLabel
|
||||
+ })) ?? []);
|
||||
+ }
|
||||
setLastSavedData(serializeEditorState({
|
||||
data: item.data,
|
||||
slug: item.slug || "",
|
||||
@@ -7912,6 +7940,7 @@ function ContentEditor({ collection, collectionLabel, item, fields, isNew, isSav
|
||||
}));
|
||||
}
|
||||
}, [
|
||||
+ item?.id,
|
||||
item?.updatedAt,
|
||||
itemDataString,
|
||||
item?.slug,
|
||||
@@ -8658,7 +8687,7 @@ function ImageFieldRenderer({ label, description, value, onChange, required }) {
|
||||
});
|
||||
};
|
||||
const handleRemove = () => {
|
||||
- onChange(void 0);
|
||||
+ onChange(null);
|
||||
};
|
||||
return /* @__PURE__ */ jsxs("div", { children: [
|
||||
/* @__PURE__ */ jsx(Label, { children: label }),
|
||||
@@ -0,0 +1,135 @@
|
||||
diff --git a/node_modules/emdash/src/astro/routes/api/content/[collection]/[id].ts b/node_modules/emdash/src/astro/routes/api/content/[collection]/[id].ts
|
||||
index 305934b..bbec25b 100644
|
||||
--- a/node_modules/emdash/src/astro/routes/api/content/[collection]/[id].ts
|
||||
+++ b/node_modules/emdash/src/astro/routes/api/content/[collection]/[id].ts
|
||||
@@ -85,7 +85,7 @@ export const PUT: APIRoute = async ({ params, request, locals, cache }) => {
|
||||
|
||||
if (!result.success) return unwrapResult(result);
|
||||
|
||||
- if (cache.enabled) await cache.invalidate({ tags: [collection, resolvedId] });
|
||||
+ if (cache?.enabled) await cache.invalidate({ tags: [collection, resolvedId] });
|
||||
|
||||
return unwrapResult(result);
|
||||
};
|
||||
@@ -131,7 +131,7 @@ export const DELETE: APIRoute = async ({ params, locals, cache }) => {
|
||||
|
||||
if (!result.success) return unwrapResult(result);
|
||||
|
||||
- if (cache.enabled) await cache.invalidate({ tags: [collection, resolvedId] });
|
||||
+ if (cache?.enabled) await cache.invalidate({ tags: [collection, resolvedId] });
|
||||
|
||||
return unwrapResult(result);
|
||||
};
|
||||
diff --git a/node_modules/emdash/src/astro/routes/api/content/[collection]/[id]/discard-draft.ts b/node_modules/emdash/src/astro/routes/api/content/[collection]/[id]/discard-draft.ts
|
||||
index 3044d39..77e2c86 100644
|
||||
--- a/node_modules/emdash/src/astro/routes/api/content/[collection]/[id]/discard-draft.ts
|
||||
+++ b/node_modules/emdash/src/astro/routes/api/content/[collection]/[id]/discard-draft.ts
|
||||
@@ -48,7 +48,7 @@ export const POST: APIRoute = async ({ params, locals, cache }) => {
|
||||
|
||||
if (!result.success) return unwrapResult(result);
|
||||
|
||||
- if (cache.enabled) await cache.invalidate({ tags: [collection, id] });
|
||||
+ if (cache?.enabled) await cache.invalidate({ tags: [collection, id] });
|
||||
|
||||
return unwrapResult(result);
|
||||
};
|
||||
diff --git a/node_modules/emdash/src/astro/routes/api/content/[collection]/[id]/duplicate.ts b/node_modules/emdash/src/astro/routes/api/content/[collection]/[id]/duplicate.ts
|
||||
index 546bdf6..d5107fc 100644
|
||||
--- a/node_modules/emdash/src/astro/routes/api/content/[collection]/[id]/duplicate.ts
|
||||
+++ b/node_modules/emdash/src/astro/routes/api/content/[collection]/[id]/duplicate.ts
|
||||
@@ -55,7 +55,7 @@ export const POST: APIRoute = async ({ params, locals, cache }) => {
|
||||
|
||||
if (!result.success) return unwrapResult(result);
|
||||
|
||||
- if (cache.enabled) await cache.invalidate({ tags: [collection] });
|
||||
+ if (cache?.enabled) await cache.invalidate({ tags: [collection] });
|
||||
|
||||
return unwrapResult(result, 201);
|
||||
};
|
||||
diff --git a/node_modules/emdash/src/astro/routes/api/content/[collection]/[id]/permanent.ts b/node_modules/emdash/src/astro/routes/api/content/[collection]/[id]/permanent.ts
|
||||
index b4bb72f..e919fe5 100644
|
||||
--- a/node_modules/emdash/src/astro/routes/api/content/[collection]/[id]/permanent.ts
|
||||
+++ b/node_modules/emdash/src/astro/routes/api/content/[collection]/[id]/permanent.ts
|
||||
@@ -27,7 +27,7 @@ export const DELETE: APIRoute = async ({ params, locals, cache }) => {
|
||||
|
||||
if (!result.success) return unwrapResult(result);
|
||||
|
||||
- if (cache.enabled) await cache.invalidate({ tags: [collection, id] });
|
||||
+ if (cache?.enabled) await cache.invalidate({ tags: [collection, id] });
|
||||
|
||||
return unwrapResult(result);
|
||||
};
|
||||
diff --git a/node_modules/emdash/src/astro/routes/api/content/[collection]/[id]/publish.ts b/node_modules/emdash/src/astro/routes/api/content/[collection]/[id]/publish.ts
|
||||
index e203608..1a9e024 100644
|
||||
--- a/node_modules/emdash/src/astro/routes/api/content/[collection]/[id]/publish.ts
|
||||
+++ b/node_modules/emdash/src/astro/routes/api/content/[collection]/[id]/publish.ts
|
||||
@@ -50,7 +50,7 @@ export const POST: APIRoute = async ({ params, locals, cache }) => {
|
||||
|
||||
if (!result.success) return unwrapResult(result);
|
||||
|
||||
- if (cache.enabled) await cache.invalidate({ tags: [collection, resolvedId] });
|
||||
+ if (cache?.enabled) await cache.invalidate({ tags: [collection, resolvedId] });
|
||||
|
||||
return unwrapResult(result);
|
||||
};
|
||||
diff --git a/node_modules/emdash/src/astro/routes/api/content/[collection]/[id]/restore.ts b/node_modules/emdash/src/astro/routes/api/content/[collection]/[id]/restore.ts
|
||||
index dd3bc91..770582e 100644
|
||||
--- a/node_modules/emdash/src/astro/routes/api/content/[collection]/[id]/restore.ts
|
||||
+++ b/node_modules/emdash/src/astro/routes/api/content/[collection]/[id]/restore.ts
|
||||
@@ -48,7 +48,7 @@ export const POST: APIRoute = async ({ params, locals, cache }) => {
|
||||
|
||||
if (!result.success) return unwrapResult(result);
|
||||
|
||||
- if (cache.enabled) await cache.invalidate({ tags: [collection, id] });
|
||||
+ if (cache?.enabled) await cache.invalidate({ tags: [collection, id] });
|
||||
|
||||
return unwrapResult(result);
|
||||
};
|
||||
diff --git a/node_modules/emdash/src/astro/routes/api/content/[collection]/[id]/schedule.ts b/node_modules/emdash/src/astro/routes/api/content/[collection]/[id]/schedule.ts
|
||||
index 9bdc6a4..67f2cae 100644
|
||||
--- a/node_modules/emdash/src/astro/routes/api/content/[collection]/[id]/schedule.ts
|
||||
+++ b/node_modules/emdash/src/astro/routes/api/content/[collection]/[id]/schedule.ts
|
||||
@@ -63,7 +63,7 @@ export const POST: APIRoute = async ({ params, request, locals, cache }) => {
|
||||
|
||||
if (!result.success) return unwrapResult(result);
|
||||
|
||||
- if (cache.enabled) await cache.invalidate({ tags: [collection, resolvedId ?? id] });
|
||||
+ if (cache?.enabled) await cache.invalidate({ tags: [collection, resolvedId ?? id] });
|
||||
|
||||
return unwrapResult(result);
|
||||
};
|
||||
@@ -95,7 +95,7 @@ export const DELETE: APIRoute = async ({ params, locals, cache }) => {
|
||||
|
||||
if (!result.success) return unwrapResult(result);
|
||||
|
||||
- if (cache.enabled) await cache.invalidate({ tags: [collection, resolvedId ?? id] });
|
||||
+ if (cache?.enabled) await cache.invalidate({ tags: [collection, resolvedId ?? id] });
|
||||
|
||||
return unwrapResult(result);
|
||||
};
|
||||
diff --git a/node_modules/emdash/src/astro/routes/api/content/[collection]/[id]/unpublish.ts b/node_modules/emdash/src/astro/routes/api/content/[collection]/[id]/unpublish.ts
|
||||
index 8034759..29e6ee1 100644
|
||||
--- a/node_modules/emdash/src/astro/routes/api/content/[collection]/[id]/unpublish.ts
|
||||
+++ b/node_modules/emdash/src/astro/routes/api/content/[collection]/[id]/unpublish.ts
|
||||
@@ -50,7 +50,7 @@ export const POST: APIRoute = async ({ params, locals, cache }) => {
|
||||
|
||||
if (!result.success) return unwrapResult(result);
|
||||
|
||||
- if (cache.enabled) await cache.invalidate({ tags: [collection, resolvedId] });
|
||||
+ if (cache?.enabled) await cache.invalidate({ tags: [collection, resolvedId] });
|
||||
|
||||
return unwrapResult(result);
|
||||
};
|
||||
diff --git a/node_modules/emdash/src/astro/routes/api/content/[collection]/index.ts b/node_modules/emdash/src/astro/routes/api/content/[collection]/index.ts
|
||||
index 1ebf36a..d009d88 100644
|
||||
--- a/node_modules/emdash/src/astro/routes/api/content/[collection]/index.ts
|
||||
+++ b/node_modules/emdash/src/astro/routes/api/content/[collection]/index.ts
|
||||
@@ -53,7 +53,7 @@ export const POST: APIRoute = async ({ params, request, locals, cache }) => {
|
||||
|
||||
if (!result.success) return unwrapResult(result);
|
||||
|
||||
- if (cache.enabled) await cache.invalidate({ tags: [collection] });
|
||||
+ if (cache?.enabled) await cache.invalidate({ tags: [collection] });
|
||||
|
||||
return unwrapResult(result, 201);
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
# Cloudflare Workers Static Assets honoreert dit bestand.
|
||||
# Versie-vaste assets: far-future immutable caching. HTML wordt NIET gecached
|
||||
# (SSR per request) — daar zetten we alleen security-headers.
|
||||
|
||||
/images/*
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
|
||||
/fonts/*
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
|
||||
/*
|
||||
X-Content-Type-Options: nosniff
|
||||
X-Frame-Options: SAMEORIGIN
|
||||
Referrer-Policy: strict-origin-when-cross-origin
|
||||
Permissions-Policy: geolocation=(), microphone=(), camera=()
|
||||
|
After Width: | Height: | Size: 565 B |
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 34 34" width="34" height="34">
|
||||
<rect width="34" height="34" rx="8" fill="#0069ff"/>
|
||||
<g fill="#fff">
|
||||
<rect x="9.5" y="9" width="4.5" height="16" rx="0.5"/>
|
||||
<rect x="20" y="9" width="4.5" height="16" rx="0.5"/>
|
||||
<rect x="9.5" y="14.75" width="15" height="4.5" rx="0.5"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 351 B |
|
After Width: | Height: | Size: 277 KiB |
|
After Width: | Height: | Size: 530 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 270 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 665 KiB |
|
After Width: | Height: | Size: 218 KiB |
|
After Width: | Height: | Size: 448 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 269 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 718 KiB |
|
After Width: | Height: | Size: 155 KiB |
|
After Width: | Height: | Size: 280 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 637 KiB |
|
After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 309 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 831 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 337 KiB |
|
After Width: | Height: | Size: 188 KiB |
|
After Width: | Height: | Size: 374 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 169 KiB |
|
After Width: | Height: | Size: 368 KiB |
|
After Width: | Height: | Size: 334 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 177 KiB |
|
After Width: | Height: | Size: 600 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 187 KiB |
|
After Width: | Height: | Size: 418 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 287 KiB |
@@ -0,0 +1,9 @@
|
||||
# Helvoirt Gevelreiniging - robots
|
||||
# Alles indexeerbaar behalve het CMS-admin en de API.
|
||||
|
||||
User-agent: *
|
||||
Allow: /
|
||||
Disallow: /_emdash/
|
||||
Disallow: /api/
|
||||
|
||||
Sitemap: https://helvoirtgevelreiniging.nl/sitemap.xml
|
||||
@@ -0,0 +1,50 @@
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
BASE = "http://localhost:4321"
|
||||
ROUTES = ["/", "/diensten", "/diensten/gevelreiniging", "/diensten/voegwerk",
|
||||
"/diensten/dakreiniging-coating", "/diensten/schoorsteen-voegwerk",
|
||||
"/diensten/algemeen-reinigen", "/over-ons", "/contact"]
|
||||
|
||||
def scroll_through(page):
|
||||
h = page.evaluate("document.body.scrollHeight")
|
||||
for y in range(0, h, 500):
|
||||
page.evaluate(f"window.scrollTo(0,{y})"); page.wait_for_timeout(40)
|
||||
page.evaluate("window.scrollTo(0,0)"); page.wait_for_timeout(250)
|
||||
|
||||
with sync_playwright() as p:
|
||||
b = p.chromium.launch(headless=True)
|
||||
|
||||
print("=== DESKTOP 1440: axe / images / headings ===")
|
||||
ctx = b.new_context(viewport={"width": 1440, "height": 900})
|
||||
pg = ctx.new_page()
|
||||
for r in ROUTES:
|
||||
pg.goto(BASE + r, wait_until="networkidle", timeout=60000); pg.wait_for_timeout(400)
|
||||
scroll_through(pg)
|
||||
pg.add_script_tag(url="https://cdnjs.cloudflare.com/ajax/libs/axe-core/4.10.2/axe.min.js")
|
||||
pg.wait_for_timeout(300)
|
||||
viol = pg.evaluate("""async()=>{const r=await axe.run(document,{runOnly:['wcag2a','wcag2aa','wcag21aa']});return r.violations.map(v=>v.impact+':'+v.id+'x'+v.nodes.length);}""")
|
||||
imgs = pg.evaluate("""()=>{const a=[...document.images];return {total:a.length,noalt:a.filter(i=>i.getAttribute('alt')===null).length,eager:a.filter(i=>i.loading!=='lazy').map(i=>i.currentSrc.split('/').pop())};}""")
|
||||
heads = pg.eval_on_selector_all("h1,h2,h3", "e=>e.map(x=>x.tagName)")
|
||||
print(f"{r:32} axe:{'clean' if not viol else viol} imgs:{imgs['total']} noalt:{imgs['noalt']} eager:{imgs['eager']} h1x{heads.count('H1')} hcount:{len(heads)}")
|
||||
ctx.close()
|
||||
|
||||
print("\n=== OVERFLOW 1440/768/390 ===")
|
||||
for r in ROUTES:
|
||||
flags = []
|
||||
for w in (1440, 768, 390):
|
||||
c = b.new_context(viewport={"width": w, "height": 900}); pp = c.new_page()
|
||||
pp.goto(BASE + r, wait_until="networkidle", timeout=60000)
|
||||
sw = pp.evaluate("document.documentElement.scrollWidth"); cw = pp.evaluate("document.documentElement.clientWidth")
|
||||
flags.append(f"{w}:{'OVERFLOW' if sw > cw + 1 else 'ok'}"); c.close()
|
||||
print(f"{r:32} {', '.join(flags)}")
|
||||
|
||||
print("\n=== MOBIEL 390: tapdoelen <44px (zichtbare controls) ===")
|
||||
m = b.new_context(viewport={"width": 390, "height": 844}); mp = m.new_page()
|
||||
for r in ["/", "/contact", "/diensten/gevelreiniging", "/over-ons"]:
|
||||
mp.goto(BASE + r, wait_until="networkidle", timeout=60000); mp.wait_for_timeout(500)
|
||||
small = mp.evaluate("""()=>{const els=[...document.querySelectorAll('a,button,input,select,textarea')];const o=[];
|
||||
for(const e of els){const r=e.getBoundingClientRect();if(r.width===0||r.height===0)continue;const s=getComputedStyle(e);if(s.visibility==='hidden'||s.display==='none')continue;
|
||||
if(r.height<44||r.width<44){const inline=(e.tagName==='A'&&s.display.includes('inline')&&!e.className);o.push((e.tagName.toLowerCase())+'.'+(String(e.className).split(' ')[0]||'?')+' '+Math.round(r.width)+'x'+Math.round(r.height)+(inline?' (inline)':''));}}
|
||||
return o;}""")
|
||||
print(f"{r:30} {len(small)} controls <44px: {small[:10]}")
|
||||
b.close()
|
||||
@@ -0,0 +1,47 @@
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
with sync_playwright() as p:
|
||||
b = p.chromium.launch(headless=True)
|
||||
|
||||
# Touch-targets op mobiel (390)
|
||||
pg = b.new_context(viewport={"width": 390, "height": 844}).new_page()
|
||||
pg.goto("http://localhost:4321/", wait_until="networkidle", timeout=60000)
|
||||
print("=== HERO TOUCH-TARGETS @390 (min 44px) ===")
|
||||
for name, sel in {
|
||||
"CTA primair": ".hero__cta-primary",
|
||||
"CTA secundair (link)": ".hero__cta-secondary",
|
||||
"card (swipe)": ".hero__card",
|
||||
}.items():
|
||||
el = pg.query_selector(sel)
|
||||
if el:
|
||||
box = el.bounding_box()
|
||||
if box:
|
||||
flag = "" if min(box["width"], box["height"]) >= 44 else " <-- < 44"
|
||||
print(f" {name:22} {box['width']:.0f} x {box['height']:.0f}{flag}")
|
||||
else:
|
||||
print(f" {name:22} (niet gevonden)")
|
||||
|
||||
# Hero gallery: scrollbaar op mobiel?
|
||||
rail = pg.query_selector(".hero__rail")
|
||||
if rail:
|
||||
sw = pg.eval_on_selector(".hero__rail", "e=>e.scrollWidth")
|
||||
cw = pg.eval_on_selector(".hero__rail", "e=>e.clientWidth")
|
||||
print(f"\n mobiele rail: scrollWidth={sw} clientWidth={cw} swipebaar={'ja' if sw>cw+1 else 'nee'}")
|
||||
|
||||
# Hero image-attrs (desktop)
|
||||
d = b.new_context(viewport={"width": 1440, "height": 900}).new_page()
|
||||
d.goto("http://localhost:4321/", wait_until="networkidle", timeout=60000)
|
||||
print("\n=== HERO GALLERY IMAGES (desktop, geladen bron) ===")
|
||||
imgs = d.eval_on_selector_all(".hero__shot img",
|
||||
"els=>els.map(e=>({src:e.currentSrc.split('/').pop(), w:e.naturalWidth, loading:e.loading, dec:e.decoding}))")
|
||||
for i in imgs:
|
||||
print(" ", i)
|
||||
|
||||
# LCP-kandidaat (grootste element boven de vouw) - ruwe indicatie
|
||||
print("\n=== H1 ===")
|
||||
h1 = d.query_selector(".hero__title")
|
||||
if h1:
|
||||
bb = h1.bounding_box()
|
||||
print(f" h1 box: {bb['width']:.0f} x {bb['height']:.0f} @ top={bb['y']:.0f}")
|
||||
|
||||
b.close()
|
||||
@@ -0,0 +1,32 @@
|
||||
from playwright.sync_api import sync_playwright
|
||||
import json
|
||||
with sync_playwright() as p:
|
||||
b = p.chromium.launch(headless=True)
|
||||
pg = b.new_context(viewport={"width":1440,"height":1000}).new_page()
|
||||
pg.goto("http://localhost:4321/", wait_until="networkidle", timeout=60000)
|
||||
try:
|
||||
pg.add_script_tag(url="https://cdnjs.cloudflare.com/ajax/libs/axe-core/4.10.2/axe.min.js")
|
||||
pg.wait_for_timeout(500)
|
||||
res = pg.evaluate("""async () => {
|
||||
const r = await axe.run(document, {runOnly:['wcag2a','wcag2aa','wcag21aa']});
|
||||
return r.violations.map(v=>({id:v.id, impact:v.impact, n:v.nodes.length,
|
||||
help:v.help, sample:(v.nodes[0]&&v.nodes[0].target)||[]}));
|
||||
}""")
|
||||
print("=== AXE VIOLATIONS ===")
|
||||
for v in res:
|
||||
print(f"[{v['impact']}] {v['id']} x{v['n']} :: {v['help']}")
|
||||
print(f" e.g. {v['sample']}")
|
||||
if not res: print("geen axe-violations")
|
||||
except Exception as e:
|
||||
print("AXE FAILED:", e)
|
||||
# heading outline
|
||||
heads = pg.eval_on_selector_all("h1,h2,h3,h4", "els=>els.map(e=>e.tagName+': '+e.textContent.trim().slice(0,42))")
|
||||
print("\n=== HEADINGS ==="); [print(h) for h in heads]
|
||||
# horizontale overflow
|
||||
for w in (1440, 768, 390):
|
||||
ctx = b.new_context(viewport={"width":w,"height":900})
|
||||
pp = ctx.new_page(); pp.goto("http://localhost:4321/", wait_until="networkidle", timeout=60000)
|
||||
sw = pp.evaluate("document.documentElement.scrollWidth"); cw = pp.evaluate("document.documentElement.clientWidth")
|
||||
print(f"\nviewport {w}: scrollWidth={sw} clientWidth={cw} overflow={'JA' if sw>cw+1 else 'nee'}")
|
||||
ctx.close()
|
||||
b.close()
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Verwijder de ingebouwde emdash-standaardcollecties "pages" en "posts".
|
||||
-- Ze zijn leeg en worden door de site niet gebruikt (wij gebruiken paginas/locaties).
|
||||
-- Emdash maakt defaults alléén bij setup aan (niet per boot), dus dit blijft weg
|
||||
-- zolang de setup-wizard niet opnieuw draait.
|
||||
DROP TABLE IF EXISTS ec_pages;
|
||||
DROP TABLE IF EXISTS ec_posts;
|
||||
DROP TABLE IF EXISTS _emdash_fts_pages;
|
||||
DROP TABLE IF EXISTS _emdash_fts_posts;
|
||||
DELETE FROM _emdash_fields WHERE collection_id IN (SELECT id FROM _emdash_collections WHERE slug IN ('pages','posts'));
|
||||
DELETE FROM _emdash_collections WHERE slug IN ('pages','posts');
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Contactpagina (wave 4) — alleen de introteksten + SEO. NAW (telefoon/e-mail/adres)
|
||||
* komt al uit `site_settings`; de labels (Bel ons/Mail ons/Adres) blijven in code.
|
||||
*/
|
||||
export const contact = [
|
||||
{
|
||||
slug: "contact_page",
|
||||
label: "Contactpagina",
|
||||
labelSingular: "Contactpagina",
|
||||
fields: [
|
||||
{ slug: "eyebrow", label: "Kicker", type: "string" },
|
||||
{ slug: "title", label: "Titel (H1)", type: "string", required: true },
|
||||
{ slug: "intro", label: "Intro", type: "text", required: true },
|
||||
{ slug: "meta_title", label: "SEO · paginatitel", type: "string" },
|
||||
{ slug: "meta_description", label: "SEO · omschrijving", type: "text" },
|
||||
],
|
||||
content: [
|
||||
{
|
||||
slug: "main",
|
||||
status: "published",
|
||||
data: {
|
||||
eyebrow: "Contact",
|
||||
title: "Vraag een gratis offerte aan",
|
||||
intro: "Laat je gegevens achter, dan plannen we een moment om langs te komen en het werk te bekijken. Je krijgt een heldere prijs op papier, zonder verplichtingen. Liever even bellen of mailen? Dat kan ook.",
|
||||
meta_title: "Contact en gratis offerte · Helvoirt Gevelreiniging",
|
||||
meta_description: "Neem contact op met Helvoirt Gevelreiniging voor een gratis, vrijblijvende offerte. Bel, mail of laat je gegevens achter via het formulier.",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,356 @@
|
||||
/**
|
||||
* Diensten (wave 3) — collectie van 5 dienstpagina's, gedeeld door:
|
||||
* - de homepage-accordion (nav_* velden)
|
||||
* - het /diensten-overzicht (nav_* velden)
|
||||
* - de /diensten/<slug>-detailpagina's (alle velden)
|
||||
* `data` = exact de huidige hardcoded tekst → de site blijft identiek zodra de pagina's
|
||||
* uit het CMS lezen (met de code als fallback). Beeldvelden blijven leeg → code valt terug
|
||||
* op de huidige statische foto's (/images/dienst-*.webp, werk-*.webp); de SEO-partij kan
|
||||
* later een eigen foto uploaden per veld.
|
||||
*/
|
||||
export const diensten = [
|
||||
{
|
||||
slug: "diensten",
|
||||
label: "Diensten",
|
||||
labelSingular: "Dienst",
|
||||
supports: ["drafts", "revisions"],
|
||||
fields: [
|
||||
// --- Menu/overzicht (homepage-accordion + /diensten) ---
|
||||
{ slug: "nav_order", label: "Volgorde in het menu (1-5)", type: "integer" },
|
||||
{ slug: "nav_title", label: "Menu/overzicht · titel", type: "string", required: true },
|
||||
{ slug: "nav_text", label: "Menu/overzicht · korte tekst", type: "text", required: true },
|
||||
{ slug: "nav_image", label: "Overzicht · afbeelding (leeg = standaardfoto)", type: "image" },
|
||||
{ slug: "nav_alt", label: "Overzicht · alt-tekst", type: "string" },
|
||||
// --- Detail-hero ---
|
||||
{ slug: "title", label: "Detailpagina · titel (H1)", type: "string", required: true },
|
||||
{ slug: "eyebrow", label: "Detailpagina · kicker", type: "string" },
|
||||
{ slug: "lead", label: "Detailpagina · introzin (hero)", type: "text", required: true },
|
||||
{ slug: "hero_image", label: "Hero · afbeelding (leeg = standaardfoto)", type: "image" },
|
||||
{ slug: "hero_alt", label: "Hero · alt-tekst", type: "string" },
|
||||
{ slug: "hero_caption", label: "Hero · bijschrift", type: "string" },
|
||||
{ slug: "points", label: "Hero · vinkjes (rijen)", type: "repeater", required: false,
|
||||
subFields: [{ slug: "text", type: "string", label: "Tekst", required: true }] },
|
||||
// --- Intro + 'wat we doen' ---
|
||||
{ slug: "intro_heading", label: "Intro · kop", type: "string", required: true },
|
||||
{ slug: "intro_body", label: "Intro · alinea's (rijen)", type: "repeater", required: false,
|
||||
subFields: [{ slug: "text", type: "text", label: "Alinea", required: true }] },
|
||||
{ slug: "included_title", label: "Wat we doen · kop", type: "string" },
|
||||
{ slug: "included", label: "Wat we doen · punten (rijen)", type: "repeater", required: false,
|
||||
subFields: [{ slug: "text", type: "string", label: "Punt", required: true }] },
|
||||
// --- Resultaat-galerij (3 vaste slots; beeld leeg = standaardfoto) ---
|
||||
{ slug: "resultaat_heading", label: "Resultaat · kop", type: "string" },
|
||||
{ slug: "resultaat_sub", label: "Resultaat · subtekst", type: "text" },
|
||||
{ slug: "result_1_image", label: "Resultaat 1 · afbeelding (leeg = standaardfoto)", type: "image" },
|
||||
{ slug: "result_1_alt", label: "Resultaat 1 · alt-tekst", type: "string" },
|
||||
{ slug: "result_1_caption", label: "Resultaat 1 · bijschrift", type: "string" },
|
||||
{ slug: "result_1_detail", label: "Resultaat 1 · detailtekst", type: "text" },
|
||||
{ slug: "result_2_image", label: "Resultaat 2 · afbeelding (leeg = standaardfoto)", type: "image" },
|
||||
{ slug: "result_2_alt", label: "Resultaat 2 · alt-tekst", type: "string" },
|
||||
{ slug: "result_2_caption", label: "Resultaat 2 · bijschrift", type: "string" },
|
||||
{ slug: "result_2_detail", label: "Resultaat 2 · detailtekst", type: "text" },
|
||||
{ slug: "result_3_image", label: "Resultaat 3 · afbeelding (leeg = standaardfoto)", type: "image" },
|
||||
{ slug: "result_3_alt", label: "Resultaat 3 · alt-tekst", type: "string" },
|
||||
{ slug: "result_3_caption", label: "Resultaat 3 · bijschrift", type: "string" },
|
||||
{ slug: "result_3_detail", label: "Resultaat 3 · detailtekst", type: "text" },
|
||||
// --- Aanpak-stappen ---
|
||||
{ slug: "aanpak_title", label: "Aanpak · kop", type: "string" },
|
||||
{ slug: "aanpak", label: "Aanpak · stappen (rijen)", type: "repeater", required: false,
|
||||
subFields: [
|
||||
{ slug: "title", type: "string", label: "Titel", required: true },
|
||||
{ slug: "text", type: "text", label: "Tekst" },
|
||||
] },
|
||||
// --- FAQ ---
|
||||
{ slug: "faq", label: "FAQ · vragen (rijen)", type: "repeater", required: false,
|
||||
subFields: [
|
||||
{ slug: "question", type: "string", label: "Vraag", required: true },
|
||||
{ slug: "answer", type: "text", label: "Antwoord" },
|
||||
] },
|
||||
// --- SEO ---
|
||||
{ slug: "meta_title", label: "SEO · paginatitel", type: "string" },
|
||||
{ slug: "meta_description", label: "SEO · omschrijving", type: "text" },
|
||||
{ slug: "noindex", label: "SEO · uitsluiten van Google (noindex)", type: "boolean" },
|
||||
],
|
||||
content: [
|
||||
{
|
||||
slug: "gevelreiniging",
|
||||
status: "published",
|
||||
data: {
|
||||
nav_order: 1,
|
||||
nav_title: "Gevelreiniging + renovatie",
|
||||
nav_text: "Jaren aanslag, groene vervuiling en vuil weg, en herstel waar nodig. De gevel staat er weer strak bij, zonder schade aan de steen.",
|
||||
nav_alt: "Bakstenen gevel half gereinigd: links nog vuil, rechts weer schoon",
|
||||
title: "Gevelreiniging en renovatie",
|
||||
eyebrow: "Onze diensten",
|
||||
lead: "Jaren van aanslag, groene vervuiling en vuil halen we van je gevel, en waar nodig herstellen we de steen en het voegwerk. Je huis staat er weer strak en verzorgd bij, zonder schade aan de gevel.",
|
||||
hero_alt: "Bakstenen gevel halverwege de reiniging: links nog vuil, rechts weer schoon",
|
||||
hero_caption: "Halverwege de reiniging",
|
||||
points: [{ text: "Veilig voor de steen" }, { text: "Herstel inbegrepen" }, { text: "Eerlijke prijs vooraf" }],
|
||||
intro_heading: "Een schone, gezonde gevel die er weer staat als nieuw",
|
||||
intro_body: [
|
||||
{ text: "Een vieze gevel is meer dan een kwestie van uiterlijk. Groene aanslag, algen en mos houden vocht vast, en dat tast op den duur de steen en het voegwerk aan. Wij halen die laag eraf met een methode die past bij jouw gevel, en pakken zwakke plekken meteen mee." },
|
||||
{ text: "Of het nu om een rijtjeshuis gaat of om een hele bedrijfsgevel: je krijgt vooraf een vaste prijs en een duidelijke planning. We werken netjes, dekken alles goed af en leveren schoon op." },
|
||||
],
|
||||
included_title: "Wat we voor je doen",
|
||||
included: [
|
||||
{ text: "Groene aanslag, algen en mos verwijderen" },
|
||||
{ text: "Vuil, roet en uitslag van de gevel halen" },
|
||||
{ text: "De juiste reinigingsmethode per steensoort" },
|
||||
{ text: "Loszittend of beschadigd voegwerk herstellen" },
|
||||
{ text: "Optioneel impregneren tegen nieuwe vervuiling" },
|
||||
{ text: "Alles netjes afgedekt en opgeruimd" },
|
||||
],
|
||||
resultaat_heading: "Zo ziet het resultaat eruit",
|
||||
resultaat_sub: "Een greep uit ons werk: schone gevels, hersteld voegwerk en steen die er weer verzorgd bij staat.",
|
||||
result_1_alt: "Schone bakstenen gevel na het verwijderen van aanslag en mos",
|
||||
result_1_caption: "Aanslag en mos verwijderd",
|
||||
result_1_detail: "Groene vervuiling en vuil weg, zonder de steen te beschadigen.",
|
||||
result_2_alt: "Strak hersteld voegwerk tussen de bakstenen",
|
||||
result_2_caption: "Voegwerk hersteld",
|
||||
result_2_detail: "Loszittende en kapotte voegen vervangen, weer netjes en waterdicht.",
|
||||
result_3_alt: "Verzorgde, schone gevel van een woonhuis",
|
||||
result_3_caption: "Strak en verzorgd",
|
||||
result_3_detail: "De gevel staat er weer als nieuw bij, klaar voor jaren vooruit.",
|
||||
aanpak_title: "Zo pakken we het aan",
|
||||
aanpak: [
|
||||
{ title: "Inspectie en advies", text: "We bekijken de steen en de mate van vervuiling, en kiezen de methode die schoon maakt zonder de gevel te beschadigen." },
|
||||
{ title: "Reinigen op maat", text: "Van een zachte lagedruk-reiniging tot een steviger aanpak bij hardnekkige aanslag, afgestemd op jouw gevel." },
|
||||
{ title: "Herstel waar nodig", text: "Loszittend of kapot voegwerk en kleine beschadigingen pakken we in dezelfde klus mee." },
|
||||
{ title: "Strak opgeleverd", text: "We spoelen na, ruimen alles op en lopen het samen na, zodat je tevreden achterblijft." },
|
||||
],
|
||||
faq: [
|
||||
{ question: "Beschadigt gevelreiniging de steen?", answer: "Niet als het goed gebeurt. We stemmen de druk en het reinigingsmiddel af op de steensoort, zodat de gevel schoon wordt zonder dat de steen of de voegen eronder lijden." },
|
||||
{ question: "Welke methode gebruiken jullie?", answer: "Dat hangt van de gevel af. Soms volstaat een zachte lagedruk-reiniging, bij zware aanslag is een steviger aanpak nodig. We kiezen altijd wat veilig en effectief is voor jouw situatie." },
|
||||
{ question: "Hoe lang blijft de gevel schoon?", answer: "Dat verschilt per ligging en omgeving. Een gevel op het noorden vervuilt sneller dan een zonnige kant. Met een impregneerlaag gaat het langer mee, omdat vuil en vocht minder grip krijgen." },
|
||||
{ question: "Doen jullie ook het voegwerk?", answer: "Ja. Loszittend of beschadigd voegwerk herstellen we in dezelfde klus, zodat de gevel niet alleen schoon is maar ook weer heel en waterdicht." },
|
||||
],
|
||||
meta_title: "Gevelreiniging en renovatie in Helvoirt en omgeving · Helvoirt Gevelreiniging",
|
||||
meta_description: "Gevelreiniging en renovatie door Helvoirt Gevelreiniging. Aanslag en groene vervuiling weg, herstel van steen en voegwerk, veilig voor de gevel. Vraag een gratis offerte aan.",
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: "voegwerk",
|
||||
status: "published",
|
||||
data: {
|
||||
nav_order: 2,
|
||||
nav_title: "Voegwerkzaamheden",
|
||||
nav_text: "Oud en kapot voegwerk uithakken en opnieuw invoegen. Dat houdt de gevel waterdicht en netjes, passend bij het huis.",
|
||||
nav_alt: "Strak nieuw voegwerk tussen rode bakstenen",
|
||||
title: "Voegwerkzaamheden",
|
||||
eyebrow: "Onze diensten",
|
||||
lead: "Oud, uitgesleten of kapot voegwerk vervangen we vakkundig. We hakken de slechte voegen uit en voegen opnieuw in, in een kleur en stijl die bij je huis passen. Zo blijft de gevel waterdicht en netjes.",
|
||||
hero_alt: "Strak nieuw voegwerk tussen rode bakstenen van een gevel",
|
||||
hero_caption: "Vers ingevoegd",
|
||||
points: [{ text: "Past bij je gevel" }, { text: "Weer waterdicht" }, { text: "Eerlijke prijs vooraf" }],
|
||||
intro_heading: "Goed voegwerk houdt je gevel heel en droog",
|
||||
intro_body: [
|
||||
{ text: "Voegen zijn meer dan een afwerking. Ze houden het vocht buiten en de stenen op hun plek. Als de voegen uitslijten, verzanden of loslaten, krijgt water vrij spel en kan er vorstschade en lekkage ontstaan. Wij hakken het slechte voegwerk uit en voegen opnieuw in." },
|
||||
{ text: "We stemmen de kleur en de vorm van de voeg af op je huis, zodat het werk niet opvalt maar gewoon klopt. Bij oudere panden werken we waar nodig met een ademende kalkmortel." },
|
||||
],
|
||||
included_title: "Wat we voor je doen",
|
||||
included: [
|
||||
{ text: "Oude en kapotte voegen uithakken" },
|
||||
{ text: "Opnieuw invoegen in de juiste kleur en vorm" },
|
||||
{ text: "Knip-, snij- of platvol voegwerk naar wens" },
|
||||
{ text: "Passende mortel, ook voor oudere panden" },
|
||||
{ text: "Scheuren en kleine beschadigingen meenemen" },
|
||||
{ text: "Netjes afgewerkt en opgeruimd" },
|
||||
],
|
||||
resultaat_heading: "Zo ziet het resultaat eruit",
|
||||
resultaat_sub: "Een greep uit ons voegwerk: strak ingevoegd, waterdicht en passend bij de gevel.",
|
||||
result_1_alt: "Strak nieuw voegwerk tussen de bakstenen van een gevel",
|
||||
result_1_caption: "Strak ingevoegd",
|
||||
result_1_detail: "Uitgehakt en opnieuw gevoegd in een passende kleur.",
|
||||
result_2_alt: "Hersteld metsel- en voegwerk aan een tuinmuur met trap",
|
||||
result_2_caption: "Metsel- en voegwerk hersteld",
|
||||
result_2_detail: "Loszittende stenen en voegen weer vast en netjes.",
|
||||
result_3_alt: "Verzorgde gevel met strak voegwerk",
|
||||
result_3_caption: "Verzorgd geheel",
|
||||
result_3_detail: "Schone steen en strak voegwerk, weer als nieuw.",
|
||||
aanpak_title: "Zo pakken we het aan",
|
||||
aanpak: [
|
||||
{ title: "Inspectie en advies", text: "We bekijken de voegen en de steen, en bepalen welke mortel en voegvorm passen." },
|
||||
{ title: "Uithakken", text: "Het oude, slechte voegwerk halen we netjes weg tot op de juiste diepte." },
|
||||
{ title: "Opnieuw invoegen", text: "We voegen in met de juiste mortel, kleur en afwerking voor jouw gevel." },
|
||||
{ title: "Strak opgeleverd", text: "We borstelen na, ruimen op en lopen het samen na." },
|
||||
],
|
||||
faq: [
|
||||
{ question: "Wanneer is voegwerk aan vervanging toe?", answer: "Als voegen verzanden, uitslijten, loslaten of als je vocht of vorstschade ziet. Wij beoordelen het vrijblijvend en zeggen eerlijk of het nodig is." },
|
||||
{ question: "Kunnen jullie de kleur van de voeg aanpassen?", answer: "Ja. We stemmen de kleur en de vorm af op je huis, zodat het nieuwe voegwerk natuurlijk overgaat in het bestaande beeld." },
|
||||
{ question: "Doen jullie ook oudere of monumentale panden?", answer: "Ja. Bij oudere panden gebruiken we waar nodig een ademende kalkmortel, zodat de muur droog kan blijven ademen." },
|
||||
{ question: "Moet de hele gevel opnieuw gevoegd worden?", answer: "Niet altijd. Soms volstaat het om de slechte vlakken aan te pakken. We adviseren wat verstandig en kostenefficiënt is." },
|
||||
],
|
||||
meta_title: "Voegwerkzaamheden in Helvoirt en omgeving · Helvoirt Gevelreiniging",
|
||||
meta_description: "Voegwerk vervangen en herstellen door Helvoirt Gevelreiniging. Oude voegen uithakken en opnieuw invoegen in de juiste kleur en mortel, weer waterdicht. Vraag een gratis offerte aan.",
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: "dakreiniging-coating",
|
||||
status: "published",
|
||||
data: {
|
||||
nav_order: 3,
|
||||
nav_title: "Dak reinigen + coating",
|
||||
nav_text: "Mos en aanslag eraf, daarna een beschermende coating. Een schoon dak dat water beter afvoert en langer mooi blijft.",
|
||||
nav_alt: "Schoon dakpannendak na reiniging en coating",
|
||||
title: "Dak reinigen en coaten",
|
||||
eyebrow: "Onze diensten",
|
||||
lead: "Mos, algen en aanslag halen we van je dak, daarna brengen we waar gewenst een beschermende coating aan. Een schoon dak dat water beter afvoert en langer mooi en heel blijft.",
|
||||
hero_alt: "Schoon dakpannendak na reiniging en coating",
|
||||
hero_caption: "Schoon en beschermd",
|
||||
points: [{ text: "Mos en aanslag eraf" }, { text: "Beschermende coating" }, { text: "Eerlijke prijs vooraf" }],
|
||||
intro_heading: "Een schoon dak gaat langer mee",
|
||||
intro_body: [
|
||||
{ text: "Mos en algen houden vocht vast en kunnen pannen poreus maken en de waterafvoer verstoppen. Wij reinigen het dak grondig en verwijderen de begroeiing, zodat het water weer vrij wegloopt en je dak er weer netjes bij ligt." },
|
||||
{ text: "Daarna kun je kiezen voor een beschermende coating. Die sluit de pannen af, gaat nieuwe aangroei tegen en geeft het dak weer een egale, frisse kleur." },
|
||||
],
|
||||
included_title: "Wat we voor je doen",
|
||||
included: [
|
||||
{ text: "Mos, algen en aanslag verwijderen" },
|
||||
{ text: "Dak grondig reinigen, veilig op hoogte" },
|
||||
{ text: "Dakgoten en afvoer meenemen" },
|
||||
{ text: "Optioneel een beschermende coating" },
|
||||
{ text: "Kleurherstel voor een egaal, fris dak" },
|
||||
{ text: "Afgespoelde resten netjes opgeruimd" },
|
||||
],
|
||||
resultaat_heading: "Van mos tot een schoon dak",
|
||||
resultaat_sub: "Mos eraf, grondig gereinigd en waar gewenst beschermend gecoat.",
|
||||
result_1_alt: "Dakpannendak vol mos voor de reiniging",
|
||||
result_1_caption: "Mos en algen verwijderen",
|
||||
result_1_detail: "Begroeiing houdt vocht vast en verstopt de afvoer, die halen we eraf.",
|
||||
result_2_alt: "Dak wordt op hoogte gereinigd",
|
||||
result_2_caption: "Grondig reinigen op hoogte",
|
||||
result_2_detail: "Veilig en zorgvuldig, ook de lastig bereikbare delen.",
|
||||
result_3_alt: "Schoon dakpannendak na reiniging en coating",
|
||||
result_3_caption: "Schoon en beschermd opgeleverd",
|
||||
result_3_detail: "Een egaal, fris dak, klaar voor jaren vooruit.",
|
||||
aanpak_title: "Zo pakken we het aan",
|
||||
aanpak: [
|
||||
{ title: "Inspectie op hoogte", text: "We bekijken het dak, de pannen en de afvoer, en bepalen de juiste aanpak." },
|
||||
{ title: "Reinigen", text: "Mos, algen en aanslag verwijderen we grondig, veilig en netjes op hoogte." },
|
||||
{ title: "Coaten (optioneel)", text: "Een beschermende coating sluit de pannen af en gaat nieuwe aangroei tegen." },
|
||||
{ title: "Strak opgeleverd", text: "We spoelen na, ruimen de resten op en lopen het samen na." },
|
||||
],
|
||||
faq: [
|
||||
{ question: "Is mijn dak geschikt voor coating?", answer: "De meeste pannendaken wel. We beoordelen de staat van de pannen eerst; soms is alleen reinigen verstandiger. We zeggen eerlijk wat het beste is." },
|
||||
{ question: "Hoe lang blijft het dak schoon?", answer: "Dat hangt van de ligging en de begroeiing in de omgeving af. Met een coating gaat het langer mee, omdat mos en vuil minder grip krijgen." },
|
||||
{ question: "Werken jullie veilig op hoogte?", answer: "Ja. We werken met de juiste voorzieningen, zodat het werk veilig en zorgvuldig gebeurt." },
|
||||
{ question: "Beschadigt de reiniging de dakpannen niet?", answer: "Niet als de druk en de methode kloppen. We stemmen die af op de staat van de pannen, zodat het dak schoon wordt zonder schade." },
|
||||
],
|
||||
meta_title: "Dak reinigen en coaten in Helvoirt en omgeving · Helvoirt Gevelreiniging",
|
||||
meta_description: "Dakreiniging en coating door Helvoirt Gevelreiniging. Mos en aanslag eraf, een beschermende coating en betere waterafvoer. Vraag een gratis offerte aan.",
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: "schoorsteen-voegwerk",
|
||||
status: "published",
|
||||
data: {
|
||||
nav_order: 4,
|
||||
nav_title: "Schoorsteen voegwerk",
|
||||
nav_text: "Voeg-, metsel- en loodwerk aan de schoorsteen herstellen, zodat lekkage en verdere schade voorkomen worden.",
|
||||
nav_alt: "Herstelde schoorsteen met nieuw voegwerk op een dak",
|
||||
title: "Schoorsteen voegwerk",
|
||||
eyebrow: "Onze diensten",
|
||||
lead: "Voeg- en metselwerk aan de schoorsteen herstellen we vakkundig, vaak in combinatie met het loodwerk. Zo voorkomen we lekkage en verdere schade, en staat de schoorsteen er weer netjes en veilig bij.",
|
||||
hero_alt: "Herstelde schoorsteen met nieuw voegwerk en loodwerk op een dak",
|
||||
hero_caption: "Schoorsteen hersteld",
|
||||
points: [{ text: "Voorkomt lekkage" }, { text: "Vaak met loodwerk" }, { text: "Eerlijke prijs vooraf" }],
|
||||
intro_heading: "Een schoorsteen die weer waterdicht is",
|
||||
intro_body: [
|
||||
{ text: "De schoorsteen krijgt veel weer te verduren en is een veelvoorkomende bron van lekkage. Loszittend voegwerk, scheuren in het metselwerk of slecht aansluitend lood laten water binnen, soms tot in de woning. Wij herstellen het voeg- en metselwerk en pakken het loodwerk in dezelfde klus mee." },
|
||||
{ text: "Omdat het werk op hoogte gebeurt, werken we veilig en zorgvuldig. Je krijgt vooraf een duidelijke prijs, ook als blijkt dat alleen een deel hersteld hoeft te worden." },
|
||||
],
|
||||
included_title: "Wat we voor je doen",
|
||||
included: [
|
||||
{ text: "Loszittend voegwerk uithakken en opnieuw invoegen" },
|
||||
{ text: "Scheuren en beschadigd metselwerk herstellen" },
|
||||
{ text: "Loodwerk nakijken en waar nodig vervangen" },
|
||||
{ text: "Aansluitingen weer waterdicht maken" },
|
||||
{ text: "Veilig werken op hoogte" },
|
||||
{ text: "Netjes opgeleverd en opgeruimd" },
|
||||
],
|
||||
resultaat_heading: "Zo ziet het resultaat eruit",
|
||||
resultaat_sub: "Schoorstenen met hersteld voeg-, metsel- en loodwerk, weer waterdicht en netjes.",
|
||||
result_1_alt: "Schoorsteen met nieuw aangebracht loodwerk op een dak",
|
||||
result_1_caption: "Nieuw loodwerk",
|
||||
result_1_detail: "Strak aangesloten lood rond de schoorsteen, weer waterdicht.",
|
||||
result_2_alt: "Hersteld metsel- en voegwerk",
|
||||
result_2_caption: "Metselwerk hersteld",
|
||||
result_2_detail: "Loszittende stenen en voegen weer vast en netjes.",
|
||||
result_3_alt: "Herstelde schoorsteen met nieuw voegwerk op een dak",
|
||||
result_3_caption: "Schoorsteen weer netjes",
|
||||
result_3_detail: "Hersteld voeg- en loodwerk, klaar voor jaren vooruit.",
|
||||
aanpak_title: "Zo pakken we het aan",
|
||||
aanpak: [
|
||||
{ title: "Inspectie op hoogte", text: "We bekijken het voeg-, metsel- en loodwerk en sporen de oorzaak van lekkage op." },
|
||||
{ title: "Herstellen", text: "Slecht voegwerk hakken we uit, scheuren en metselwerk herstellen we." },
|
||||
{ title: "Loodwerk waterdicht", text: "Het lood kijken we na en vervangen we waar nodig, zodat alles weer goed aansluit." },
|
||||
{ title: "Veilig opgeleverd", text: "We ruimen op en lopen het werk samen na." },
|
||||
],
|
||||
faq: [
|
||||
{ question: "Hoe weet ik of mijn schoorsteen lekt?", answer: "Vochtplekken op zolder of rond de schoorsteen, of een muffe geur, wijzen vaak op een lekkende schoorsteen. We zoeken vrijblijvend de oorzaak op." },
|
||||
{ question: "Doen jullie ook het loodwerk?", answer: "Ja. Voeg-, metsel- en loodwerk pakken we het liefst in een klus, want de lekkage zit vaak in de combinatie van die drie." },
|
||||
{ question: "Moet de schoorsteen helemaal opnieuw?", answer: "Lang niet altijd. Vaak volstaat herstel van de slechte plekken. We zeggen eerlijk wat nodig is." },
|
||||
{ question: "Werken jullie veilig op hoogte?", answer: "Ja. We werken met de juiste voorzieningen, zodat het werk veilig en netjes gebeurt." },
|
||||
],
|
||||
meta_title: "Schoorsteen voegwerk en herstel in Helvoirt en omgeving · Helvoirt Gevelreiniging",
|
||||
meta_description: "Schoorsteen voegwerk, metselwerk en loodwerk herstellen door Helvoirt Gevelreiniging. Voorkom lekkage en schade, weer waterdicht. Vraag een gratis offerte aan.",
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: "algemeen-reinigen",
|
||||
status: "published",
|
||||
data: {
|
||||
nav_order: 5,
|
||||
nav_title: "Algemeen reinigen",
|
||||
nav_text: "Terras, oprit, bestrating en ander buitenwerk. We pakken het hele plaatje aan en maken de buitenkant weer fris.",
|
||||
nav_alt: "Terras en bestrating worden schoongespoten",
|
||||
title: "Algemeen reinigen",
|
||||
eyebrow: "Onze diensten",
|
||||
lead: "Terras, oprit, bestrating en ander buitenwerk maken we weer fris. We pakken het hele plaatje rond je huis aan, zodat de buitenkant er weer verzorgd en veilig bij ligt.",
|
||||
hero_alt: "Terras en bestrating worden schoongespoten",
|
||||
hero_caption: "Bestrating weer fris",
|
||||
points: [{ text: "Terras en oprit" }, { text: "Heel het buitenwerk" }, { text: "Eerlijke prijs vooraf" }],
|
||||
intro_heading: "De hele buitenkant weer fris",
|
||||
intro_body: [
|
||||
{ text: "Niet alleen de gevel, ook het terras, de oprit, de bestrating en ander buitenwerk vervuilen en vergroenen na verloop van tijd. Groene aanslag maakt bestrating bovendien glad en onveilig. Wij reinigen het grondig, zodat alles er weer netjes en veilig bij ligt." },
|
||||
{ text: "We kijken naar het hele plaatje rond je huis en stemmen de aanpak af op de ondergrond, of het nu om sierbestrating, beton of natuursteen gaat." },
|
||||
],
|
||||
included_title: "Wat we voor je doen",
|
||||
included: [
|
||||
{ text: "Terras, oprit en bestrating reinigen" },
|
||||
{ text: "Groene aanslag en onkruid aanpakken" },
|
||||
{ text: "Tuinmuren, schuttingen en bijgebouwen" },
|
||||
{ text: "Aanpak afgestemd op de ondergrond" },
|
||||
{ text: "Optioneel opnieuw invegen van voegzand" },
|
||||
{ text: "Alles netjes opgeruimd en afgespoeld" },
|
||||
],
|
||||
resultaat_heading: "Zo ziet het resultaat eruit",
|
||||
resultaat_sub: "Een greep uit ons werk: terrassen, bestrating en muren weer schoon en fris.",
|
||||
result_1_alt: "Schone tuinmuur en bestrating na reiniging",
|
||||
result_1_caption: "Tuinmuur en bestrating",
|
||||
result_1_detail: "Muur en terras weer schoon en verzorgd.",
|
||||
result_2_alt: "Schone tuintrap en metselwerk",
|
||||
result_2_caption: "Tuintrap en metselwerk",
|
||||
result_2_detail: "Bestrating en trap weer netjes en veilig.",
|
||||
result_3_alt: "Vers gereinigd terras en bestrating",
|
||||
result_3_caption: "Terras weer fris",
|
||||
result_3_detail: "Groene aanslag eraf, de bestrating is weer veilig.",
|
||||
aanpak_title: "Zo pakken we het aan",
|
||||
aanpak: [
|
||||
{ title: "Inspectie en advies", text: "We bekijken het buitenwerk en de ondergrond, en kiezen de juiste methode." },
|
||||
{ title: "Reinigen op maat", text: "Met de juiste druk halen we aanslag en vuil weg, zonder de bestrating te beschadigen." },
|
||||
{ title: "Afwerken", text: "Waar nodig vegen we voegzand opnieuw in en pakken we onkruid aan." },
|
||||
{ title: "Strak opgeleverd", text: "We spoelen na, ruimen op en lopen het samen na." },
|
||||
],
|
||||
faq: [
|
||||
{ question: "Wat kunnen jullie allemaal reinigen?", answer: "Terras, oprit, bestrating, tuinmuren, schuttingen en bijgebouwen. Vrijwel al het buitenwerk rond je huis." },
|
||||
{ question: "Beschadigt hogedruk de bestrating niet?", answer: "Niet als de druk klopt. We stemmen die af op de ondergrond, zodat het schoon wordt zonder schade." },
|
||||
{ question: "Vegen jullie het voegzand opnieuw in?", answer: "Dat kan. Na het reinigen kunnen we het voegzand aanvullen, zodat de bestrating stevig blijft liggen." },
|
||||
{ question: "Kan dit samen met de gevelreiniging?", answer: "Zeker. Veel klanten combineren het, dan staat de hele buitenkant in een keer weer fris. Dat scheelt ook in de kosten." },
|
||||
],
|
||||
meta_title: "Terras, oprit en bestrating reinigen in Helvoirt en omgeving · Helvoirt Gevelreiniging",
|
||||
meta_description: "Algemeen reinigen door Helvoirt Gevelreiniging: terras, oprit, bestrating en ander buitenwerk weer fris en veilig. Vraag een gratis offerte aan.",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||