commit 276dba07a2b50d0d4ce19db3a1f1001af82c1f67 Author: Joery Disselhof Date: Wed Aug 26 13:57:08 2026 +0200 Eerste commit voor Helvoirt Gevelreiniging diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4876083 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..559a1ac --- /dev/null +++ b/CLAUDE.md @@ -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 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 ` (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=` (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 --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` → `

`; 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 ``) + 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/`), 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. diff --git a/Checklist - Websites (template, leeg).xlsx b/Checklist - Websites (template, leeg).xlsx new file mode 100644 index 0000000..bd6226c Binary files /dev/null and b/Checklist - Websites (template, leeg).xlsx differ diff --git a/Checklist - Websites - helvoirt (ingevuld).xlsx b/Checklist - Websites - helvoirt (ingevuld).xlsx new file mode 100644 index 0000000..e21825a Binary files /dev/null and b/Checklist - Websites - helvoirt (ingevuld).xlsx differ diff --git a/GO-LIVE.md b/GO-LIVE.md new file mode 100644 index 0000000..3cb58bc --- /dev/null +++ b/GO-LIVE.md @@ -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//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' '' | 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','',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 | `@` | `` | 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) | `._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 `` 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.** diff --git a/Helvoirtgevelreiniging.nl checklist.xlsx b/Helvoirtgevelreiniging.nl checklist.xlsx new file mode 100644 index 0000000..02edb4a Binary files /dev/null and b/Helvoirtgevelreiniging.nl checklist.xlsx differ diff --git a/architecture.md b/architecture.md new file mode 100644 index 0000000..c93f86a --- /dev/null +++ b/architecture.md @@ -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 → /, 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 ` + + + +
Hetzelfde herenhuis na reiniging, met strakke schone gevel in de zon
Bakstenen herenhuis met jaren aanslag en groene vervuiling op de gevel

Weer trots op je gevel.

We halen jaren aanslag, groen en vuil weg en herstellen voegwerk, dak en schoorsteen. Sleep de balk en zie het verschil.

  • 4,9Google-beoordeling
  • Helvoirt e.o.en omgeving
  • Gratisofferte aan huis

+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 & zakelijkvan woning tot VvE
  • Eerlijke prijsvooraf op papier
  • Netjes werkenschoon opgeleverd

Van eerste belletje tot schoon opgeleverd

  1. 1

    Bellen of mailen

    Je vraagt een gratis offerte aan. Vertel kort om welk werk het gaat.

  2. 2

    We komen kijken

    We bekijken het werk ter plekke, meten op en denken met je mee.

  3. 3

    Heldere offerte

    Je krijgt een duidelijke prijs op papier. Geen verrassingen achteraf.

  4. 4

    Schoon opgeleverd

    We voeren het netjes uit, ruimen op en lopen het samen na.

Echt werk, echte resultaten

Alle projecten
Voegwerk aan de gevel
Voegwerk aan de gevel
Schoorsteen met nieuw loodwerk
Schoorsteen met nieuw loodwerk
Dak vol mos, voor reiniging
Dak vol mos, voor reiniging
Tuinmuur en bestrating
Tuinmuur en bestrating
Dakreiniging op hoogte
Dakreiniging op hoogte
Metselwerk, tuintrap
Metselwerk, tuintrap

Wat klanten zeggen

4,9 gemiddeld op Google

Snel een eerlijke offerte gekregen en het voegwerk is prachtig geworden.

Peter de Vries

Dak laten reinigen en coaten. Goede communicatie en een mooi resultaat.

M. Bakker

Werkzaam in Helvoirt en omgeving

Staat jouw plaats er niet bij? Bel of mail gerust, vaak komen we er ook.

  • Helvoirt
  • Vught
  • 's-Hertogenbosch
  • Boxtel
  • Oisterwijk
  • Haaren
  • Esch
  • Sint-Michielsgestel
  • Berlicum
  • Udenhout
  • Tilburg
  • Cromvoirt

Gratis en vrijblijvend

Vraag een offerte aan

Laat je gegevens achter, dan plannen we een moment om langs te komen en de gevel te bekijken. Je krijgt een heldere prijs, zonder verplichtingen.

+Gratis offerte aanvragen + \ No newline at end of file diff --git a/input/Content.docx b/input/Content.docx new file mode 100644 index 0000000..7d43961 Binary files /dev/null and b/input/Content.docx differ diff --git a/input/foto's/WhatsApp Image 2026-05-20 at 14.38.23(1).jpeg b/input/foto's/WhatsApp Image 2026-05-20 at 14.38.23(1).jpeg new file mode 100644 index 0000000..bb8d4cd Binary files /dev/null and b/input/foto's/WhatsApp Image 2026-05-20 at 14.38.23(1).jpeg differ diff --git a/input/foto's/WhatsApp Image 2026-05-20 at 14.38.23(2).jpeg b/input/foto's/WhatsApp Image 2026-05-20 at 14.38.23(2).jpeg new file mode 100644 index 0000000..069b0c7 Binary files /dev/null and b/input/foto's/WhatsApp Image 2026-05-20 at 14.38.23(2).jpeg differ diff --git a/input/foto's/WhatsApp Image 2026-05-20 at 14.38.23.jpeg b/input/foto's/WhatsApp Image 2026-05-20 at 14.38.23.jpeg new file mode 100644 index 0000000..639c02b Binary files /dev/null and b/input/foto's/WhatsApp Image 2026-05-20 at 14.38.23.jpeg differ diff --git a/input/foto's/WhatsApp Image 2026-05-20 at 14.38.24(1).jpeg b/input/foto's/WhatsApp Image 2026-05-20 at 14.38.24(1).jpeg new file mode 100644 index 0000000..45a1f2d Binary files /dev/null and b/input/foto's/WhatsApp Image 2026-05-20 at 14.38.24(1).jpeg differ diff --git a/input/foto's/WhatsApp Image 2026-05-20 at 14.38.24(2).jpeg b/input/foto's/WhatsApp Image 2026-05-20 at 14.38.24(2).jpeg new file mode 100644 index 0000000..edc3604 Binary files /dev/null and b/input/foto's/WhatsApp Image 2026-05-20 at 14.38.24(2).jpeg differ diff --git a/input/foto's/WhatsApp Image 2026-05-20 at 14.38.24.jpeg b/input/foto's/WhatsApp Image 2026-05-20 at 14.38.24.jpeg new file mode 100644 index 0000000..2428c91 Binary files /dev/null and b/input/foto's/WhatsApp Image 2026-05-20 at 14.38.24.jpeg differ diff --git a/migrations/0001_contact_submissions.sql b/migrations/0001_contact_submissions.sql new file mode 100644 index 0000000..3e55a92 --- /dev/null +++ b/migrations/0001_contact_submissions.sql @@ -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); diff --git a/migrations/0002_over_ons.sql b/migrations/0002_over_ons.sql new file mode 100644 index 0000000..2bf0f8a --- /dev/null +++ b/migrations/0002_over_ons.sql @@ -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.', '', 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.', '', 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.', '', 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.', '', 4); diff --git a/migrations/0003_over_ons_repeater.sql b/migrations/0003_over_ons_repeater.sql new file mode 100644 index 0000000..b894f3e --- /dev/null +++ b/migrations/0003_over_ons_repeater.sql @@ -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":""},{"title":"Echt vakwerk","text":"We kennen ons vak en de materialen. Elke gevel, elk dak en elke schoorsteen krijgt de juiste aanpak.","icon":""},{"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":""},{"title":"Lokaal en betrokken","text":"We werken in Helvoirt en omgeving. Korte lijnen, snel even langs en altijd persoonlijk aanspreekbaar.","icon":""}]' 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'; diff --git a/migrations/0004_over_ons_images.sql b/migrations/0004_over_ons_images.sql new file mode 100644 index 0000000..f402dd9 --- /dev/null +++ b/migrations/0004_over_ons_images.sql @@ -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); diff --git a/migrations/0005_clean_seo_labels.sql b/migrations/0005_clean_seo_labels.sql new file mode 100644 index 0000000..42de9fe --- /dev/null +++ b/migrations/0005_clean_seo_labels.sql @@ -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'); diff --git a/migrations/0006_over_ons_media.sql b/migrations/0006_over_ons_media.sql new file mode 100644 index 0000000..11c7fe4 --- /dev/null +++ b/migrations/0006_over_ons_media.sql @@ -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'; diff --git a/migrations/0007_homepage.sql b/migrations/0007_homepage.sql new file mode 100644 index 0000000..e246a05 --- /dev/null +++ b/migrations/0007_homepage.sql @@ -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."}]'); diff --git a/migrations/0008_contact.sql b/migrations/0008_contact.sql new file mode 100644 index 0000000..27cfff3 --- /dev/null +++ b/migrations/0008_contact.sql @@ -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.'); diff --git a/migrations/0009_diensten.sql b/migrations/0009_diensten.sql new file mode 100644 index 0000000..5213792 --- /dev/null +++ b/migrations/0009_diensten.sql @@ -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.'); diff --git a/migrations/0010_diensten_media.sql b/migrations/0010_diensten_media.sql new file mode 100644 index 0000000..7e85ba9 --- /dev/null +++ b/migrations/0010_diensten_media.sql @@ -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'; diff --git a/migrations/0011_homepage_media.sql b/migrations/0011_homepage_media.sql new file mode 100644 index 0000000..c5c7e4a --- /dev/null +++ b/migrations/0011_homepage_media.sql @@ -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); diff --git a/migrations/0012_voorwaarden.sql b/migrations/0012_voorwaarden.sql new file mode 100644 index 0000000..f5a98e6 --- /dev/null +++ b/migrations/0012_voorwaarden.sql @@ -0,0 +1,11 @@ +-- Auto-gegenereerd door scripts/gen-cms-sql.mjs — NIET met de hand bewerken. + +-- ===== collectie: voorwaarden (Algemene voorwaarden) ===== +INSERT INTO _emdash_collections (id, slug, label, label_singular, supports, source, has_seo) VALUES ('01KVANTH7RRAEETMC9B30R4WBE', 'voorwaarden', 'Algemene voorwaarden', 'Algemene voorwaarden', '["drafts","revisions"]', 'seed', 0); +CREATE TABLE "ec_voorwaarden" ("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 '', "intro" TEXT, "articles" JSON, "meta_title" TEXT, "meta_description" TEXT, constraint "ec_voorwaarden_slug_locale_unique" unique ("slug", "locale")); +INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KVANTH7SAN3ZZFKA1NAC86QJ', '01KVANTH7RRAEETMC9B30R4WBE', 'title', '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 ('01KVANTH7SF5XD0XC1AZEA5BR5', '01KVANTH7RRAEETMC9B30R4WBE', 'intro', 'Intro', 'text', 'TEXT', 0, 1, 0, 1); +INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable, validation) VALUES ('01KVANTH7S8BT06E98EQPJH1YW', '01KVANTH7RRAEETMC9B30R4WBE', 'articles', 'Artikelen (rijen)', 'repeater', 'JSON', 0, 2, 0, 1, '{"subFields":[{"slug":"heading","type":"string","label":"Artikelkop","required":true},{"slug":"body","type":"text","label":"Artikeltekst","required":true}],"minItems":0,"maxItems":20}'); +INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) VALUES ('01KVANTH7SGB40409JTGNS50YE', '01KVANTH7RRAEETMC9B30R4WBE', '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 ('01KVANTH7T6JST04XQ64ME6ETD', '01KVANTH7RRAEETMC9B30R4WBE', 'meta_description', 'SEO · omschrijving', 'text', 'TEXT', 0, 4, 0, 1); +INSERT INTO "ec_voorwaarden" ("id", "slug", "status", "locale", "title", "intro", "articles", "meta_title", "meta_description") VALUES ('01KVANTH7TNG27BR7HQB847QW1', 'main', 'published', 'en', 'Algemene voorwaarden', 'Deze algemene voorwaarden zijn van toepassing op alle aanbiedingen, offertes en overeenkomsten tussen Helvoirt Gevelreiniging en de klant.', '[{"heading":"Artikel 1. Algemeen","body":"Deze voorwaarden gelden voor alle aanbiedingen en overeenkomsten tussen Helvoirt Gevelreiniging en de klant, tenzij schriftelijk uitdrukkelijk anders is overeengekomen. Ze gelden ook voor overeenkomsten waarbij derden worden ingeschakeld en voor alle medewerkers van het bedrijf. Eventuele inkoop- of andere voorwaarden van de klant worden niet aanvaard."},{"heading":"Artikel 2. Offertes en aanbiedingen","body":"Alle offertes en aanbiedingen blijven twee (2) maanden geldig, tenzij anders vermeld. Helvoirt Gevelreiniging is niet gebonden aan een offerte als de klant redelijkerwijs kan begrijpen dat deze een kennelijke vergissing of verschrijving bevat. Een offerte geldt voor het beschreven werk; vervolgopdrachten vallen er niet automatisch onder."},{"heading":"Artikel 3. Contractduur, termijnen, uitvoering en wijziging","body":"Tenzij anders afgesproken is de overeenkomst aangegaan voor de duur van de opdracht. Genoemde termijnen zijn een indicatie en gelden nimmer als fatale termijnen; bij buitenwerk kunnen weersomstandigheden de planning beinvloeden. Wijzigingen in de opdracht worden in overleg afgesproken en kunnen de prijs en de planning beinvloeden. De klant zorgt voor toegang tot het werk en, waar nodig, voor water en elektriciteit."},{"heading":"Artikel 4. Opschorting, ontbinding en tussentijdse opzegging","body":"Helvoirt Gevelreiniging mag de nakoming van haar verplichtingen opschorten of de overeenkomst ontbinden als de klant zijn verplichtingen niet of niet volledig nakomt, of bij omstandigheden die nakoming redelijkerwijs onmogelijk maken. Wordt de overeenkomst ontbonden door toedoen van de klant, dan is de klant een schadevergoeding verschuldigd van 30% van het totaalbedrag van de geaccordeerde offerte, onverminderd het recht op vergoeding van de werkelijk gemaakte kosten. Bij annulering van een opdracht vergoedt de klant de reeds gemaakte kosten."},{"heading":"Artikel 5. Overmacht","body":"Helvoirt Gevelreiniging is niet gehouden tot het nakomen van een verplichting als zij daartoe verhinderd is door overmacht: omstandigheden buiten haar schuld die nakoming tijdelijk of blijvend verhinderen. Gedurende de overmacht worden de wederzijdse verplichtingen opgeschort."},{"heading":"Artikel 6. Betaling en incassokosten","body":"Betaling vindt contant of per overschrijving plaats direct na uitvoering van de werkzaamheden, tenzij schriftelijk anders is afgesproken. Bij niet-tijdige betaling is de klant van rechtswege in verzuim en is hij de wettelijke rente verschuldigd, met een minimum van 1% per maand. De buitengerechtelijke incassokosten bedragen 15% van de hoofdsom, met een minimum van 150,00 euro."},{"heading":"Artikel 7. Eigendomsvoorbehoud","body":"Alle door Helvoirt Gevelreiniging geleverde en gebruikte zaken blijven haar eigendom totdat de klant alle verplichtingen uit de overeenkomst volledig is nagekomen. De klant mag deze zaken niet bezwaren of vervreemden, dient ze te verzekeren tegen brand-, water- en diefstalschade, en geeft op verzoek de locatie van de zaken op."},{"heading":"Artikel 8. Garanties en reclames","body":"Helvoirt Gevelreiniging staat in voor de kwaliteit van het geleverde werk. De garantietermijn bedraagt vijf (5) jaar op reparaties en herstelwerk, een (1) jaar op reinigingswerk en noodreparaties, en tien (10) jaar op volledige renovaties. Gebreken dienen binnen acht (8) dagen na ontdekking schriftelijk te worden gemeld. De garantie vervalt bij onjuist gebruik of onderhoud, bij gebreken aan de bestaande ondergrond die vooraf niet kenbaar waren, of bij werkzaamheden van derden aan hetzelfde werk."},{"heading":"Artikel 9. Aansprakelijkheid","body":"De aansprakelijkheid van Helvoirt Gevelreiniging is beperkt tot directe schade en tot ten hoogste driemaal de factuurwaarde van de betreffende opdracht. Aansprakelijkheid voor indirecte schade, waaronder gevolgschade, gederfde winst en gemiste besparingen, is uitgesloten. De aansprakelijkheid is in alle gevallen beperkt tot het bedrag dat de verzekeraar in het voorkomende geval uitkeert."},{"heading":"Artikel 10. Toepasselijk recht en geschillen","body":"Op alle overeenkomsten is uitsluitend Nederlands recht van toepassing. Geschillen worden voorgelegd aan de bevoegde rechter in het arrondissement waar Helvoirt Gevelreiniging is gevestigd. Partijen zullen pas een beroep op de rechter doen nadat zij zich tot het uiterste hebben ingespannen om het geschil in onderling overleg op te lossen."}]', 'Algemene voorwaarden · Helvoirt Gevelreiniging', 'De algemene voorwaarden van Helvoirt Gevelreiniging: offertes, uitvoering, betaling, garantie en aansprakelijkheid.'); diff --git a/migrations/0013_paginas_embed.sql b/migrations/0013_paginas_embed.sql new file mode 100644 index 0000000..465c8a6 --- /dev/null +++ b/migrations/0013_paginas_embed.sql @@ -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 diff --git a/src/components/ContactForm.astro b/src/components/ContactForm.astro new file mode 100644 index 0000000..cd79ea4 --- /dev/null +++ b/src/components/ContactForm.astro @@ -0,0 +1,51 @@ +--- +// Herbruikbaar offerte/contactformulier. Styling (.cform*) en de submit-JS +// staan globaal in Base.astro (binden op #contactForm). Dit component levert +// alleen de markup. Eén instantie per pagina (prefooter OF /contact). +const ts = Date.now(); +--- + +
+
+ + +
+ + + + + + + + +
+ +
+ +
+
diff --git a/src/components/Hero.astro b/src/components/Hero.astro new file mode 100644 index 0000000..c8fd790 --- /dev/null +++ b/src/components/Hero.astro @@ -0,0 +1,339 @@ +--- +interface HeroCard { + src: string; + srcset?: string; + title: string; + sub: string; + alt: string; +} +interface Props { + title?: string; + subtitle?: string; + points?: string[]; + cards?: HeroCard[]; +} + +const { + title = "Je gevel weer als nieuw.", + subtitle = "Van groene aanslag tot kapot voegwerk: wij maken de buitenkant van je huis weer schoon en heel. Netjes opgeleverd, met een eerlijke prijs vooraf.", + points = ["Eerlijke prijs vooraf", "Netjes opgeleverd", "Particulier en zakelijk"], + cards, +} = Astro.props; + +// Hero-cards uit het CMS (collectie hero_cards, via `cards`-prop uit index.astro). Fallback = +// de statische werkfoto's met kleine srcset-varianten (480/768; de cards tonen klein). +const fallbackShots: HeroCard[] = [ + { src: "/images/werk-3.webp", srcset: "/images/werk-3-480.webp 480w, /images/werk-3.webp 738w", title: "Voegwerk", sub: "gevel hersteld", alt: "Strak nieuw voegwerk tussen bakstenen van een gevel" }, + { src: "/images/werk-1-768.webp", srcset: "/images/werk-1-480.webp 480w, /images/werk-1-768.webp 768w", title: "Schoorsteen", sub: "nieuw loodwerk", alt: "Herstelde schoorsteen met nieuw loodwerk op een dak" }, + { src: "/images/werk-5-768.webp", srcset: "/images/werk-5-480.webp 480w, /images/werk-5-768.webp 768w", title: "Dakreiniging", sub: "op hoogte", alt: "Dak wordt op hoogte gereinigd" }, +]; +const shots = cards && cards.length ? cards : fallbackShots; +--- + +
+ +
+
+

{title}

+

{subtitle}

+ + + +
    + {points.map((p) => ( +
  • + + {p} +
  • + ))} +
+ +
+ + 4,9 op Google, beoordeling 4,9 van 5 +
+
+
+ + + +
+ + diff --git a/src/components/PtMark.astro b/src/components/PtMark.astro new file mode 100644 index 0000000..33446e4 --- /dev/null +++ b/src/components/PtMark.astro @@ -0,0 +1,35 @@ +--- +/** + * Custom PortableText mark-renderer (vervangt de standaard `mark`-component van + * astro-portabletext). Reden: links moeten standaard in HETZELFDE tabblad openen, en + * alleen in een nieuw tabblad wanneer de redacteur dat in de editor heeft aangevinkt + * (markDef.blank === true) -> dan target="_blank" + veilige rel. + * + * We behandelen álle standaard-marks (zoals de ingebouwde Mark.astro) zodat vet/cursief/ + * onderstreept blijven werken; alleen `link` is aangepast. Doorgegeven via: + * + * astro-portabletext geeft hier `node` door met `node.markType` en `node.markDef`. + */ +const { node } = Astro.props as { + node?: { markType?: string; markDef?: { href?: string; blank?: boolean } }; +}; +const markType = node?.markType; +const def = node?.markDef ?? {}; +const href = typeof def.href === "string" ? def.href : "#"; +const newTab = def.blank === true; +--- +{markType === "link" ? ( + +) : markType === "strong" ? ( + +) : markType === "em" ? ( + +) : markType === "underline" ? ( + +) : markType === "strike-through" ? ( + +) : markType === "code" ? ( + +) : ( + +)} diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro new file mode 100644 index 0000000..cb7a6c6 --- /dev/null +++ b/src/layouts/Base.astro @@ -0,0 +1,1058 @@ +--- +import "../styles/tokens.css"; +import ContactForm from "../components/ContactForm.astro"; +import { getEmDashEntry, getEmDashCollection } from "emdash"; +import { resolveCmsImage } from "../lib/images"; + +interface Props { + title?: string; + description?: string; + canonical?: string; + ogImage?: string; + /** Sluit de pagina uit van Google (robots: noindex). Voor CMS-pagina's met het noindex-vinkje. */ + noindex?: boolean; + /** Pagina opent met donkere hero — header start transparant, wordt solid bij scroll. */ + heroDark?: boolean; + /** Verberg de globale prefooter (bv. op /contact, die zijn eigen formulier toont). */ + hidePrefooter?: boolean; +} + +const { + title: titleProp, + description: descriptionProp, + canonical, + ogImage: ogImageProp, + noindex = false, + heroDark = false, + hidePrefooter = false, +} = Astro.props; + +// site_settings komt uit de CMS zodra die geseed is; vóór seed gebruiken we fallbacks. +let s: Record = {}; +try { + const { entry } = await getEmDashEntry("site_settings", "main"); + s = (entry?.data ?? {}) as Record; +} catch { + /* nog geen CMS/seed — fallbacks hieronder */ +} + +const phone = (s.phone as string | undefined) ?? "085 060 4956"; // aangeleverd +const phoneTel = `tel:+31${phone.replace(/\D/g, "").replace(/^0/, "")}`; +const email = (s.email as string | undefined) ?? "info@helvoirtgevelreiniging.nl"; // PLACEHOLDER (e-mail nog niet aangeleverd) +const address = (s.address as string | undefined) ?? "Stationsplein 1, 6131 AS Sittard"; // aangeleverd +const kvk = (s.kvk as string | undefined) ?? ""; +const btw = (s.btw as string | undefined) ?? ""; +const werkgebied = (s.werkgebied as string | undefined) ?? "Helvoirt en omgeving"; +const googleReviewsUrl = (s.google_reviews_url as string | undefined) ?? ""; +const overallRating = (s.overall_rating as string | undefined) ?? "4,9"; + +// Google Tag Manager: de klant vult in de admin (Instellingen) alleen de container-ID in +// (GTM-XXXXXX). We accepteren ook een geplakt volledig snippet en vissen de ID eruit; is er +// geen geldige ID, dan injecteren we helemaal niets. +const gtmId = (((s.gtm as string | undefined) ?? "").match(/GTM-[A-Z0-9]+/i)?.[0] ?? "").toUpperCase(); + +// SEO: per-pagina prop > site_settings (Instellingen, bewerkbaar door SEO-partij) > code-default. +const pickStr = (v: unknown): string | undefined => (typeof v === "string" && v.trim() ? v.trim() : undefined); +const DEFAULT_TITLE = "Helvoirt Gevelreiniging · Gevelreiniging, voegwerk en dakcoating"; +const DEFAULT_DESCRIPTION = "Helvoirt Gevelreiniging reinigt en renoveert gevels, voegwerk, daken en schoorstenen. Vakwerk in de regio. Vraag een gratis offerte aan."; +const title = titleProp ?? pickStr(s.meta_title) ?? DEFAULT_TITLE; +const description = descriptionProp ?? pickStr(s.meta_description) ?? DEFAULT_DESCRIPTION; +const ogImage = ogImageProp ?? resolveCmsImage(s.og_image)?.src; + +const fullCanonical = canonical ?? new URL(Astro.url.pathname, Astro.site ?? Astro.url).toString(); + +// OG-image absoluut maken: CMS-media is een relatief pad (/_emdash/api/media/file/...). +// Valt terug op een gebrande 1200x630-default (scripts/make-og.py) zodat delen op social +// altijd een nette kaart toont. +const OG_DEFAULT = "/images/og-default.webp"; +const ogRaw = ogImage ?? OG_DEFAULT; +const ogIsDefault = ogRaw === OG_DEFAULT; +const ogImageUrl = /^https?:\/\//.test(ogRaw) + ? ogRaw + : new URL(ogRaw, Astro.site ?? Astro.url).toString(); + +// Adres voor JSON-LD opsplitsen (NL-formaat "straat, 1234 AB plaats"); valt bij een +// afwijkend formaat terug op het hele adres als streetAddress. +const addrParts = address.split(",").map((p) => p.trim()).filter(Boolean); +const postalAddress = address + ? { + "@type": "PostalAddress", + ...(addrParts.length >= 2 + ? { streetAddress: addrParts[0], addressLocality: addrParts.slice(1).join(", ") } + : { streetAddress: address }), + addressCountry: "NL", + } + : null; + +// LocalBusiness JSON-LD voor zoekmachines (rich-result eligible). HomeAndConstructionBusiness +// is een specifiekere subtype van LocalBusiness en past bij een gevelreinigings-/renovatiebedrijf. +const ratingValue = overallRating.replace(",", "."); +const jsonLd = { + "@context": "https://schema.org", + "@type": "HomeAndConstructionBusiness", + "@id": new URL("/", Astro.site ?? Astro.url).toString() + "#business", + name: "Helvoirt Gevelreiniging", + description, + url: new URL("/", Astro.site ?? Astro.url).toString(), + image: ogImageUrl, + telephone: phone, + email, + ...(postalAddress ? { address: postalAddress } : {}), + ...(kvk ? { taxID: kvk } : {}), + ...(btw ? { vatID: btw } : {}), + areaServed: werkgebied, + ...(googleReviewsUrl ? { sameAs: [googleReviewsUrl] } : {}), + ...(/^\d+(\.\d+)?$/.test(ratingValue) + ? { + aggregateRating: { + "@type": "AggregateRating", + ratingValue, + bestRating: "5", + }, + } + : {}), +}; + +interface NavItem { + label: string; + url: string; + children?: { label: string; url: string }[]; +} + +// Diensten-dropdown uit de CMS-collectie "diensten" (gedeeld met de homepage + /diensten), +// gesorteerd op nav_order; valt terug op de vaste lijst zodra het CMS leeg/onbereikbaar is. +const dienstenChildrenFallback = [ + { label: "Gevelreiniging + renovatie", url: "/diensten/gevelreiniging" }, + { label: "Voegwerkzaamheden", url: "/diensten/voegwerk" }, + { label: "Dak reinigen + coating", url: "/diensten/dakreiniging-coating" }, + { label: "Schoorsteen voegwerk", url: "/diensten/schoorsteen-voegwerk" }, + { label: "Algemeen reinigen", url: "/diensten/algemeen-reinigen" }, +]; +let dienstenChildren = dienstenChildrenFallback; +try { + const { entries } = await getEmDashCollection("diensten", { status: "published" }); + const rows = (entries ?? []) as unknown as Array<{ slug: string; data: Record }>; + if (rows.length) { + dienstenChildren = rows + .slice() + .sort((a, b) => Number(a.data.nav_order ?? 99) - Number(b.data.nav_order ?? 99)) + .map((e) => { + const fb = dienstenChildrenFallback.find((f) => f.url === `/diensten/${e.slug}`); + const label = typeof e.data.nav_title === "string" && e.data.nav_title.trim() ? e.data.nav_title : (fb?.label ?? e.slug); + return { label, url: `/diensten/${e.slug}` }; + }); + } +} catch { + /* CMS nog niet geseed/bereikbaar → vaste lijst */ +} + +const nav: NavItem[] = [ + { label: "Diensten", url: "/diensten", children: dienstenChildren }, + { label: "Over ons", url: "/over-ons" }, + { label: "Contact", url: "/contact" }, +]; + +const path = Astro.url.pathname; +const isActive = (url: string): boolean => + url === "/" ? path === "/" : path === url || path.startsWith(url + "/"); +--- + + + + + + + + {/* Google Tag Manager — alleen aanwezig als de klant een container-ID heeft ingevuld (Instellingen). */} + {gtmId && ( + + )} + + {title} + + + + + + + + + + + + + + {ogIsDefault && ( + <> + + + + + )} + + + + + + + + + + + diff --git a/src/lib/images.ts b/src/lib/images.ts new file mode 100644 index 0000000..df0ee7b --- /dev/null +++ b/src/lib/images.ts @@ -0,0 +1,64 @@ +/** + * Resolve een Emdash image-veldwaarde naar een renderbare URL + metadata. + * + * Een image-veld staat in D1 als JSON (kolomtype TEXT). De vorm verschilt per herkomst: + * - admin-upload (local provider op R2): + * { id, provider:"local", meta:{ storageKey }, width, height, alt, ... } + * `src` wordt bewust NIET opgeslagen; Emdash leidt die op displaytijd af uit de + * storageKey (zie media/normalize.ts → "strip src for local"). + * - directe of externe URL: { id, src } of een kale string. + * De loader kan de waarde als object óf als JSON-string teruggeven; we accepteren beide. + * Local media wordt geserveerd via /_emdash/api/media/file/. + */ +export interface ResolvedImage { + src: string; + width?: number; + height?: number; + alt?: string; +} + +const MEDIA_FILE_PREFIX = "/_emdash/api/media/file/"; + +export function resolveCmsImage(value: unknown): ResolvedImage | null { + if (value == null) return null; + + // Kale string: JSON-object of een directe URL. + if (typeof value === "string") { + const s = value.trim(); + if (!s) return null; + if (s.startsWith("{") || s.startsWith("[")) { + try { + return resolveCmsImage(JSON.parse(s)); + } catch { + // val door: behandel als directe URL + } + } + return { src: s }; + } + + if (typeof value !== "object") return null; + const o = value as Record; + + // Voorkeur: expliciete src (extern of vooraf geresolved). Anders local: bouw de + // file-URL uit meta.storageKey, en val terug op de media-id (storageKey == id). + let src = typeof o.src === "string" && o.src.trim() ? o.src.trim() : undefined; + if (!src) { + const meta = + o.meta && typeof o.meta === "object" ? (o.meta as Record) : undefined; + const key = + typeof meta?.storageKey === "string" && meta.storageKey + ? meta.storageKey + : typeof o.id === "string" && o.id + ? o.id + : undefined; + if (key) src = `${MEDIA_FILE_PREFIX}${key}`; + } + if (!src) return null; + + return { + src, + width: typeof o.width === "number" ? o.width : undefined, + height: typeof o.height === "number" ? o.height : undefined, + alt: typeof o.alt === "string" && o.alt.trim() ? o.alt.trim() : undefined, + }; +} diff --git a/src/lib/img.ts b/src/lib/img.ts new file mode 100644 index 0000000..6d548ac --- /dev/null +++ b/src/lib/img.ts @@ -0,0 +1,48 @@ +/** + * Bouwt een `srcset`-string voor een afbeelding met vooraf gegenereerde + * breedte-varianten (zie scripts/img-variants.py). Voor `/images/foo.webp` + * met varianten `/images/foo-768.webp` etc. + * + * @param base pad naar het origineel, bv. "/images/gevel-hero.webp" + * @param originalWidth intrinsieke breedte van het origineel in px + * @param widths beschikbare variant-breedtes (default 768/1280/1920) + */ +export function buildSrcset( + base: string, + originalWidth: number, + widths: number[] = [768, 1280, 1920], +): string { + const dot = base.lastIndexOf("."); + const stem = base.slice(0, dot); + const ext = base.slice(dot); + const entries = widths + .filter((w) => w < originalWidth) + .map((w) => `${stem}-${w}${ext} ${w}w`); + entries.push(`${base} ${originalWidth}w`); + return entries.join(", "); +} + +/** + * Intrinsieke breedtes van de site-foto's waarvoor 768/1280/1920-varianten + * bestaan (gegenereerd met scripts/img-variants.py). Vullen zodra de echte + * hero/dienst/project-beelden binnen zijn (zie todo §10). + */ +export const IMG_WIDTHS: Record = { + "/images/hero-after.webp": 2752, + "/images/hero-before.webp": 2752, + "/images/hero-gevel.webp": 2752, + "/images/dienst-gevelreiniging.webp": 2400, + "/images/dienst-voegwerk.webp": 2400, + "/images/dienst-dakreiniging.webp": 2400, + "/images/dienst-schoorsteen.webp": 2400, + "/images/dienst-algemeen.webp": 2400, +}; + +/** + * `srcset` voor een bekend `/images/*.webp`-pad, of `undefined` als er geen + * varianten zijn (bv. een CMS-pad). Gebruik samen met een `sizes`-attribuut. + */ +export function srcsetFor(base: string): string | undefined { + const w = IMG_WIDTHS[base]; + return w ? buildSrcset(base, w) : undefined; +} diff --git a/src/lib/smtp.ts b/src/lib/smtp.ts new file mode 100644 index 0000000..fae615e --- /dev/null +++ b/src/lib/smtp.ts @@ -0,0 +1,156 @@ +// Minimale SMTP-over-TLS client voor Cloudflare Workers (cloudflare:sockets). +// Implicit TLS (poort 465) + AUTH LOGIN. Met de hand geschreven i.p.v. een runtime- +// dependency: SMTP is een eenvoudig regel-protocol en nodemailer e.d. draaien niet op +// workerd. Gebruikt door src/pages/api/contact.ts (lead-melding) en +// src/plugins/smtp-email.ts (magic-link login) via de mailbox info@helvoirtgevelreiniging.nl. +import { connect } from "cloudflare:sockets"; + +export interface SmtpConfig { + host: string; + port: number; + username: string; + password: string; +} + +export interface MailMessage { + from: string; // From-header afzender (bv. info@helvoirtgevelreiniging.nl) + fromName?: string; + /** + * Envelope-afzender (MAIL FROM). Default = `from`. Zet dit op de geauthenticeerde + * mailbox als de From-header een ander (zelfde-domein) adres is, zodat de server + * de envelope tegen de login kan matchen en niet weigert. + */ + envelopeFrom?: string; + to: string; + replyTo?: string; + subject: string; + text: string; // platte tekst, UTF-8 +} + +const encoder = new TextEncoder(); + +/** base64 van een UTF-8 string (Workers heeft btoa maar geen Buffer). */ +function b64(input: string): string { + const bytes = encoder.encode(input); + let bin = ""; + for (const byte of bytes) bin += String.fromCharCode(byte); + return btoa(bin); +} + +/** Breek een base64-string af in regels van 76 tekens (RFC 2045). */ +function wrap76(s: string): string { + return (s.match(/.{1,76}/g) ?? []).join("\r\n"); +} + +/** Codeer een header-waarde als RFC 2047 wanneer er niet-ASCII in zit. */ +function encodeHeader(value: string): string { + // eslint-disable-next-line no-control-regex + return /^[\x00-\x7F]*$/.test(value) ? value : `=?UTF-8?B?${b64(value)}?=`; +} + +/** RFC 5322 datum: toUTCString geeft "GMT", vervang door "+0000". */ +function rfcDate(d: Date): string { + return d.toUTCString().replace(/GMT$/, "+0000"); +} + +/** + * Verstuur één e-mail via authenticated SMTP over implicit TLS. + * Gooit bij een onverwacht serverantwoord of timeout; de aanroeper (contact.ts) + * vangt dit en logt het, zodat een mailfout het formulier nooit laat falen. + */ +export async function sendMail( + config: SmtpConfig, + msg: MailMessage, + timeoutMs = 10000, +): Promise { + const socket = connect( + { hostname: config.host, port: config.port }, + { secureTransport: "on", allowHalfOpen: false }, + ); + + const writer = socket.writable.getWriter(); + const reader = socket.readable.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + /** Lees één (mogelijk meerregelig) SMTP-antwoord; geeft de eindcode terug. */ + async function readReply(): Promise<{ code: number; text: string }> { + for (;;) { + const idx = buffer.indexOf("\r\n"); + if (idx !== -1) { + // Scan alle complete regels op een eindregel "NNN ". + const lines = buffer.split("\r\n"); + for (let i = 0; i < lines.length - 1; i++) { + if (/^\d{3} /.test(lines[i])) { + const consumed = lines.slice(0, i + 1).join("\r\n").length + 2; + const text = buffer.slice(0, consumed); + buffer = buffer.slice(consumed); + return { code: Number.parseInt(text.slice(0, 3), 10), text }; + } + } + } + const { value, done } = await reader.read(); + if (done) throw new Error("SMTP: verbinding voortijdig gesloten"); + buffer += decoder.decode(value, { stream: true }); + } + } + + async function cmd(line: string, expect: number[]): Promise { + await writer.write(encoder.encode(line + "\r\n")); + const reply = await readReply(); + if (!expect.includes(reply.code)) { + const verb = line.split(" ", 1)[0] || "DATA"; + throw new Error(`SMTP: onverwacht antwoord op ${verb}: ${reply.text.trim()}`); + } + } + + const exchange = (async () => { + const greeting = await readReply(); + if (greeting.code !== 220) throw new Error(`SMTP: onverwachte begroeting ${greeting.text.trim()}`); + + await cmd(`EHLO helvoirtgevelreiniging.nl`, [250]); + await cmd(`AUTH LOGIN`, [334]); + await cmd(b64(config.username), [334]); + await cmd(b64(config.password), [235]); + await cmd(`MAIL FROM:<${msg.envelopeFrom ?? msg.from}>`, [250]); + await cmd(`RCPT TO:<${msg.to}>`, [250, 251]); + await cmd(`DATA`, [354]); + + const fromHeader = msg.fromName + ? `${encodeHeader(msg.fromName)} <${msg.from}>` + : msg.from; + const headers = [ + `From: ${fromHeader}`, + `To: <${msg.to}>`, + ...(msg.replyTo ? [`Reply-To: <${msg.replyTo}>`] : []), + `Subject: ${encodeHeader(msg.subject)}`, + `Date: ${rfcDate(new Date())}`, + `Message-ID: <${crypto.randomUUID()}@helvoirtgevelreiniging.nl>`, + `MIME-Version: 1.0`, + `Content-Type: text/plain; charset=utf-8`, + `Content-Transfer-Encoding: base64`, + ].join("\r\n"); + + // base64-body bevat nooit een regel die met "." begint, dus dot-stuffing is + // niet nodig; alleen de afsluitende "\r\n.\r\n". + const body = wrap76(b64(msg.text)); + await cmd(`${headers}\r\n\r\n${body}\r\n.`, [250]); + await writer.write(encoder.encode("QUIT\r\n")); + })(); + + let timer: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error("SMTP: timeout")), timeoutMs); + }); + + try { + await Promise.race([exchange, timeout]); + } finally { + if (timer) clearTimeout(timer); + try { + await socket.close(); + } catch { + // socket kan al dicht zijn + } + } +} diff --git a/src/live.config.ts b/src/live.config.ts new file mode 100644 index 0000000..c8c819d --- /dev/null +++ b/src/live.config.ts @@ -0,0 +1,13 @@ +/** + * EmDash Live Content Collections + * + * Defines the _emdash collection that handles all content types from the database. + * Query specific types using getEmDashCollection() and getEmDashEntry(). + */ + +import { defineLiveCollection } from "astro:content"; +import { emdashLoader } from "emdash/runtime"; + +export const collections = { + _emdash: defineLiveCollection({ loader: emdashLoader() }), +}; diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..680af94 --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,24 @@ +import { defineMiddleware } from "astro:middleware"; + +// Canonicaliseer naar https + non-www met een 301: +// - www.helvoirtgevelreiniging.nl -> helvoirtgevelreiniging.nl (apex = voorkeursdomein, matcht canonical). +// - http -> https. Cloudflare termineert TLS aan de edge, dus de origin ziet meestal +// al https; de oorspronkelijke scheme staat in de `cf-visitor`-header. +// Zonder cf-visitor en zonder www (bv. lokale dev / *.workers.dev) doet de middleware +// niets, zodat dev/preview niet breekt. Geen loop: de bestemming is apex + https. +export const onRequest = defineMiddleware((context, next) => { + const url = new URL(context.request.url); + const cfVisitor = context.request.headers.get("cf-visitor"); + const isHttp = cfVisitor ? cfVisitor.includes('"scheme":"http"') : false; + const isWww = url.hostname.startsWith("www."); + + if (isWww || isHttp) { + if (isWww) url.hostname = url.hostname.slice(4); // strip "www." + url.protocol = "https:"; + return new Response(null, { + status: 301, + headers: { Location: url.toString() }, + }); + } + return next(); +}); diff --git a/src/pages/[...slug].astro b/src/pages/[...slug].astro new file mode 100644 index 0000000..35a7757 --- /dev/null +++ b/src/pages/[...slug].astro @@ -0,0 +1,445 @@ +--- +/** + * Root-router voor CMS-content (catch-all, LAAGSTE route-prioriteit). + * + * Vaste routes (/, /contact, /diensten, /over-ons, /werkgebied, ...) winnen altijd. + * Voor al het overige op de root zoeken we de slug op in twee collecties: + * 1. "locaties" → lokale SEO-landingspagina (bv. /maastricht) + * 2. "paginas" → gewone CMS-pagina (bv. /onderhoudstips) + * Niets gevonden → nette branded 404. Veilig vóór de seed (try/catch → 404). + * + * De SEO-partij beheert deze in /_emdash/admin onder "Locatiepagina's" en "Pagina's". + */ +import Base from "../layouts/Base.astro"; +import { PortableText } from "emdash/ui"; +import { getEmDashEntry } from "emdash"; +import { resolveCmsImage, type ResolvedImage } from "../lib/images"; +import PtMark from "../components/PtMark.astro"; + +// Links in rich text openen standaard in hetzelfde tabblad; nieuw tabblad alleen +// wanneer aangevinkt in de editor (markDef.blank). Zie PtMark.astro. +const ptComponents = { mark: PtMark }; + +const slugParam = Astro.params.slug ?? ""; +const slug = Array.isArray(slugParam) ? slugParam.join("/") : slugParam; + +// Niet-bestaande asset-paden en botscans (met een bestandsextensie, bv. /x.php, /.env, +// /wp-login.php, /sitemap.xml.bak) belanden alleen hier als ze nergens anders matchen. +// Geef die een goedkope, kale 404 in plaats van twee D1-lookups + de volledige layout te +// renderen. Scheelt cold-start-CPU op de worker (botscans zijn een grote bron van koude, +// zware SSR-renders). Echte pagina-slugs hebben nooit een extensie. +if (/\.[a-z0-9]{1,6}$/i.test(slug)) { + return new Response("Niet gevonden", { status: 404 }); +} + +interface LocatieData { + plaats?: string; + title?: string; + intro?: string; + hero_image?: unknown; + // 4 content-blokken (kop + alinea + optionele foto), om-en-om getoond. Zie 0015_locaties_layout.sql. + blok1_heading?: string; blok1_text?: string; blok1_image?: unknown; + blok2_heading?: string; blok2_text?: string; blok2_image?: unknown; + blok3_heading?: string; blok3_text?: string; blok3_image?: unknown; + blok4_heading?: string; blok4_text?: string; blok4_image?: unknown; + meta_title?: string; + meta_description?: string; + canonical?: string; + og_image?: unknown; + noindex?: boolean; +} +interface PaginaData { + title?: string; + intro?: string; + content?: unknown; + embed_code?: string; + meta_title?: string; + meta_description?: string; + canonical?: string; + og_image?: unknown; + noindex?: boolean; +} + +let kind: "locatie" | "pagina" | null = null; +let loc: LocatieData | null = null; +let pag: PaginaData | null = null; + +if (slug) { + // Beide lookups parallel; locatie heeft voorrang bij een (zeldzame) slug-botsing. + const [locRes, pagRes] = await Promise.allSettled([ + getEmDashEntry("locaties", slug), + getEmDashEntry("paginas", slug), + ]); + if (locRes.status === "fulfilled" && locRes.value.entry) { + loc = (locRes.value.entry.data ?? {}) as LocatieData; + kind = "locatie"; + } else if (pagRes.status === "fulfilled" && pagRes.value.entry) { + pag = (pagRes.value.entry.data ?? {}) as PaginaData; + kind = "pagina"; + } +} + +if (!kind) { + Astro.response.status = 404; +} + +// Gedeelde SEO-afleiding. +const seoSrc = loc ?? pag; +const plaatsNaam = loc?.plaats?.trim() ?? ""; +const h1 = + kind === "locatie" + ? (loc?.title?.trim() || (plaatsNaam ? `Gevelreiniging in ${plaatsNaam}` : "")) + : (pag?.title?.trim() ?? ""); + +const pageTitle = kind + ? (seoSrc?.meta_title?.trim() || `${h1} · Helvoirt Gevelreiniging`) + : "Pagina niet gevonden · Helvoirt Gevelreiniging"; +const pageDesc = seoSrc?.meta_description?.trim() || seoSrc?.intro?.trim() || undefined; +const ogImage = resolveCmsImage(seoSrc?.og_image)?.src; +const canonical = seoSrc?.canonical?.trim() || undefined; +const noindex = kind ? !!seoSrc?.noindex : true; + +// ===== Locatie-opmaak (rijke layout, alles uit het CMS met veilige fallbacks) ===== +const str = (v: unknown): string | undefined => + typeof v === "string" && v.trim() ? v.trim() : undefined; +// Blok-tekst is PortableText (rich text): een array met blokken, of leeg. +const hasPT = (v: unknown): boolean => Array.isArray(v) && v.length > 0; + +interface LocBlock { heading?: string; body: unknown; img: ResolvedImage | null } + +const heroImg = kind === "locatie" ? resolveCmsImage(loc?.hero_image) : null; + +const locBlocks: LocBlock[] = + kind === "locatie" && loc + ? [1, 2, 3, 4] + .map((n) => { + const data = loc as Record; + return { + heading: str(data[`blok${n}_heading`]), + body: data[`blok${n}_text`], + img: resolveCmsImage(data[`blok${n}_image`]), + }; + }) + .filter((b) => b.heading || hasPT(b.body) || b.img) + : []; + +// Diensten-strip: vaste interne links (lokale SEO + vervolgstap). Volgt de 5 dienstpagina's. +const DIENSTEN_LINKS = [ + { slug: "gevelreiniging", name: "Gevelreiniging en renovatie" }, + { slug: "voegwerk", name: "Voegwerkzaamheden" }, + { slug: "dakreiniging-coating", name: "Dak reinigen en coaten" }, + { slug: "schoorsteen-voegwerk", name: "Schoorsteen voegwerk" }, + { slug: "algemeen-reinigen", name: "Algemeen reinigen" }, +]; +--- + + + {kind === "locatie" && loc ? ( + <> +
+
+
+

Werkgebied{plaatsNaam ? ` · ${plaatsNaam}` : ""}

+

{h1}

+ {loc.intro &&

{loc.intro}

} +
+ + Gratis offerte + + + + + 4,9 op Google, beoordeling 4,9 van 5 + +
+
+ {heroImg && ( +
+ {heroImg.alt +
+ )} +
+
+ + {locBlocks.length > 0 && ( +
+
+ {locBlocks.map((b, i) => ( +
+
+ {b.heading &&

{b.heading}

} + {hasPT(b.body) &&
} +
+ {b.img && ( +
+ {b.img.alt +
+ )} +
+ ))} +
+
+ )} + +
+
+

+ Onze diensten{plaatsNaam ? ` in ${plaatsNaam}` : ""} +

+ +
+
+ + ) : kind === "pagina" && pag ? ( +
+
+
+

{pag.title}

+ {pag.intro &&

{pag.intro}

} +
+ {pag.content && ( +
+ )} + {pag.embed_code?.trim() && ( + // Rauwe embed/script-injectie (bv. cookieverklaring). Bewust ZONDER sanitatie: + // alleen vertrouwde admins beheren het CMS. Het + + diff --git a/src/pages/over-ons.astro b/src/pages/over-ons.astro new file mode 100644 index 0000000..a4f0b91 --- /dev/null +++ b/src/pages/over-ons.astro @@ -0,0 +1,319 @@ +--- +import Base from "../layouts/Base.astro"; +import { srcsetFor } from "../lib/img"; +import { PortableText } from "emdash/ui"; +import { getEmDashEntry } from "emdash"; +import { resolveCmsImage } from "../lib/images"; + +// Fallback = de huidige teksten. De CMS (collecties "over_ons" + "waarden") overschrijft dit +// zodra geseed; bij een leeg/onbereikbaar CMS blijft de pagina exact zoals hieronder. +const FB_WAARDEN = [ + { title: "Eerlijke prijs vooraf", text: "Je krijgt een duidelijke offerte op papier. Geen verrassingen achteraf, geen kleine lettertjes.", icon: '' }, + { title: "Echt vakwerk", text: "We kennen ons vak en de materialen. Elke gevel, elk dak en elke schoorsteen krijgt de juiste aanpak.", icon: '' }, + { 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: '' }, + { title: "Lokaal en betrokken", text: "We werken in Helvoirt en omgeving. Korte lijnen, snel even langs en altijd persoonlijk aanspreekbaar.", icon: '' }, +]; + +interface WaardeData { title?: string; text?: string; icon?: string; } +let d: Record = {}; +try { + const o = await getEmDashEntry("over_ons", "main"); + d = (o.entry?.data ?? {}) as Record; +} catch { + /* CMS nog niet geseed/bereikbaar → fallbacks */ +} + +const pick = (v: unknown, fb: string): string => (typeof v === "string" && v.trim() ? v : fb); +const heroEyebrow = pick(d.hero_eyebrow, "Over ons"); +const heroTitle = pick(d.hero_title, "De vakmensen achter een schone gevel"); +const heroLead = pick(d.hero_lead, "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."); +const storyTitle = pick(d.story_title, "Schoon, strak en vakkundig"); +const storyBody = Array.isArray(d.story_body) && d.story_body.length ? d.story_body : null; +const valuesTitle = pick(d.values_title, "Waarom Helvoirt Gevelreiniging"); +const valuesIntro = pick(d.values_intro, "Wat je van ons mag verwachten, bij elke klus."); +const metaTitle = pick(d.meta_title, "Over ons · Helvoirt Gevelreiniging"); +const metaDescription = pick(d.meta_description, "Helvoirt Gevelreiniging is een lokaal gevelreinigings- en renovatiebedrijf. Vakwerk, een eerlijke prijs vooraf en netjes opgeleverd, in Helvoirt en omgeving."); + +// "Waarden" is nu een inline repeater-veld op over_ons (rijen toevoegen/verwijderen/verslepen). +const waardenCms = Array.isArray(d.waarden) ? (d.waarden as WaardeData[]) : []; +const waarden: WaardeData[] = waardenCms.length ? waardenCms : FB_WAARDEN; + +const plaatsen = ["Helvoirt", "Maastricht", "Echt", "Roermond", "Weert", "Sittard", "Heerlen", "Brunssum", "Kerkrade", "Landgraaf"]; + +// Beeldvelden uit het CMS, met de huidige statische foto's als fallback. +const heroCms = resolveCmsImage(d.hero_image); +const heroImg = heroCms?.src ?? "/images/hero-after.webp"; +const heroAlt = heroCms?.alt ?? "Schone, verzorgde gevel van een woonhuis na reiniging"; +const heroSrcset = heroCms ? undefined : srcsetFor("/images/hero-after.webp"); +const storyCms = resolveCmsImage(d.story_image); +const storyImg = storyCms?.src ?? "/images/werk-3.webp"; +const storyAlt = storyCms?.alt ?? "Vakman aan het werk aan het voegwerk van een gevel"; +--- + + + +
+
+
+

{heroEyebrow}

+

{heroTitle}

+

{heroLead}

+ + + +
+ + 4,9 op Google, beoordeling 4,9 van 5 +
+
+ +
+ {heroAlt} +
+
+
+ + +
+
+
+

{storyTitle}

+ {storyBody ? ( + + ) : ( + <> +

+ 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. +

+

+ 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. +

+ + )} +
+
+ {storyAlt} +
+
+
+ + +
+
+
+

{valuesTitle}

+

{valuesIntro}

+
+
    + {waarden.map((w, i) => ( +
  • + +

    {w.title}

    +

    {w.text}

    +
  • + ))} +
+
+
+ + +
+
+
+

Werkzaam in Helvoirt en omgeving

+

Staat jouw plaats er niet bij? Bel of mail gerust, vaak komen we er ook.

+
+
    + {plaatsen.map((p, i) =>
  • {p}
  • )} +
+
+
+ + + diff --git a/src/pages/sitemap.xml.ts b/src/pages/sitemap.xml.ts new file mode 100644 index 0000000..8afb9d0 --- /dev/null +++ b/src/pages/sitemap.xml.ts @@ -0,0 +1,107 @@ +import type { APIRoute } from "astro"; +import { getEmDashCollection } from "emdash"; + +// XML-sitemap: de vaste (code-)routes + alle gepubliceerde CMS-pagina's en +// locatiepagina's. `site` komt uit astro.config.mjs (https://helvoirtgevelreiniging.nl); +// fallback voor de zekerheid zodat de build nooit op een ontbrekende site valt. +const FALLBACK_ORIGIN = "https://helvoirtgevelreiniging.nl"; + +// Bump bij een grote structuurwijziging; per-pagina gebruiken we waar mogelijk de +// CMS-updatedAt. +const DEFAULT_LASTMOD = "2026-06-17"; + +interface SitemapUrl { + loc: string; + lastmod?: string; + changefreq?: string; + priority?: string; +} + +// Vaste routes die in code bestaan (geen CMS). Prioriteit relatief t.o.v. de homepage. +const STATIC_ROUTES: { path: string; priority: string; changefreq: string }[] = [ + { path: "/", priority: "1.0", changefreq: "weekly" }, + { path: "/diensten", priority: "0.9", changefreq: "monthly" }, + { path: "/diensten/gevelreiniging", priority: "0.8", changefreq: "monthly" }, + { path: "/diensten/voegwerk", priority: "0.8", changefreq: "monthly" }, + { path: "/diensten/dakreiniging-coating", priority: "0.8", changefreq: "monthly" }, + { path: "/diensten/schoorsteen-voegwerk", priority: "0.8", changefreq: "monthly" }, + { path: "/diensten/algemeen-reinigen", priority: "0.8", changefreq: "monthly" }, + { path: "/over-ons", priority: "0.6", changefreq: "monthly" }, + { path: "/contact", priority: "0.7", changefreq: "monthly" }, + { path: "/werkgebied", priority: "0.6", changefreq: "monthly" }, + { path: "/voorwaarden", priority: "0.2", changefreq: "yearly" }, +]; + +interface CmsEntry { + slug?: string | null; + data?: { noindex?: boolean }; + updatedAt?: string | Date; + publishedAt?: string | Date; +} + +function toLastmod(value: unknown): string | undefined { + if (!value) return undefined; + const d = value instanceof Date ? value : new Date(String(value)); + return Number.isNaN(d.getTime()) ? undefined : d.toISOString().slice(0, 10); +} + +// Haal een gepubliceerde collectie op; faalt dit (nog niet geseed, D1-storing), dan +// geven we een lege lijst terug zodat de sitemap met de vaste routes blijft werken. +async function publishedSlugs(collection: string): Promise { + try { + const { entries } = await getEmDashCollection(collection, { status: "published" }); + return ((entries ?? []) as unknown as CmsEntry[]) + .filter((e) => e.slug && !e.data?.noindex) + .map((e) => ({ + loc: `/${e.slug}`, + lastmod: toLastmod(e.updatedAt ?? e.publishedAt) ?? DEFAULT_LASTMOD, + changefreq: "monthly", + priority: "0.7", + })); + } catch { + return []; + } +} + +export const GET: APIRoute = async ({ site }) => { + const origin = (site ?? new URL(FALLBACK_ORIGIN)).origin; + + const [locaties, paginas] = await Promise.all([ + publishedSlugs("locaties"), + publishedSlugs("paginas"), + ]); + + const urls: SitemapUrl[] = [ + ...STATIC_ROUTES.map((r) => ({ + loc: r.path, + lastmod: DEFAULT_LASTMOD, + changefreq: r.changefreq, + priority: r.priority, + })), + ...locaties, + ...paginas, + ]; + + const body = urls + .map( + (u) => ` + ${origin}${u.loc}${u.lastmod ? `\n ${u.lastmod}` : ""}${u.changefreq ? `\n ${u.changefreq}` : ""}${u.priority ? `\n ${u.priority}` : ""} + `, + ) + .join("\n"); + + const xml = ` + +${body} + +`; + + return new Response(xml, { + headers: { + "Content-Type": "application/xml; charset=utf-8", + "Cache-Control": "public, max-age=3600", + }, + }); +}; + +export const prerender = false; diff --git a/src/pages/voorwaarden.astro b/src/pages/voorwaarden.astro new file mode 100644 index 0000000..7c6ced0 --- /dev/null +++ b/src/pages/voorwaarden.astro @@ -0,0 +1,96 @@ +--- +/** + * Algemene voorwaarden (vaste code-route /voorwaarden). Inhoud komt uit het CMS + * (singleton `voorwaarden`, artikelen = repeater) met de tekst hieronder als fallback, + * zodat de pagina nooit breekt. Gebaseerd op de voorwaarden van het zusterbedrijf, + * aangepast naar Helvoirt Gevelreiniging. NB: laat de klant deze juridisch controleren. + */ +import Base from "../layouts/Base.astro"; +import { getEmDashEntry } from "emdash"; + +interface Article { heading: string; body: string } + +const fallbackTitle = "Algemene voorwaarden"; +const fallbackIntro = "Deze algemene voorwaarden zijn van toepassing op alle aanbiedingen, offertes en overeenkomsten tussen Helvoirt Gevelreiniging en de klant."; +const fallbackArticles: Article[] = [ + { heading: "Artikel 1. Algemeen", body: "Deze voorwaarden gelden voor alle aanbiedingen en overeenkomsten tussen Helvoirt Gevelreiniging en de klant, tenzij schriftelijk uitdrukkelijk anders is overeengekomen. Ze gelden ook voor overeenkomsten waarbij derden worden ingeschakeld en voor alle medewerkers van het bedrijf. Eventuele inkoop- of andere voorwaarden van de klant worden niet aanvaard." }, + { heading: "Artikel 2. Offertes en aanbiedingen", body: "Alle offertes en aanbiedingen blijven twee (2) maanden geldig, tenzij anders vermeld. Helvoirt Gevelreiniging is niet gebonden aan een offerte als de klant redelijkerwijs kan begrijpen dat deze een kennelijke vergissing of verschrijving bevat. Een offerte geldt voor het beschreven werk; vervolgopdrachten vallen er niet automatisch onder." }, + { heading: "Artikel 3. Contractduur, termijnen, uitvoering en wijziging", body: "Tenzij anders afgesproken is de overeenkomst aangegaan voor de duur van de opdracht. Genoemde termijnen zijn een indicatie en gelden nimmer als fatale termijnen; bij buitenwerk kunnen weersomstandigheden de planning beinvloeden. Wijzigingen in de opdracht worden in overleg afgesproken en kunnen de prijs en de planning beinvloeden. De klant zorgt voor toegang tot het werk en, waar nodig, voor water en elektriciteit." }, + { heading: "Artikel 4. Opschorting, ontbinding en tussentijdse opzegging", body: "Helvoirt Gevelreiniging mag de nakoming van haar verplichtingen opschorten of de overeenkomst ontbinden als de klant zijn verplichtingen niet of niet volledig nakomt, of bij omstandigheden die nakoming redelijkerwijs onmogelijk maken. Wordt de overeenkomst ontbonden door toedoen van de klant, dan is de klant een schadevergoeding verschuldigd van 30% van het totaalbedrag van de geaccordeerde offerte, onverminderd het recht op vergoeding van de werkelijk gemaakte kosten. Bij annulering van een opdracht vergoedt de klant de reeds gemaakte kosten." }, + { heading: "Artikel 5. Overmacht", body: "Helvoirt Gevelreiniging is niet gehouden tot het nakomen van een verplichting als zij daartoe verhinderd is door overmacht: omstandigheden buiten haar schuld die nakoming tijdelijk of blijvend verhinderen. Gedurende de overmacht worden de wederzijdse verplichtingen opgeschort." }, + { heading: "Artikel 6. Betaling en incassokosten", body: "Betaling vindt contant of per overschrijving plaats direct na uitvoering van de werkzaamheden, tenzij schriftelijk anders is afgesproken. Bij niet-tijdige betaling is de klant van rechtswege in verzuim en is hij de wettelijke rente verschuldigd, met een minimum van 1% per maand. De buitengerechtelijke incassokosten bedragen 15% van de hoofdsom, met een minimum van 150,00 euro." }, + { heading: "Artikel 7. Eigendomsvoorbehoud", body: "Alle door Helvoirt Gevelreiniging geleverde en gebruikte zaken blijven haar eigendom totdat de klant alle verplichtingen uit de overeenkomst volledig is nagekomen. De klant mag deze zaken niet bezwaren of vervreemden, dient ze te verzekeren tegen brand-, water- en diefstalschade, en geeft op verzoek de locatie van de zaken op." }, + { heading: "Artikel 8. Garanties en reclames", body: "Helvoirt Gevelreiniging staat in voor de kwaliteit van het geleverde werk. De garantietermijn bedraagt vijf (5) jaar op reparaties en herstelwerk, een (1) jaar op reinigingswerk en noodreparaties, en tien (10) jaar op volledige renovaties. Gebreken dienen binnen acht (8) dagen na ontdekking schriftelijk te worden gemeld. De garantie vervalt bij onjuist gebruik of onderhoud, bij gebreken aan de bestaande ondergrond die vooraf niet kenbaar waren, of bij werkzaamheden van derden aan hetzelfde werk." }, + { heading: "Artikel 9. Aansprakelijkheid", body: "De aansprakelijkheid van Helvoirt Gevelreiniging is beperkt tot directe schade en tot ten hoogste driemaal de factuurwaarde van de betreffende opdracht. Aansprakelijkheid voor indirecte schade, waaronder gevolgschade, gederfde winst en gemiste besparingen, is uitgesloten. De aansprakelijkheid is in alle gevallen beperkt tot het bedrag dat de verzekeraar in het voorkomende geval uitkeert." }, + { heading: "Artikel 10. Toepasselijk recht en geschillen", body: "Op alle overeenkomsten is uitsluitend Nederlands recht van toepassing. Geschillen worden voorgelegd aan de bevoegde rechter in het arrondissement waar Helvoirt Gevelreiniging is gevestigd. Partijen zullen pas een beroep op de rechter doen nadat zij zich tot het uiterste hebben ingespannen om het geschil in onderling overleg op te lossen." }, +]; + +let data: Record = {}; +try { + const { entry } = await getEmDashEntry("voorwaarden", "main"); + data = (entry?.data ?? {}) as Record; +} catch { + /* CMS nog niet geseed/bereikbaar → fallback */ +} +const pick = (v: unknown, fb: string): string => (typeof v === "string" && v.trim() ? v : fb); +const title = pick(data.title, fallbackTitle); +const intro = pick(data.intro, fallbackIntro); +const articles: Article[] = Array.isArray(data.articles) && data.articles.length + ? (data.articles as Array<{ heading?: string; body?: string }>).map((a) => ({ heading: a.heading ?? "", body: a.body ?? "" })) + : fallbackArticles; + +const metaTitle = pick(data.meta_title, "Algemene voorwaarden · Helvoirt Gevelreiniging"); +const metaDesc = pick(data.meta_description, "De algemene voorwaarden van Helvoirt Gevelreiniging: offertes, uitvoering, betaling, garantie en aansprakelijkheid."); +--- + + + + + + diff --git a/src/pages/werkgebied/index.astro b/src/pages/werkgebied/index.astro new file mode 100644 index 0000000..47c915c --- /dev/null +++ b/src/pages/werkgebied/index.astro @@ -0,0 +1,180 @@ +--- +/** + * Werkgebied-overzicht (hub) — linkt naar alle gepubliceerde locatiepagina's (collectie + * "locaties"). Geeft de lokale SEO-pagina's een ouderpagina + interne links. + * Vóór de seed (geen locaties) valt 'ie terug op de plaats-pills + CTA. + */ +import Base from "../../layouts/Base.astro"; +import { getEmDashCollection } from "emdash"; + +interface LocatieEntry { + slug: string; + data: { plaats?: string; title?: string; intro?: string }; +} + +let locaties: LocatieEntry[] = []; +try { + const { entries } = await getEmDashCollection("locaties", { status: "published" }); + locaties = (entries ?? []) as unknown as LocatieEntry[]; +} catch { + /* nog niet geseed → toon de statische pills */ +} + +const plaatsen = ["Helvoirt", "Maastricht", "Echt", "Roermond", "Weert", "Sittard", "Heerlen", "Brunssum", "Kerkrade", "Landgraaf"]; +--- + + +
+
+

Werkgebied

+

Werkzaam in Helvoirt en omgeving

+

+ Van gevelreiniging en voegwerk tot dak reinigen en coaten. Staat jouw plaats er niet bij? + Bel of mail gerust, vaak komen we er ook. +

+ +
+
+ + {locaties.length > 0 && ( +
+ +
+ )} + +
+
+
+

Helvoirt en omgeving

+

Een greep uit onze werkgebied. Niet zeker of we bij jou komen? Vraag het gerust.

+
+
    + {plaatsen.map((p, i) =>
  • {p}
  • )} +
+
+
+ + + diff --git a/src/plugins/smtp-email.ts b/src/plugins/smtp-email.ts new file mode 100644 index 0000000..2dab112 --- /dev/null +++ b/src/plugins/smtp-email.ts @@ -0,0 +1,65 @@ +// Emdash e-mail-provider-plugin (standard-formaat) — levert Emdash's systeem-mail +// (magic-link login, e-mailverificatie) af via de mailbox info@helvoirtgevelreiniging.nl. +// +// Zonder zo'n provider geeft Emdash's ingebouwde `magic-link/send` een 503 +// ("EMAIL_NOT_CONFIGURED") en blijft de "Sign in with email link"-knop dood. Met deze +// plugin registreren we de exclusieve `email:deliver`-hook; in productie is dit de enige +// provider, dus de runtime selecteert 'm automatisch (zie resolveExclusiveHooks). +// +// id/version/capabilities komen uit de descriptor in astro.config.mjs; daarom is hier +// geen emdash-import nodig. De handler draait in-host (trusted plugin), dus we lezen de +// Worker-secret rechtstreeks uit `cloudflare:workers` — zelfde transport + secret als het +// contactformulier (src/lib/smtp.ts + src/pages/api/contact.ts). +import { env } from "cloudflare:workers"; +import { sendMail } from "../lib/smtp"; + +interface EmailMessage { + to: string; + subject: string; + text: string; + html?: string; +} +interface EmailDeliverEvent { + message: EmailMessage; + source: string; +} + +async function deliver(event: EmailDeliverEvent): Promise { + const { message } = event; + + // Strip een eventuele UTF-8 BOM (zie contact.ts): een via PowerShell-pipe gezette + // secret krijgt een BOM-prefix waardoor SMTP-auth stil op "535" zou vallen. + let password = String(env.CONTACT_SMTP_PASS ?? ""); + if (password.charCodeAt(0) === 0xfeff) password = password.slice(1); + if (!password) { + // Gooien zodat de e-mail-pipeline de fout meldt (niet stil "verstuurd"). + throw new Error("[smtp-email] CONTACT_SMTP_PASS niet gezet"); + } + const host = String(env.CONTACT_SMTP_HOST ?? "mail.helvoirtgevelreiniging.nl"); + const port = Number(env.CONTACT_SMTP_PORT ?? 465); + const username = String(env.CONTACT_SMTP_USER ?? "info@helvoirtgevelreiniging.nl"); + const fromAddr = String(env.CONTACT_EMAIL_FROM ?? "info@helvoirtgevelreiniging.nl"); + + await sendMail( + { host, port, username, password }, + { + from: fromAddr, + fromName: "Helvoirt Gevelreiniging", + envelopeFrom: username, // envelope = geauthenticeerde mailbox, From = house-style + to: message.to, + subject: message.subject, + text: message.text, + }, + ); +} + +// Standard-formaat: default export met hooks. `exclusive: true` markeert dit als de +// enige e-mail-leverancier (provider-patroon). +export default { + hooks: { + "email:deliver": { + exclusive: true, + handler: deliver, + }, + }, +}; diff --git a/src/styles/tokens.css b/src/styles/tokens.css new file mode 100644 index 0000000..bd7cda3 --- /dev/null +++ b/src/styles/tokens.css @@ -0,0 +1,437 @@ +/* Helvoirt Gevelreiniging — design tokens. + Register: conversiegerichte lokale dienstensite (geen quiet-luxury). + Palette uit het zusterbedrijf onderhoudsbedrijfvanhelvoirt.nl: + cyaan #4EBFD9, actie-blauw #0069FF / #0055CE, donker petrol #1D4852, + licht cyaan #E5FAFF, antraciet #373737. Fonts: Poppins (kop) + Open Sans (body). */ + +:root { + /* === Actie / blauw (CTA, links) === */ + --c-action: #0069ff; /* primaire CTA fill + links */ + --c-action-deep: #0055ce; /* hover + links op wit (WCAG AA) */ + --c-action-soft: #e7f1ff; /* zachte blauwe tint (badges, hover-bg) */ + + /* === Brand / cyaan (accent, iconen, highlights) === */ + --c-brand: #4ebfd9; /* merk-cyaan, accenten en icoon-tint */ + --c-brand-soft: #8fd9e8; /* lichtere cyaan */ + + /* === Petrol (donkere secties, koppen) === */ + --c-petrol: #1d4852; /* donkere secties + koppen op licht */ + --c-petrol-deep: #15363d; /* footer / diepste donker */ + + /* === Neutraal — licht (getint, geen puur wit) === */ + --c-paper: #f6fafb; /* page background, koel off-white */ + --c-paper-soft: #eef4f7; /* alt sectie */ + --c-card: #fdfeff; /* kaart/oppervlak boven paper */ + --c-tint: #e3f6fd; /* licht-cyaan accent-sectie */ + --c-white: #fdfeff; /* near-white (geen #fff) */ + + /* === Neutraal — ink === */ + --c-ink: #1f2a2e; /* body tekst (zacht zwart, petrol-getint) */ + --c-ink-soft: #5b6a70; /* secundair tekst, meta */ + --c-line: #dce6eb; /* hairlines, borders, dividers */ + + /* === Tekst op donker === */ + --c-on-dark: #ffffff; + --c-on-dark-soft: #c7dae0; + --c-divider-on-dark: rgba(255, 255, 255, 0.16); + + /* === Form tokens (light-surface form) === */ + --c-form-bg: #fdfeff; + --c-form-border: #cbd8de; + --c-form-border-hover: #9fb6be; + --c-form-placeholder: #8a989e; + + /* === Error / status / accent === */ + --c-error: #d64545; + --c-error-bg: #fcedec; + --c-on-error: #ffb4b4; /* error-tekst op donker oppervlak */ + --c-on-success: #8fe6b4; /* succes-tekst op donker oppervlak */ + --c-star: #f5a623; /* review-sterren */ + + /* === Header / glass + scrim === */ + --c-paper-glass: rgba(255, 255, 255, 0.90); + --c-paper-glass-scrolled: rgba(255, 255, 255, 0.97); + --c-veil-deep: rgba(21, 54, 61, 0.55); + --c-veil-mid: rgba(21, 54, 61, 0.20); + --c-veil-end: rgba(21, 54, 61, 0.92); + + /* RGB-triplets voor rgba() met variabele alpha (overlays/scrims/gloed op donker) */ + --c-petrol-deep-rgb: 21, 54, 61; /* #15363d */ + --c-scrim-rgb: 13, 33, 38; /* #0d2126, diepste petrol-scrim */ + --c-brand-rgb: 78, 191, 217; /* #4ebfd9 */ + --c-action-deep-rgb: 0, 85, 206; /* #0055ce */ + + /* === Type === */ + --font-display: "Archivo Expanded", "Archivo", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; + --font-body: "Open Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; + + --t-display: clamp(2.5rem, 1.1rem + 5.6vw, 4.75rem); + --t-headline: clamp(1.85rem, 1rem + 3vw, 3rem); + --t-title: 1.3125rem; + --t-subtitle: 1.125rem; + --t-body: 1.0625rem; + --t-small: 0.9375rem; + --t-label: 0.8125rem; + + --lh-tight: 1.08; + --lh-snug: 1.2; + --lh-normal: 1.6; + --lh-loose: 1.75; + + /* === Layout & spacing === */ + --max-w: 1240px; + --max-w-prose: 65ch; + --gutter: clamp(1.25rem, 4vw, 2.75rem); + + --s-1: 0.25rem; + --s-2: 0.5rem; + --s-3: 0.75rem; + --s-4: 1rem; + --s-5: 1.5rem; + --s-6: 2rem; + --s-7: 3rem; + --s-8: 4rem; + --s-9: 6rem; + --s-10: 8rem; + + --radius-sm: 4px; + --radius-md: 8px; + --radius-lg: 16px; + --radius-pill: 999px; + + /* === Motion === */ + --ease-out: cubic-bezier(0.22, 1, 0.36, 1); + --ease-expo: cubic-bezier(0.16, 1, 0.3, 1); + --d-fast: 140ms; + --d-base: 220ms; + --d-slow: 360ms; + + /* === Shadows / depth === */ + --shadow-soft: 0 6px 22px -10px rgba(21, 54, 61, 0.18), + 0 2px 6px -3px rgba(21, 54, 61, 0.10); + --shadow-lift: 0 16px 38px -12px rgba(21, 54, 61, 0.28), + 0 4px 10px -4px rgba(21, 54, 61, 0.16); + /* kaart-schaduw op donkere (petrol) oppervlakken — getint, niet puur zwart */ + --shadow-card-dark: 0 22px 46px -18px rgba(13, 33, 38, 0.72), + 0 6px 14px -8px rgba(13, 33, 38, 0.55); + --shadow-cta: 0 10px 26px -10px rgba(0, 105, 255, 0.45); + --shadow-focus: 0 0 0 3px rgba(0, 105, 255, 0.40); + + /* === Header height (offset voor sticky / anchor scroll) === */ + --header-h: 80px; +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} + +/* === Base reset === */ +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + -webkit-text-size-adjust: 100%; + scroll-behavior: smooth; +} + +body { + margin: 0; + background: var(--c-paper); + color: var(--c-ink); + font-family: var(--font-body); + font-size: var(--t-body); + line-height: var(--lh-normal); + font-feature-settings: "kern", "liga"; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; +} + +img, picture, svg { + display: block; + max-width: 100%; +} + +a { + color: inherit; + text-decoration: none; +} + +button { + font: inherit; + color: inherit; + background: none; + border: 0; + padding: 0; + cursor: pointer; +} + +input, +textarea, +select { + font: inherit; + color: inherit; +} + +h1, h2, h3, h4, h5, h6 { + margin: 0; + font-family: var(--font-display); + font-weight: 700; + letter-spacing: -0.01em; + line-height: var(--lh-snug); + color: var(--c-petrol); +} + +p { + margin: 0; +} + +p + p { + margin-top: var(--s-4); +} + +::selection { + background: var(--c-action); + color: var(--c-white); +} + +:focus-visible { + outline: 2px solid var(--c-action); + outline-offset: 3px; + border-radius: 2px; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; +} + +.wrap { + width: 100%; + max-width: var(--max-w); + margin-inline: auto; + padding-inline: var(--gutter); +} + +/* === Buttons === */ + +/* Primary CTA — actie-blauw fill, witte tekst, vriendelijk afgerond. */ +.btn-cta { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.6rem; + padding: 0.9rem 1.6rem; + font-family: var(--font-body); + font-size: var(--t-small); + font-weight: 700; + letter-spacing: 0.005em; + color: var(--c-white); + background: var(--c-action); + border: 1px solid var(--c-action); + border-radius: var(--radius-md); + box-shadow: var(--shadow-cta); + transition: background var(--d-base) var(--ease-out), + border-color var(--d-base) var(--ease-out), + box-shadow var(--d-base) var(--ease-out), + transform var(--d-base) var(--ease-out); +} + +.btn-cta:hover { + background: var(--c-action-deep); + border-color: var(--c-action-deep); + transform: translateY(-1px); + box-shadow: 0 14px 32px -10px rgba(0, 85, 206, 0.55); +} + +/* Tactiele "indruk" bij klik/tap — bevestigt de actie fysiek. */ +.btn-cta:active { + transform: scale(0.97); + box-shadow: var(--shadow-cta); + transition-duration: var(--d-fast); +} + +.btn-cta svg { + transition: transform var(--d-base) var(--ease-out); +} + +.btn-cta:hover svg { + transform: translateX(3px); +} + +/* Secundaire knop — outline op licht / wit op donker */ +.btn-outline { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.6rem; + padding: 0.9rem 1.6rem; + font-family: var(--font-body); + font-size: var(--t-small); + font-weight: 700; + color: var(--c-action); + background: transparent; + border: 1px solid var(--c-action); + border-radius: var(--radius-md); + transition: background var(--d-base) var(--ease-out), + color var(--d-base) var(--ease-out); +} + +.btn-outline:hover { + background: var(--c-action); + color: var(--c-white); +} + +.btn-outline:active { + transform: scale(0.97); + transition-duration: var(--d-fast); +} + +/* Text-arrow link — secondary CTA */ +.btn-text { + display: inline-flex; + align-items: center; + gap: 0.55rem; + padding-bottom: 0.35rem; + font-family: var(--font-body); + font-size: var(--t-small); + font-weight: 600; + color: var(--c-action-deep); + border-bottom: 1px solid currentColor; + transition: color var(--d-base) var(--ease-out); +} + +.btn-text svg { + transition: transform var(--d-base) var(--ease-out); +} + +.btn-text:hover svg, +.btn-text:focus-visible svg { + transform: translateX(4px); +} + +/* === Section helpers === */ +.section { + padding-block: clamp(var(--s-8), 8vw, var(--s-10)); +} + +.section--paper { background: var(--c-paper); } +.section--soft { background: var(--c-paper-soft); } +.section--tint { background: var(--c-tint); } + +.section--petrol { + background: var(--c-petrol); + color: var(--c-on-dark); +} + +.section--petrol-deep { + background: var(--c-petrol-deep); + color: var(--c-on-dark); +} + +.section--petrol h1, .section--petrol h2, .section--petrol h3, +.section--petrol-deep h1, .section--petrol-deep h2, .section--petrol-deep h3 { + color: var(--c-on-dark); +} + +/* === Scroll-reveal (IO-systeem voegt .revealed toe, in Base.astro) === */ +.reveal { + opacity: 0; + transform: translateY(20px); + transition: opacity 0.7s var(--ease-expo), transform 0.7s var(--ease-expo); + transition-delay: calc(var(--reveal-delay, 0) * 1ms); +} + +.reveal.revealed { + opacity: 1; + transform: translateY(0); +} + +.reveal[data-delay="1"] { --reveal-delay: 100; } +.reveal[data-delay="2"] { --reveal-delay: 200; } +.reveal[data-delay="3"] { --reveal-delay: 300; } +.reveal[data-delay="4"] { --reveal-delay: 400; } + +/* Variant: een lijn die zich vanaf links intekent (tijdlijn-verbinding, dividers). */ +.reveal--draw { + opacity: 1; + transform: scaleX(0); + transform-origin: left center; + transition: transform 0.85s var(--ease-expo); +} +.reveal--draw.revealed { transform: scaleX(1); } + +/* Variant: de container blijft staan, de directe kinderen komen na elkaar omhoog (--i index). */ +.reveal--stagger { + opacity: 1; + transform: none; +} +.reveal--stagger > * { + opacity: 0; + transform: translateY(12px); + transition: opacity 0.55s var(--ease-out), transform 0.55s var(--ease-out); + transition-delay: calc(var(--i, 0) * 45ms); +} +.reveal--stagger.revealed > * { + opacity: 1; + transform: none; +} + +@media (prefers-reduced-motion: reduce) { + .reveal, + .reveal--stagger > * { opacity: 1; transform: none; } + .reveal--draw { transform: none; } +} + +/* === Prose: opmaak van CMS Portable Text-uitvoer (koppen, alinea's, lijsten, links) === */ +.prose { color: var(--c-ink); } +.prose h2 { + font-family: var(--font-display); + font-weight: 700; + font-size: clamp(1.35rem, 2.6vw, 1.75rem); + color: var(--c-ink); + line-height: 1.15; + margin: 2rem 0 0.75rem; +} +.prose h3 { + font-family: var(--font-display); + font-weight: 700; + font-size: 1.2rem; + color: var(--c-ink); + margin: 1.5rem 0 0.5rem; +} +.prose p { + line-height: 1.7; + margin: 0 0 1rem; +} +.prose ul, +.prose ol { + line-height: 1.7; + margin: 0 0 1rem; + padding-left: 1.25rem; +} +.prose li { margin: 0 0 0.35rem; } +.prose a { + color: var(--c-action); + text-decoration: underline; + text-underline-offset: 2px; +} +.prose a:hover { color: var(--c-action-deep); } +.prose strong { font-weight: 700; } +.prose > :first-child { margin-top: 0; } +.prose > :last-child { margin-bottom: 0; } diff --git a/src/worker.ts b/src/worker.ts new file mode 100644 index 0000000..132f6bc --- /dev/null +++ b/src/worker.ts @@ -0,0 +1,3 @@ +import handler from "@astrojs/cloudflare/entrypoints/server"; +export { PluginBridge } from "@emdash-cms/cloudflare/sandbox"; +export default handler; diff --git a/todo.md b/todo.md new file mode 100644 index 0000000..0b424b4 --- /dev/null +++ b/todo.md @@ -0,0 +1,135 @@ +# Helvoirt Gevelreiniging — Bouwplan (todo) + +Stack: Astro 6 + Emdash 0.1.0 + Cloudflare Workers (D1 + R2 + KV). Register: **conversiegerichte lokale dienstensite**, familie van `onderhoudsbedrijfvanhelvoirt.nl`. Zie `architecture.md` (technisch) en `CLAUDE.md` (regels/workarounds). **Alleen wijzigen in `dewebsmid\helvoirt`.** + +**Status (16-06-2026): marketing-pagina's LIVE + CMS-migratie loopt — morgen verder met LIVE ZETTEN.** Alle pagina's live op `https://helvoirt-gevelreiniging.mike-197.workers.dev` (deploy version **`f576e989`**). **CMS geseed + ingericht** (zie §3); de SEO-partij kan pagina's + locatiepagina's beheren/maken/SEO-optimaliseren (`CMS-SEO-handleiding.md`). **Werkgebied-pills** = Helvoirt + Limburgse steden. **Bestaande pagina's worden "volledig naar CMS" gemigreerd** (klantopdracht), met fallback-veiligheid + sentinel-verificatie: **Over ons, Homepage(-tekst), Contact KLAAR**; **Maastricht-locatiepagina live**. + +← **VOLGENDE STAP:** De **code-side go-live-laag is af + live** (17-06-2026, deploy `ed137121`): robots, sitemap (incl. CMS), JSON-LD (LocalBusiness + Service/Breadcrumb), GTM-render, OG-image + apple-touch-icon, middleware, privacy/voorwaarden, **mail-code + D1-opslag** (`contact_submissions`) + **magic-link-plugin**. **CMS = 100% van de pagina's** (Wave 3 Diensten klaar → ook de 5 dienstpagina's + overzicht + homepage-accordion + nav-menu uit de `diensten`-collectie). Wat nu nog rest is **(1) externe input** — mailbox-wachtwoord/host → secret `CONTACT_SMTP_PASS` (bash printf), SEO-partij-e-mail → `users`-rij, klantcontent (NAW/reviews/FAQ/foto's/GTM-ID), domein-DNS → zone+nameservers+`routes`(rebuild)+mail-DNS+HTTPS (zie **`GO-LIVE.md`**) — en **(2) optioneel** self-hosted fonts, Lighthouse-meting, checklist `.xlsx`. **CMS-migratie is 100% klaar** (alle pagina's + alle beelden, tekst én media, in het CMS — deploy `57651519`). Schema op live D1 = via `migrations/*.sql`. Herdeploy: `astro build` + `wrangler deploy`. + +--- + +## ⏳ Open punten (afstemmen met klant) +- [x] **Telefoon + adres aangeleverd:** `085 060 4956`, `Stationsplein 1, 6131 AS Sittard` (in `Base.astro` + `contact.astro` fallbacks; staat live). +- [x] **Werkgebied-copy blijft "Helvoirt en omgeving" (16-06-2026):** kop + alle SEO-copy ongewijzigd ("Helvoirt en omgeving"). **Alleen de pills vernieuwd:** Helvoirt blijft vooraan, daarna de nieuwe steden. Eerdere reverteerde poging om naar "Sittard en omgeving" te gaan is teruggedraaid op klantverzoek. +- [ ] **NAW resterend:** e-mail (nu placeholder `info@helvoirtgevelreiniging.nl`), KvK, BTW, WhatsApp, openingstijden bevestigen, **Google-reviews-URL + score**. +- [ ] **Echte reviews** (i.p.v. de 3 placeholders) — liefst met de Google-link. +- [ ] **Echte foto's**: hero + per dienst (resultaat-galerijen) — nu AI-placeholders + de 6 echte `werk-*`-foto's. (Before/after-paren niet meer nodig: projecten geschrapt.) +- [x] **Werkgebied-plaatsen (pills) vastgesteld:** Helvoirt, Maastricht, Echt, Roermond, Weert, Sittard, Heerlen, Brunssum, Kerkrade, Landgraaf. Bron: klant 16-06-2026. (Kop blijft "Helvoirt en omgeving".) +- [ ] **FAQ-teksten** "Goed om te weten" bevestigen. +- [ ] **Logo:** levert klant een eigen logo aan? (nu tekst-wordmark "Helvoirt / Gevelreiniging" + **H-monogram**-merkmark; druppel verwijderd.) +- [x] ~~Kleuren samplen~~ → uit Elementor-kit zusterbedrijf. ~~Fonts~~ → Archivo Expanded + Open Sans. ~~Worker/D1/R2-naamgeving~~ → `helvoirt-gevelreiniging` / `helvoirt-db` / `helvoirt-media`. ~~Hero-fotografie~~ → nano-banana placeholders (klantkeuze). ~~Blog~~ → buiten launch-scope. + +--- + +## §1 — Scaffold (mirror exterios) ✅ GEDAAN +- [x] `package.json`/`tsconfig`/`astro.config.mjs`/`worker.ts`/`live.config.ts`/`wrangler.jsonc`/`.gitignore`/`.env` + scripts + `lib/img.ts`. +- [x] `npm install --ignore-scripts` (721 packages) · `astro sync`/`check`/`build` slagen. +- **Geleerd:** `NODE_OPTIONS=--use-system-ca` voor elke astro/wrangler-call; placeholder-`database_id` (miniflare eist niet-leeg). + +## §2 — Design tokens ✅ GEDAAN (DESIGN.md = optioneel, nog niet geschreven) +- [x] `tokens.css`: committed blauw/petrol palette (cyaan `#4ebfd9`, actie `#0069ff`/`#0055ce`, petrol `#1d4852`/`#15363d`, off-white `#f6fafb`), getinte neutralen, status/ster-tokens. +- [x] Fonts **Archivo Expanded** (kop) + **Open Sans** (body). Géén Poppins/Fraunces. +- [x] `.btn-cta`/`.btn-outline`/`.btn-text`, section-helpers, `.reveal`. +- [ ] (optioneel) `DESIGN.md` schrijven via `/impeccable document`. + +## §3 — Emdash schema (seed/seed.json) + CMS voor SEO-partij 🟢 GESEED + LIVE (volledige migratie loopt, §3b) +**Doel (klant 16-06-2026):** de SEO-partij moet zelf **gewone pagina's én locatiepagina's** kunnen aanmaken en optimaliseren voor SEO. Bewust **SEO-vriendelijk + simpel** ingericht (3 onderdelen i.p.v. het volledige sectie-model). +- [x] **`seed/seed.json` geschreven** — collecties: **`site_settings`** (NAW + `address_street`/`address_city` + KvK/BTW/WhatsApp + social + **SEO-standaard meta_title/description** + **GTM** + logo/favicon/og_image), **`paginas`** (gewone pagina's: title/intro/portableText-content + SEO-groep), **`locaties`** (locatiepagina's: plaats/H1/intro/portableText-content + SEO-groep). SEO-groep = expliciete velden met nette NL-labels: `meta_title`, `meta_description`, `canonical`, `og_image`, `noindex`. Demo-content: `site_settings/main` (echte NAW, werkgebied "Helvoirt en omgeving", score 4,9) + 1 concept-sjabloon per type (Voorbeeldpagina + locatie Maastricht). +- [x] **Routes gebouwd** (lezen CMS met try/catch + fallback → nette 404 vóór seed): + - `src/pages/[...slug].astro` — **root-router**: zoekt de slug op in `locaties` (lokale pagina, petrol-hero + content) én `paginas` (gewone pagina); beide op de **root** (`/maastricht`, `/onderhoudstips`). Locatie wint bij botsing. Laagste prioriteit, PortableText, eigen branded 404. (Klantkeuze 16-06: locatiepagina's op `/URL`, niet `/werkgebied/URL`.) + - `src/pages/werkgebied/index.astro` — werkgebied-hub die gepubliceerde locaties oplijst (linkt naar `/`); anders statische pills. +- [x] **`src/lib/images.ts`** = `resolveCmsImage` (uit behangerbrabant) voor image-velden; **`Base.astro`** uitgebreid met `noindex`-prop + absolute OG-URL; gedeelde **`.prose`** in `tokens.css`. **`astro check` 0/0/0 + build OK.** +- [x] **`CMS-SEO-handleiding.md`** geschreven voor de SEO-partij (inloggen, pagina/locatie aanmaken, SEO-velden, do's/don'ts, wat nog in code zit). +- [x] **Geseed (16-06-2026):** setup-wizard gedraaid met "Include sample content" → `site_settings`/`paginas`/`locaties` + demo in remote D1. +- [x] **Ingebouwde `pages`/`posts` verwijderd** (emdash-defaults, leeg/ongebruikt) via `scripts/cleanup-default-collections.sql`. Blijven weg (defaults worden alléén bij setup aangemaakt). CMS toont nu alleen relevante onderdelen. +- [x] **`Base.astro` leest nu `site_settings.meta_title/meta_description/og_image`** (per-pagina prop > Instellingen > code-default) → SEO-partij kan site-brede SEO in Instellingen zetten. +- [ ] **CMS-login**: e-mail-provider-plugin (magic-link, newsite.md §6b) + SEO-partij-gebruiker(s) in `users`. (nog te doen) + +### §3b — Volledige CMS-migratie bestaande pagina's (klant: "Volledig naar CMS") ✅ KLAAR (17-06-2026) — alle pagina's + álle beelden in het CMS (tekst + media) +**Aanpak (bewezen, risico-arm):** per pagina (a) model in `scripts/cms-models/.mjs`, (b) `scripts/gen-cms-sql.mjs` genereert D1-SQL die emdash's eigen tabelstructuur spiegelt (overgenomen uit de echte `ec_paginas`-DDL; **géén search/FTS** op nieuwe collecties), (c) toepassen op remote D1 via `wrangler d1 execute --file migrations/*.sql`, (d) `.astro` herschrijven om uit CMS te lezen **met de huidige tekst als fallback** (site blijft identiek), (e) deploy + verifiëren. +- [x] **Machinerie gebouwd:** `scripts/gen-cms-sql.mjs` (generator: type→column_type, systeemkolommen, ULID via `ulidx`) + `scripts/cms-models/`. UTF-8 (·, —) blijft intact door de round-trip (geverifieerd). +- [x] **Wave 1 — Over ons KLAAR + LIVE-GEVERIFIEERD** (version `3ce0ce32`). Singleton **`over_ons`** (hero/verhaal/waarden-koppen + SEO; verhaal = portableText) + **`waarden` als inline repeater-veld** op over_ons. `over-ons.astro` leest uit CMS met fallback. Sentinel-test bewees CMS-aansturing; 4/4 repeater-rijen + icoon-SVG renderen live. Visueel identiek. +- [x] **Klantfeedback Over ons verwerkt (`0003_*.sql`):** "Waarden" van losse collectie → **inline repeater** (rijen toevoegen/verwijderen/**verslepen** binnen de pagina, WP-Bakery-achtig); kern-inhoudvelden **verplicht** zodat "(optional)" daar weg is. **Native emdash:** pagina's **dupliceren** (kopieer-icoon in lijst, `POST .../duplicate`), collectie-rijen add/delete. **"(optional)"** is niet globaal uitschakelbaar (geen branding/CSS-hook); alleen via velden verplicht maken. Repeater-velddef = `validation.subFields` (zie `scripts/gen-over-ons-repeater.mjs`). +- [x] **Beeldvelden toegevoegd aan Over ons (`0004_*.sql`):** `hero_image` (banner) + `story_image` (verhaalfoto), via `resolveCmsImage` met de huidige statische foto's als fallback (leeg veld = standaardfoto). Sentinel-test bewees dat het veld de pagina aanstuurt; leeg → fallback. **Les voor volgende waves: beeldvelden meenemen** (hero/galerij/dienst-foto's) — niet alleen tekst. +- [x] **Labels opgeschoond (`0005_*.sql`):** "(~N tekens)" uit alle SEO-labels (site_settings/paginas/locaties/over_ons) — ook in `seed.json` + model. **NB:** "(optional)" blijft (emdash-standaard, niet uitzetbaar). +- [x] **Foto's in de mediabibliotheek gezet (`0006_*.sql` + R2-upload, `scripts/gen-over-ons-media.mjs`):** `hero-after-1280.webp` + `werk-3.webp` naar R2 (`helvoirt-media`, key=`.webp`) + `media`-rijen + veld-JSON (local provider). De Over ons-beeldvelden tonen nu de echte foto in het CMS (niet leeg). Geverifieerd: media serveert via `/_emdash/api/media/file/` (200, image/webp) en de pagina gebruikt de CMS-afbeeldingen. **Patroon voor volgende waves:** zo ook de echte foto's voor homepage/diensten in de library zetten. +- [x] **Maastricht-locatiepagina LIVE** (`/maastricht`, gepubliceerd, 200, H1 correct). +- [x] **Wave 2a — Homepage TEKST KLAAR + LIVE-GEVERIFIEERD** (version `1223ddda`). Singleton **`homepage`** (`0007_*.sql`): hero (titel/sub/**vinkjes-repeater**), intro, diensten-koppen, **werkwijze-repeater** (4), bewijs-kop, reviews (uitgelicht + **kleine-reviews-repeater**), werkgebied-koppen, **FAQ-repeater** (4). `Hero.astro` (props) + `index.astro` lezen uit CMS met fallback. Sentinel-test bewees CMS-aansturing; alle repeaters renderen live. Generator uitgebreid met repeater-`validation`/subFields. +- [x] **Wave 2b — Homepage BEELD KLAAR + LIVE-GEVERIFIEERD (17-06-2026, `0011_homepage_media.sql`).** Collecties **`hero_cards`** (3) + **`galerij`** (6) als image-rijen (image+tekst); `Hero.astro` (via `cards`-prop uit `index.astro`) + de bewijs-sectie lezen eruit met fallback. Foto's in de mediabibliotheek (galerij hergebruikt de werk-foto's uit Wave-3-media; hero gebruikt kleine -768-varianten voor perf → 2 nieuwe media). Live: 3/3 hero + 6/6 galerij = CMS-media; screenshot bevestigt visueel identiek. +- [x] **Diensten-BEELD in de mediabibliotheek (`0010_diensten_media.sql`, `gen-diensten-media.mjs`).** 13 unieke foto's naar R2 + `media`-rijen, gekoppeld aan alle **25 beeldvelden** (nav/hero + 3 resultaat-slots × 5 diensten). Live: detailpagina 4/4, overzicht 5/5, accordion 5/5 = CMS-media. CMS serveert één formaat → -1280 voor hero/cards, werk-basis voor galerij; `srcsetFor` geeft `undefined` voor CMS-paden (geen kapotte srcset). +- [x] **Wave 3 — Diensten KLAAR + LIVE-GEVERIFIEERD (17-06-2026, `0009_diensten.sql`).** Collectie **`diensten`** (5 entries, 36 velden) gedeeld door: **homepage-accordion** (`nav_*`), **`/diensten`-overzicht** (`title` + `nav_*`), **`/diensten/[slug]`-detail** (alle velden: hero/points/intro/included/resultaat-galerij/aanpak/faq/seo/noindex) én het **nav-dropdownmenu** (`Base.astro`, op `nav_order`). Alles leest uit CMS met de hardcoded data als fallback. Beeldvelden leeg = code-fallback naar de huidige foto's (`nav_image`/`hero_image`/`result_N_image` per dienst; media-library-populatie = optionele vervolgstap zoals over-ons). Resultaat-galerij = **3 vaste image-slots** (repeater kan geen image-subveld). Sentinel-test bewees dat de CMS-waarden zowel de detailpagina als de homepage-accordion aansturen. Onbekende slug → 404. `astro check` 0. +- [x] **Wave 4 — Contact KLAAR + LIVE-GEVERIFIEERD** (version `f576e989`, `0008_*.sql`). Singleton **`contact_page`** (kicker/H1/intro + SEO). `contact.astro` leest uit CMS met fallback (NAW blijft uit `site_settings`). Sentinel-test OK. Generator is nu argument-gedreven: `node scripts/gen-cms-sql.mjs `. + +## §4 — Base layout + kerncomponenten ✅ GEDAAN +- [x] `Base.astro` (donkere petrol header met telefoon + CTA + dropdown, drawer, prefooter-form `#contact`, footer, sticky-CTA, reveal-IO, SEO-head, site_settings try/catch). +- [x] `ContactForm.astro` (naam [placeholder "Naam"], e-mail, telefoon, bericht + honeypot + time-trap). +- [x] `api/contact.ts` (spam-bescherming + validatie + **D1-opslag `contact_submissions` + 2 mails via `src/lib/smtp.ts`**; secret = §12). +- [ ] `CtaBand.astro` extraheren wanneer subpagina's het nodig hebben. + +## §5 — Homepage (index.astro) ✅ GEDAAN + GEPOLISHED +- [x] **Hero** = **tekst links + werkfoto-collage rechts** (Tailark hero-6 nagebouwd): Archivo-kop "Je gevel weer als nieuw." (800) + dominante blauwe CTA + vinkjes + prominente 4,9-Google-regel; rechts **3** staande werkfoto-cards die links overlopen in het tekstblok (`.hero__fade`), mobiel swipebaar. Doorlopen: craft → bolder (verworpen) → quieter → split (verworpen) → collage → audit 20/20 → critique 33/40 + fixes. **Géén before/after-slider** in hero (→ planning `/projecten`, §7). `hero-after/before/gevel.webp` + `degrade-before.py` nu ongebruikt op de homepage (de 3 hero-cards gebruiken `werk-1/3/5`; was 4 incl. `werk-6`/Metselwerk, op klantverzoek terug naar 3 + cards verbreed). +- [x] **Intro** (statement + inline meta), **Diensten = uitklappende beeld-accordion** (5 panelen, "+"-indicator, blijft open om te lezen, mobiel gestapeld), **Werkwijze**-tijdlijn (licht), **Bewijs**-galerij (echte foto's), **Reviews** (uitgelicht + 2 klein), **Werkgebied**-pills, **FAQ** "Goed om te weten". +- [x] Impeccable: craft ("het verschil zien") → bolder (donkere header) → overdrive (accordion) → **audit 19/20** (tapdoelen ≥44px, fetchpriority, tokens) → **critique 32/40** (accordion klikbaar, FAQ, scaffold-cleanup, hero→`#contact` + adresveld). +- [x] **`animate`-pass:** samenhangende motion-laag — consistente scroll-reveals over álle secties, werkwijze-tijdlijn tekent zich in (`.flow__line` + `.reveal--draw`), gestaggerde werkgebied-pills + FAQ (`.reveal--stagger`/`--i`), `:active`-indruk op `.btn-cta`/`.btn-outline`, offerteformulier-spinner (`aria-busy`) + geanimeerde succesmelding, trage hero-gloed. Nieuwe token-primitives `.reveal--draw`/`.reveal--stagger` in `tokens.css`. Alles achter `prefers-reduced-motion`. +- **Eerdere diensten-iteraties (verworpen):** zig-zag-banden → index+preview → foto-mozaïek → typografisch menu → **accordion** (definitief). +- [ ] Inline secties later extraheren naar herbruikbare componenten. + +## §6 — Diensten ✅ KLAAR (overzicht + 5 detailpagina's) +- [x] **`pages/diensten/[slug].astro`** = herbruikbaar SSR-sjabloon met **alle 5 slugs gevuld** (zie onder); per pagina dienst-hero (+4,9-regel) + intro/checklist + **resultaat-galerij** + mid-page CTA-band + aanpak-stappen + dienst-FAQ → CTA's `#contact`. Onbekende slug → **404** (getest). `astro check` 0 errors, axe-core **0 violations**, geen overflow (1440/768/390), mobiel stapelt netjes. +- [x] **Resultaat-galerij** i.p.v. een voor/na-slider op de dienstpagina (klantkeuze): 3 losse resultaatfoto's met bijschrift per stap (aanslag weg, voegwerk hersteld, strak/verzorgd). Reden: een echte voor/na-slider eist twee exact uitgelijnde foto's van dezelfde gevel, wat de klant zelden heeft. Werkt met losse werkfoto's. +- [x] **`components/BeforeAfter.astro`** = herbruikbare voor/na-sleepvergelijker (range-input → sleep + toetsenbord, `:has()`-focusring, "voor"/"na"-labels). **Nergens geïmporteerd** (projecten geschrapt, §7); bewaard als herbruikbaar component. +- [x] **Alle 5 slugs ingevuld** in `DIENSTEN`: `gevelreiniging`, `voegwerk`, `dakreiniging-coating`, `schoorsteen-voegwerk`, `algemeen-reinigen`. Elk: dienst-hero (+4,9-regel) + intro/checklist + resultaat-galerij + mid-page CTA-band + aanpak-stappen + dienst-FAQ. **Dak** gebruikt de 3 echte dakfoto's als narratief (mos → reinigen op hoogte → schoon, kop "Van mos tot een schoon dak"). Galerijen mixen de echte `werk-*`-foto's met dienst/hero-placeholders. +- [x] **Smoke-test:** alle 5 slugs → **200** (juiste H1), onbekend → **404**. Per nieuwe pagina: axe **clean**, geen overflow (1440/768/390). `astro check` 0 errors, build OK. +- [x] **`pages/diensten/index.astro`** (overzicht, 5 dienstkaarten met "Bekijk" → detail) gebouwd; nav-link "Diensten" werkt nu, breadcrumb-crumb "Diensten" hersteld op de detailpagina's. 200, axe clean, geen overflow. +- [ ] Image-fallback per slug bestaat al (`/images/dienst-.webp`). Echte projectfoto's vervangen de placeholders zodra de klant ze levert. +- [x] **`/impeccable critique` op `/diensten/gevelreiniging`** → **33/40** (sterk), detector `[]` (0 slop-patronen), cognitive load laag. Fixes doorgevoerd: **P0** alle CTA-knoppen (header/sticky/drawer) → `#contact` i.p.v. `/contact`-404 (globaal in `Base.astro`); **P1** 4,9-Google-trust-regel in de dienst-hero + **mid-page CTA-band** ("Klaar om je gevel weer als nieuw te zien?") na de galerij als desktop-snelkoppeling naar het formulier; **P2** dode breadcrumb-crumb "Diensten" verwijderd tot `/diensten` bestaat; **contrast-fix** stap-nummers cyaan → diep blauw (`--c-action-deep`, cyaan op `--soft` zakte onder WCAG). Galerij bewust gelaten (klantkeuze, foto's later). FAQ bewust plat gehouden (homepage-FAQ is óók plat; accordion zou juist inconsistent zijn). Na de fixes: axe 0, geen overflow (1440/768/390), build OK. + +## §7 — Projecten (before/after) ❌ GESCHRAPT (klantverzoek) +- [x] Projecten-pagina geschrapt op klantverzoek: uit de nav (`Base.astro`) en de homepage-link ("Alle projecten") verwijderd. `BeforeAfter.astro` blijft als **ongebruikt** herbruikbaar component staan (nergens geimporteerd, dus niet in de build). + +## §8 — Contact / Over-ons / Werkgebied 🟡 DEELS (contact + over-ons af) +- [x] **`pages/contact.astro`** (`hidePrefooter` + NAW + formulier, sectie `id="contact"`). Contactblok = **Bel ons / Mail ons / Adres** (Werkgebied + Bereikbaar op klantverzoek verwijderd) + 4,9-regel links, formulier-kaart rechts; e-mail op één regel (1-koloms). 200, axe clean, geen overflow. +- [x] **`pages/over-ons.astro`** (hero + 4,9-regel + verhaal met werkfoto + 4 waarden + werkgebied-pills). 200, axe clean, geen overflow. +- [x] Hoofd-CTA's (header/sticky/drawer) → `/contact` (was interim `#contact`); sticky-CTA verborgen wanneer `hidePrefooter` (dus op /contact). Naam-veld-placeholder in alle formulieren: "Naam" (was "Voor- en achternaam"). +- [ ] Form end-to-end testen (valid / honeypot / time-trap / 422) — na Mailtrap (§12). +- [ ] `pages/werkgebied.astro` (optioneel, lokale SEO) — nog te bepalen. + +## §9 — Footer-pagina's + SEO 🟢 GROTENDEELS KLAAR (17-06-2026, deploy `15e076ff`) +- [x] **Branded 404** bestaat nu via de catch-all `[...slug].astro` (onbekende route → nette 404-pagina met CTA i.p.v. blanco). Losse `404.astro` daardoor optioneel. +- [x] **`public/robots.txt`** (Allow /, Disallow /_emdash/ + /api/, Sitemap-regel). Live geverifieerd. +- [x] **`src/pages/sitemap.xml.ts`** = vaste code-routes + gepubliceerde `paginas` + `locaties` (live query, noindex uitgesloten; CMS-fout → vaste routes blijven). **Overschrijft emdash's eigen `/sitemap.xml`** (die gebruikt `/collectie/slug`, fout voor ons root-URL-model) — user-route in `src/pages/` wint (build-warning is verwacht/onschadelijk). Live geverifieerd: bevat `/maastricht` uit CMS + `/privacy`/`/voorwaarden`. +- [x] **`site: "https://helvoirtgevelreiniging.nl"`** in `astro.config.mjs` → canonical + sitemap-base wijzen naar het echte domein (ook vóór koppeling). +- [x] **`voorwaarden.astro`** = Algemene voorwaarden (17-06-2026: herschreven o.b.v. zusterbedrijf onderhoudsbedrijfvanhelvoirt.nl, 10 artikelen; **klant juridisch laten controleren**) + **in het CMS** (singleton `voorwaarden`, artikelen-repeater, `0012_voorwaarden.sql`, sentinel-geverifieerd). **Privacyverklaring op klantverzoek VERWIJDERD** (pagina + footer-link + sitemap; /privacy → 404). NB: voor de AVG is een privacyverklaring bij een contactformulier doorgaans wél aan te raden. +- [x] **JSON-LD `HomeAndConstructionBusiness`** (LocalBusiness-subtype) site-breed in `Base.astro`: naam/desc/url/image/telefoon/e-mail/adres (gesplitst) + areaServed + aggregateRating (4,9) + optioneel kvk/btw/sameAs uit `site_settings`. Live geverifieerd (rendert met CMS-data). +- [x] **GTM-renderblok** in `Base.astro` (head-script + body-noscript), leest `site_settings.gtm`; leeg = niets geïnjecteerd (geverifieerd). + theme-color + twitter-cards + og:image-default. +- [x] **Gebrande 1200×630 OG-image** (`public/images/og-default.webp` via `scripts/make-og.py`: schone gevel + petrol-gradient + wordmark + 4,9-Google) + `og:image:width/height/type` in `Base.astro`. Live (200, image/webp). + **`apple-touch-icon.png`** (180×180 H-monogram) + ``. +- [x] **`Service` + `BreadcrumbList` JSON-LD** op de 5 dienst-detailpagina's (`diensten/[slug].astro`, matcht het zichtbare kruimelpad). 3 ld+json-blokken per dienstpagina (LocalBusiness + Service + Breadcrumb). Live geverifieerd. +- [ ] **Resterend (optioneel):** `FAQPage` JSON-LD (wacht op echte FAQ-teksten — placeholder nu, schema zou dun zijn), self-hosted fonts (Archivo Expanded niet via css2, zie §10/notitie), Lighthouse/CWV-meting (keyless PSI = 429, **eigen PSI-key nodig**). + +## §10 — Beeld-pipeline 🟡 DEELS +- [x] nano-banana placeholders: `hero-after/before.webp`, `hero-gevel.webp`, 5× `dienst-*.webp`; echte input → `werk-1..6.webp`; 768/1280/1920-varianten + `IMG_WIDTHS`/`srcsetFor` bedraad. In gebruik: dienst-hero's (`dienst-*`), homepage-hero + galerijen (`werk-*`), dienst-resultaat-galerijen + over-ons (`hero-after/gevel`). Ongebruikt: `hero-before.webp` (alleen via het geparkeerde `BeforeAfter.astro`). +- [ ] Echte foto's van klant verwerken → placeholders vervangen; `IMG_WIDTHS` bijwerken. + +## §11 — Cloudflare provisioning + deploy ✅ GEDAAN (preview) +- [x] `wrangler d1 create helvoirt-db` → `database_id` `0fb83f17-f91d-4910-ac8c-80f00569ce35` in `wrangler.jsonc`; `r2 bucket create helvoirt-media`; KV `SESSION` auto-provisioned. +- [x] `astro build` + `wrangler deploy` → **`https://helvoirt-gevelreiniging.mike-197.workers.dev`** (alle marketing-pagina's 200 op fallbacks; onbekend → 404; `/_emdash/admin` → setup). Laatste deploy: version `ff725970`. +- [ ] Remote D1 seeden (dump/restore per tabel, NIET hele DB) + setup-wizard remote — hangt af van §3 (`seed.json`). + +## §12 — Domein + Mail + CMS-login 🟡 CODE KLAAR, EXTERNE STAPPEN OPEN (17-06-2026) +- [x] **Mail-code af** (patroon uit exterios/behangerbrabant): `src/lib/smtp.ts` (authenticated SMTP over `cloudflare:sockets`, poort 465) + `api/contact.ts` slaat nu op in **D1 `contact_submissions`** (migratie `0001`, **toegepast op remote D1**) + stuurt **2 mails** (melding-klant + autoreply) via `Promise.allSettled`. **Mailtrap geschrapt** → eigen mailbox-SMTP (besluit conform newsite.md §6). Live geverifieerd: POST → 200, D1-rij opgeslagen (sentinel getest + opgeruimd). **Mail is no-op tot secret gezet is.** +- [x] **CMS-login-code af:** `src/plugins/smtp-email.ts` (email:deliver-provider voor magic-link) + geregistreerd in `astro.config.mjs` (`plugins:[…]`, absoluut pad/forward-slashes). + `src/cloudflare-env.d.ts` (env/sockets types) + `src/middleware.ts` (http→https + www→apex 301, no-op op *.workers.dev). +- [x] **MAIL WERKT (17-06-2026, deploy `cb425dc4`).** `CONTACT_SMTP_PASS` secret gezet (bash printf). **`CONTACT_SMTP_HOST=srv8.dewebsmid.nl`** in `wrangler.jsonc` vars — mailbox draait op srv8 (ontdekt via `/webmail`→roundcube-redirect); `mail.helvoirtgevelreiniging.nl` is NXDOMAIN in Cloudflare-DNS. Live getest: beide mails verstuurd, alle paden (valid/422/honeypot/time-trap) OK, testrijen opgeruimd. Magic-link werkt nu ook (zelfde SMTP). +- [ ] **Mail-DNS in Cloudflare (collega; mijn token mag geen DNS, 403):** A `mail`→srv8-IP (185.84.143.157, DNS-only), MX `@`→mail.helvoirtgevelreiniging.nl, SPF, DKIM (key uit DirectAdmin), DMARC. Nodig voor INKOMENDE mail naar info@ van externen + afleverbaarheid autoreply. Outbound form-mail werkt al zonder. +- [ ] **CMS-gebruiker(s) SEO-partij** in `users` aanmaken (SQL, §6b newsite.md) — e-mailadres(sen) opvragen. +- [x] **DOMEIN LIVE (17-06-2026, deploy `7ed8f33b`).** Zone `helvoirtgevelreiniging.nl` actief op Cloudflare (NS `cesar`/`priscilla.ns.cloudflare.com`). Custom domain (apex + `www`) gekoppeld via `wrangler.jsonc` `routes` + rebuild. **Site live op `https://helvoirtgevelreiniging.nl`** (alle pagina's 200, canonical/sitemap = echte domein, 404 werkt). www→apex + http→https = 301 (middleware). `*.workers.dev` is **uitgeschakeld** (custom-domain-only; geen duplicate content). NS-blokkade was: registrar wees nog naar hosted.nl i.p.v. Cloudflare → collega heeft NS omgezet. +- [ ] **Mail-DNS** (MX/SPF/DKIM/DMARC) — wacht op mailbox (collega) + DNS-edit-token/dashboard (OAuth-token mag geen zone-settings/DNS, gaf 403). **HTTPS-hardening** (Always Use HTTPS + HSTS) = optioneel via dashboard; redirects werken al via middleware, SSL-cert actief (Workers custom domain → SSL-modus n.v.t.). +- [ ] Echte NAW/reviews/werkgebied/FAQ + GTM-ID in `site_settings`; echte foto's. + +## §13 — QA & oplevering 🟡 DEELS +- [x] **`/impeccable audit` (hele site) = 17/20 (Good).** Fixes doorgevoerd + live (version `ff725970`): tap-doelen ≥44px (telefoon/e-mail prefooter+contact, logo, skip-link), scrim-kleuren → tokens, dienst-kaarttitels → `

`, eerste-fold-beelden eager+`fetchpriority`, accordion-reflow `contain: layout`. axe 0 op alle 9 pagina's, geen overflow (1440/768/390), `astro check` 0, detector `[]`. +- [ ] Lighthouse/CWV (mobile+desktop) meten, formulier end-to-end (na Mailtrap §12), git-remote + eerste commit/push. + +--- + +## Gedaan (referentie) +- [x] Input gelezen (Content.docx + 6 werkfoto's), referentiesite geanalyseerd, `architecture.md`/`todo.md`/`CLAUDE.md` opgesteld en bijgehouden. diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..a9210e6 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "astro/tsconfigs/strict", + "include": [".astro/types.d.ts", "**/*"], + "exclude": ["dist"] +} diff --git a/wrangler.jsonc b/wrangler.jsonc new file mode 100644 index 0000000..27eb438 --- /dev/null +++ b/wrangler.jsonc @@ -0,0 +1,36 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "helvoirt-gevelreiniging", + "main": "./src/worker.ts", + "compatibility_date": "2026-03-29", + "compatibility_flags": [ + "nodejs_compat" + ], + "routes": [ + { "pattern": "helvoirtgevelreiniging.nl", "custom_domain": true }, + { "pattern": "www.helvoirtgevelreiniging.nl", "custom_domain": true } + ], + // SMTP-host = de DirectAdmin-server waar de mailbox info@helvoirtgevelreiniging.nl op staat. + // Bewust srv8.dewebsmid.nl i.p.v. mail.helvoirtgevelreiniging.nl: dat laatste resolvet (nog) + // niet via Cloudflare-DNS. srv8 resolvet wel + heeft een geldig TLS-cert. User/From/To blijven + // info@helvoirtgevelreiniging.nl (zie src/pages/api/contact.ts). Wachtwoord = secret CONTACT_SMTP_PASS. + "vars": { + "CONTACT_SMTP_HOST": "srv8.dewebsmid.nl" + }, + "d1_databases": [ + { + "binding": "DB", + "database_name": "helvoirt-db", + "database_id": "0fb83f17-f91d-4910-ac8c-80f00569ce35" + } + ], + "r2_buckets": [ + { + "binding": "MEDIA", + "bucket_name": "helvoirt-media" + } + ], + "observability": { + "enabled": true + } +}