Eerste commit voor Helvoirt Gevelreiniging
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
BASE = "http://localhost:4321"
|
||||
ROUTES = ["/", "/diensten", "/diensten/gevelreiniging", "/diensten/voegwerk",
|
||||
"/diensten/dakreiniging-coating", "/diensten/schoorsteen-voegwerk",
|
||||
"/diensten/algemeen-reinigen", "/over-ons", "/contact"]
|
||||
|
||||
def scroll_through(page):
|
||||
h = page.evaluate("document.body.scrollHeight")
|
||||
for y in range(0, h, 500):
|
||||
page.evaluate(f"window.scrollTo(0,{y})"); page.wait_for_timeout(40)
|
||||
page.evaluate("window.scrollTo(0,0)"); page.wait_for_timeout(250)
|
||||
|
||||
with sync_playwright() as p:
|
||||
b = p.chromium.launch(headless=True)
|
||||
|
||||
print("=== DESKTOP 1440: axe / images / headings ===")
|
||||
ctx = b.new_context(viewport={"width": 1440, "height": 900})
|
||||
pg = ctx.new_page()
|
||||
for r in ROUTES:
|
||||
pg.goto(BASE + r, wait_until="networkidle", timeout=60000); pg.wait_for_timeout(400)
|
||||
scroll_through(pg)
|
||||
pg.add_script_tag(url="https://cdnjs.cloudflare.com/ajax/libs/axe-core/4.10.2/axe.min.js")
|
||||
pg.wait_for_timeout(300)
|
||||
viol = pg.evaluate("""async()=>{const r=await axe.run(document,{runOnly:['wcag2a','wcag2aa','wcag21aa']});return r.violations.map(v=>v.impact+':'+v.id+'x'+v.nodes.length);}""")
|
||||
imgs = pg.evaluate("""()=>{const a=[...document.images];return {total:a.length,noalt:a.filter(i=>i.getAttribute('alt')===null).length,eager:a.filter(i=>i.loading!=='lazy').map(i=>i.currentSrc.split('/').pop())};}""")
|
||||
heads = pg.eval_on_selector_all("h1,h2,h3", "e=>e.map(x=>x.tagName)")
|
||||
print(f"{r:32} axe:{'clean' if not viol else viol} imgs:{imgs['total']} noalt:{imgs['noalt']} eager:{imgs['eager']} h1x{heads.count('H1')} hcount:{len(heads)}")
|
||||
ctx.close()
|
||||
|
||||
print("\n=== OVERFLOW 1440/768/390 ===")
|
||||
for r in ROUTES:
|
||||
flags = []
|
||||
for w in (1440, 768, 390):
|
||||
c = b.new_context(viewport={"width": w, "height": 900}); pp = c.new_page()
|
||||
pp.goto(BASE + r, wait_until="networkidle", timeout=60000)
|
||||
sw = pp.evaluate("document.documentElement.scrollWidth"); cw = pp.evaluate("document.documentElement.clientWidth")
|
||||
flags.append(f"{w}:{'OVERFLOW' if sw > cw + 1 else 'ok'}"); c.close()
|
||||
print(f"{r:32} {', '.join(flags)}")
|
||||
|
||||
print("\n=== MOBIEL 390: tapdoelen <44px (zichtbare controls) ===")
|
||||
m = b.new_context(viewport={"width": 390, "height": 844}); mp = m.new_page()
|
||||
for r in ["/", "/contact", "/diensten/gevelreiniging", "/over-ons"]:
|
||||
mp.goto(BASE + r, wait_until="networkidle", timeout=60000); mp.wait_for_timeout(500)
|
||||
small = mp.evaluate("""()=>{const els=[...document.querySelectorAll('a,button,input,select,textarea')];const o=[];
|
||||
for(const e of els){const r=e.getBoundingClientRect();if(r.width===0||r.height===0)continue;const s=getComputedStyle(e);if(s.visibility==='hidden'||s.display==='none')continue;
|
||||
if(r.height<44||r.width<44){const inline=(e.tagName==='A'&&s.display.includes('inline')&&!e.className);o.push((e.tagName.toLowerCase())+'.'+(String(e.className).split(' ')[0]||'?')+' '+Math.round(r.width)+'x'+Math.round(r.height)+(inline?' (inline)':''));}}
|
||||
return o;}""")
|
||||
print(f"{r:30} {len(small)} controls <44px: {small[:10]}")
|
||||
b.close()
|
||||
@@ -0,0 +1,47 @@
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
with sync_playwright() as p:
|
||||
b = p.chromium.launch(headless=True)
|
||||
|
||||
# Touch-targets op mobiel (390)
|
||||
pg = b.new_context(viewport={"width": 390, "height": 844}).new_page()
|
||||
pg.goto("http://localhost:4321/", wait_until="networkidle", timeout=60000)
|
||||
print("=== HERO TOUCH-TARGETS @390 (min 44px) ===")
|
||||
for name, sel in {
|
||||
"CTA primair": ".hero__cta-primary",
|
||||
"CTA secundair (link)": ".hero__cta-secondary",
|
||||
"card (swipe)": ".hero__card",
|
||||
}.items():
|
||||
el = pg.query_selector(sel)
|
||||
if el:
|
||||
box = el.bounding_box()
|
||||
if box:
|
||||
flag = "" if min(box["width"], box["height"]) >= 44 else " <-- < 44"
|
||||
print(f" {name:22} {box['width']:.0f} x {box['height']:.0f}{flag}")
|
||||
else:
|
||||
print(f" {name:22} (niet gevonden)")
|
||||
|
||||
# Hero gallery: scrollbaar op mobiel?
|
||||
rail = pg.query_selector(".hero__rail")
|
||||
if rail:
|
||||
sw = pg.eval_on_selector(".hero__rail", "e=>e.scrollWidth")
|
||||
cw = pg.eval_on_selector(".hero__rail", "e=>e.clientWidth")
|
||||
print(f"\n mobiele rail: scrollWidth={sw} clientWidth={cw} swipebaar={'ja' if sw>cw+1 else 'nee'}")
|
||||
|
||||
# Hero image-attrs (desktop)
|
||||
d = b.new_context(viewport={"width": 1440, "height": 900}).new_page()
|
||||
d.goto("http://localhost:4321/", wait_until="networkidle", timeout=60000)
|
||||
print("\n=== HERO GALLERY IMAGES (desktop, geladen bron) ===")
|
||||
imgs = d.eval_on_selector_all(".hero__shot img",
|
||||
"els=>els.map(e=>({src:e.currentSrc.split('/').pop(), w:e.naturalWidth, loading:e.loading, dec:e.decoding}))")
|
||||
for i in imgs:
|
||||
print(" ", i)
|
||||
|
||||
# LCP-kandidaat (grootste element boven de vouw) - ruwe indicatie
|
||||
print("\n=== H1 ===")
|
||||
h1 = d.query_selector(".hero__title")
|
||||
if h1:
|
||||
bb = h1.bounding_box()
|
||||
print(f" h1 box: {bb['width']:.0f} x {bb['height']:.0f} @ top={bb['y']:.0f}")
|
||||
|
||||
b.close()
|
||||
@@ -0,0 +1,32 @@
|
||||
from playwright.sync_api import sync_playwright
|
||||
import json
|
||||
with sync_playwright() as p:
|
||||
b = p.chromium.launch(headless=True)
|
||||
pg = b.new_context(viewport={"width":1440,"height":1000}).new_page()
|
||||
pg.goto("http://localhost:4321/", wait_until="networkidle", timeout=60000)
|
||||
try:
|
||||
pg.add_script_tag(url="https://cdnjs.cloudflare.com/ajax/libs/axe-core/4.10.2/axe.min.js")
|
||||
pg.wait_for_timeout(500)
|
||||
res = pg.evaluate("""async () => {
|
||||
const r = await axe.run(document, {runOnly:['wcag2a','wcag2aa','wcag21aa']});
|
||||
return r.violations.map(v=>({id:v.id, impact:v.impact, n:v.nodes.length,
|
||||
help:v.help, sample:(v.nodes[0]&&v.nodes[0].target)||[]}));
|
||||
}""")
|
||||
print("=== AXE VIOLATIONS ===")
|
||||
for v in res:
|
||||
print(f"[{v['impact']}] {v['id']} x{v['n']} :: {v['help']}")
|
||||
print(f" e.g. {v['sample']}")
|
||||
if not res: print("geen axe-violations")
|
||||
except Exception as e:
|
||||
print("AXE FAILED:", e)
|
||||
# heading outline
|
||||
heads = pg.eval_on_selector_all("h1,h2,h3,h4", "els=>els.map(e=>e.tagName+': '+e.textContent.trim().slice(0,42))")
|
||||
print("\n=== HEADINGS ==="); [print(h) for h in heads]
|
||||
# horizontale overflow
|
||||
for w in (1440, 768, 390):
|
||||
ctx = b.new_context(viewport={"width":w,"height":900})
|
||||
pp = ctx.new_page(); pp.goto("http://localhost:4321/", wait_until="networkidle", timeout=60000)
|
||||
sw = pp.evaluate("document.documentElement.scrollWidth"); cw = pp.evaluate("document.documentElement.clientWidth")
|
||||
print(f"\nviewport {w}: scrollWidth={sw} clientWidth={cw} overflow={'JA' if sw>cw+1 else 'nee'}")
|
||||
ctx.close()
|
||||
b.close()
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Verwijder de ingebouwde emdash-standaardcollecties "pages" en "posts".
|
||||
-- Ze zijn leeg en worden door de site niet gebruikt (wij gebruiken paginas/locaties).
|
||||
-- Emdash maakt defaults alléén bij setup aan (niet per boot), dus dit blijft weg
|
||||
-- zolang de setup-wizard niet opnieuw draait.
|
||||
DROP TABLE IF EXISTS ec_pages;
|
||||
DROP TABLE IF EXISTS ec_posts;
|
||||
DROP TABLE IF EXISTS _emdash_fts_pages;
|
||||
DROP TABLE IF EXISTS _emdash_fts_posts;
|
||||
DELETE FROM _emdash_fields WHERE collection_id IN (SELECT id FROM _emdash_collections WHERE slug IN ('pages','posts'));
|
||||
DELETE FROM _emdash_collections WHERE slug IN ('pages','posts');
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Contactpagina (wave 4) — alleen de introteksten + SEO. NAW (telefoon/e-mail/adres)
|
||||
* komt al uit `site_settings`; de labels (Bel ons/Mail ons/Adres) blijven in code.
|
||||
*/
|
||||
export const contact = [
|
||||
{
|
||||
slug: "contact_page",
|
||||
label: "Contactpagina",
|
||||
labelSingular: "Contactpagina",
|
||||
fields: [
|
||||
{ slug: "eyebrow", label: "Kicker", type: "string" },
|
||||
{ slug: "title", label: "Titel (H1)", type: "string", required: true },
|
||||
{ slug: "intro", label: "Intro", type: "text", required: true },
|
||||
{ slug: "meta_title", label: "SEO · paginatitel", type: "string" },
|
||||
{ slug: "meta_description", label: "SEO · omschrijving", type: "text" },
|
||||
],
|
||||
content: [
|
||||
{
|
||||
slug: "main",
|
||||
status: "published",
|
||||
data: {
|
||||
eyebrow: "Contact",
|
||||
title: "Vraag een gratis offerte aan",
|
||||
intro: "Laat je gegevens achter, dan plannen we een moment om langs te komen en het werk te bekijken. Je krijgt een heldere prijs op papier, zonder verplichtingen. Liever even bellen of mailen? Dat kan ook.",
|
||||
meta_title: "Contact en gratis offerte · Helvoirt Gevelreiniging",
|
||||
meta_description: "Neem contact op met Helvoirt Gevelreiniging voor een gratis, vrijblijvende offerte. Bel, mail of laat je gegevens achter via het formulier.",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,356 @@
|
||||
/**
|
||||
* Diensten (wave 3) — collectie van 5 dienstpagina's, gedeeld door:
|
||||
* - de homepage-accordion (nav_* velden)
|
||||
* - het /diensten-overzicht (nav_* velden)
|
||||
* - de /diensten/<slug>-detailpagina's (alle velden)
|
||||
* `data` = exact de huidige hardcoded tekst → de site blijft identiek zodra de pagina's
|
||||
* uit het CMS lezen (met de code als fallback). Beeldvelden blijven leeg → code valt terug
|
||||
* op de huidige statische foto's (/images/dienst-*.webp, werk-*.webp); de SEO-partij kan
|
||||
* later een eigen foto uploaden per veld.
|
||||
*/
|
||||
export const diensten = [
|
||||
{
|
||||
slug: "diensten",
|
||||
label: "Diensten",
|
||||
labelSingular: "Dienst",
|
||||
supports: ["drafts", "revisions"],
|
||||
fields: [
|
||||
// --- Menu/overzicht (homepage-accordion + /diensten) ---
|
||||
{ slug: "nav_order", label: "Volgorde in het menu (1-5)", type: "integer" },
|
||||
{ slug: "nav_title", label: "Menu/overzicht · titel", type: "string", required: true },
|
||||
{ slug: "nav_text", label: "Menu/overzicht · korte tekst", type: "text", required: true },
|
||||
{ slug: "nav_image", label: "Overzicht · afbeelding (leeg = standaardfoto)", type: "image" },
|
||||
{ slug: "nav_alt", label: "Overzicht · alt-tekst", type: "string" },
|
||||
// --- Detail-hero ---
|
||||
{ slug: "title", label: "Detailpagina · titel (H1)", type: "string", required: true },
|
||||
{ slug: "eyebrow", label: "Detailpagina · kicker", type: "string" },
|
||||
{ slug: "lead", label: "Detailpagina · introzin (hero)", type: "text", required: true },
|
||||
{ slug: "hero_image", label: "Hero · afbeelding (leeg = standaardfoto)", type: "image" },
|
||||
{ slug: "hero_alt", label: "Hero · alt-tekst", type: "string" },
|
||||
{ slug: "hero_caption", label: "Hero · bijschrift", type: "string" },
|
||||
{ slug: "points", label: "Hero · vinkjes (rijen)", type: "repeater", required: false,
|
||||
subFields: [{ slug: "text", type: "string", label: "Tekst", required: true }] },
|
||||
// --- Intro + 'wat we doen' ---
|
||||
{ slug: "intro_heading", label: "Intro · kop", type: "string", required: true },
|
||||
{ slug: "intro_body", label: "Intro · alinea's (rijen)", type: "repeater", required: false,
|
||||
subFields: [{ slug: "text", type: "text", label: "Alinea", required: true }] },
|
||||
{ slug: "included_title", label: "Wat we doen · kop", type: "string" },
|
||||
{ slug: "included", label: "Wat we doen · punten (rijen)", type: "repeater", required: false,
|
||||
subFields: [{ slug: "text", type: "string", label: "Punt", required: true }] },
|
||||
// --- Resultaat-galerij (3 vaste slots; beeld leeg = standaardfoto) ---
|
||||
{ slug: "resultaat_heading", label: "Resultaat · kop", type: "string" },
|
||||
{ slug: "resultaat_sub", label: "Resultaat · subtekst", type: "text" },
|
||||
{ slug: "result_1_image", label: "Resultaat 1 · afbeelding (leeg = standaardfoto)", type: "image" },
|
||||
{ slug: "result_1_alt", label: "Resultaat 1 · alt-tekst", type: "string" },
|
||||
{ slug: "result_1_caption", label: "Resultaat 1 · bijschrift", type: "string" },
|
||||
{ slug: "result_1_detail", label: "Resultaat 1 · detailtekst", type: "text" },
|
||||
{ slug: "result_2_image", label: "Resultaat 2 · afbeelding (leeg = standaardfoto)", type: "image" },
|
||||
{ slug: "result_2_alt", label: "Resultaat 2 · alt-tekst", type: "string" },
|
||||
{ slug: "result_2_caption", label: "Resultaat 2 · bijschrift", type: "string" },
|
||||
{ slug: "result_2_detail", label: "Resultaat 2 · detailtekst", type: "text" },
|
||||
{ slug: "result_3_image", label: "Resultaat 3 · afbeelding (leeg = standaardfoto)", type: "image" },
|
||||
{ slug: "result_3_alt", label: "Resultaat 3 · alt-tekst", type: "string" },
|
||||
{ slug: "result_3_caption", label: "Resultaat 3 · bijschrift", type: "string" },
|
||||
{ slug: "result_3_detail", label: "Resultaat 3 · detailtekst", type: "text" },
|
||||
// --- Aanpak-stappen ---
|
||||
{ slug: "aanpak_title", label: "Aanpak · kop", type: "string" },
|
||||
{ slug: "aanpak", label: "Aanpak · stappen (rijen)", type: "repeater", required: false,
|
||||
subFields: [
|
||||
{ slug: "title", type: "string", label: "Titel", required: true },
|
||||
{ slug: "text", type: "text", label: "Tekst" },
|
||||
] },
|
||||
// --- FAQ ---
|
||||
{ slug: "faq", label: "FAQ · vragen (rijen)", type: "repeater", required: false,
|
||||
subFields: [
|
||||
{ slug: "question", type: "string", label: "Vraag", required: true },
|
||||
{ slug: "answer", type: "text", label: "Antwoord" },
|
||||
] },
|
||||
// --- SEO ---
|
||||
{ slug: "meta_title", label: "SEO · paginatitel", type: "string" },
|
||||
{ slug: "meta_description", label: "SEO · omschrijving", type: "text" },
|
||||
{ slug: "noindex", label: "SEO · uitsluiten van Google (noindex)", type: "boolean" },
|
||||
],
|
||||
content: [
|
||||
{
|
||||
slug: "gevelreiniging",
|
||||
status: "published",
|
||||
data: {
|
||||
nav_order: 1,
|
||||
nav_title: "Gevelreiniging + renovatie",
|
||||
nav_text: "Jaren aanslag, groene vervuiling en vuil weg, en herstel waar nodig. De gevel staat er weer strak bij, zonder schade aan de steen.",
|
||||
nav_alt: "Bakstenen gevel half gereinigd: links nog vuil, rechts weer schoon",
|
||||
title: "Gevelreiniging en renovatie",
|
||||
eyebrow: "Onze diensten",
|
||||
lead: "Jaren van aanslag, groene vervuiling en vuil halen we van je gevel, en waar nodig herstellen we de steen en het voegwerk. Je huis staat er weer strak en verzorgd bij, zonder schade aan de gevel.",
|
||||
hero_alt: "Bakstenen gevel halverwege de reiniging: links nog vuil, rechts weer schoon",
|
||||
hero_caption: "Halverwege de reiniging",
|
||||
points: [{ text: "Veilig voor de steen" }, { text: "Herstel inbegrepen" }, { text: "Eerlijke prijs vooraf" }],
|
||||
intro_heading: "Een schone, gezonde gevel die er weer staat als nieuw",
|
||||
intro_body: [
|
||||
{ text: "Een vieze gevel is meer dan een kwestie van uiterlijk. Groene aanslag, algen en mos houden vocht vast, en dat tast op den duur de steen en het voegwerk aan. Wij halen die laag eraf met een methode die past bij jouw gevel, en pakken zwakke plekken meteen mee." },
|
||||
{ text: "Of het nu om een rijtjeshuis gaat of om een hele bedrijfsgevel: je krijgt vooraf een vaste prijs en een duidelijke planning. We werken netjes, dekken alles goed af en leveren schoon op." },
|
||||
],
|
||||
included_title: "Wat we voor je doen",
|
||||
included: [
|
||||
{ text: "Groene aanslag, algen en mos verwijderen" },
|
||||
{ text: "Vuil, roet en uitslag van de gevel halen" },
|
||||
{ text: "De juiste reinigingsmethode per steensoort" },
|
||||
{ text: "Loszittend of beschadigd voegwerk herstellen" },
|
||||
{ text: "Optioneel impregneren tegen nieuwe vervuiling" },
|
||||
{ text: "Alles netjes afgedekt en opgeruimd" },
|
||||
],
|
||||
resultaat_heading: "Zo ziet het resultaat eruit",
|
||||
resultaat_sub: "Een greep uit ons werk: schone gevels, hersteld voegwerk en steen die er weer verzorgd bij staat.",
|
||||
result_1_alt: "Schone bakstenen gevel na het verwijderen van aanslag en mos",
|
||||
result_1_caption: "Aanslag en mos verwijderd",
|
||||
result_1_detail: "Groene vervuiling en vuil weg, zonder de steen te beschadigen.",
|
||||
result_2_alt: "Strak hersteld voegwerk tussen de bakstenen",
|
||||
result_2_caption: "Voegwerk hersteld",
|
||||
result_2_detail: "Loszittende en kapotte voegen vervangen, weer netjes en waterdicht.",
|
||||
result_3_alt: "Verzorgde, schone gevel van een woonhuis",
|
||||
result_3_caption: "Strak en verzorgd",
|
||||
result_3_detail: "De gevel staat er weer als nieuw bij, klaar voor jaren vooruit.",
|
||||
aanpak_title: "Zo pakken we het aan",
|
||||
aanpak: [
|
||||
{ title: "Inspectie en advies", text: "We bekijken de steen en de mate van vervuiling, en kiezen de methode die schoon maakt zonder de gevel te beschadigen." },
|
||||
{ title: "Reinigen op maat", text: "Van een zachte lagedruk-reiniging tot een steviger aanpak bij hardnekkige aanslag, afgestemd op jouw gevel." },
|
||||
{ title: "Herstel waar nodig", text: "Loszittend of kapot voegwerk en kleine beschadigingen pakken we in dezelfde klus mee." },
|
||||
{ title: "Strak opgeleverd", text: "We spoelen na, ruimen alles op en lopen het samen na, zodat je tevreden achterblijft." },
|
||||
],
|
||||
faq: [
|
||||
{ question: "Beschadigt gevelreiniging de steen?", answer: "Niet als het goed gebeurt. We stemmen de druk en het reinigingsmiddel af op de steensoort, zodat de gevel schoon wordt zonder dat de steen of de voegen eronder lijden." },
|
||||
{ question: "Welke methode gebruiken jullie?", answer: "Dat hangt van de gevel af. Soms volstaat een zachte lagedruk-reiniging, bij zware aanslag is een steviger aanpak nodig. We kiezen altijd wat veilig en effectief is voor jouw situatie." },
|
||||
{ question: "Hoe lang blijft de gevel schoon?", answer: "Dat verschilt per ligging en omgeving. Een gevel op het noorden vervuilt sneller dan een zonnige kant. Met een impregneerlaag gaat het langer mee, omdat vuil en vocht minder grip krijgen." },
|
||||
{ question: "Doen jullie ook het voegwerk?", answer: "Ja. Loszittend of beschadigd voegwerk herstellen we in dezelfde klus, zodat de gevel niet alleen schoon is maar ook weer heel en waterdicht." },
|
||||
],
|
||||
meta_title: "Gevelreiniging en renovatie in Helvoirt en omgeving · Helvoirt Gevelreiniging",
|
||||
meta_description: "Gevelreiniging en renovatie door Helvoirt Gevelreiniging. Aanslag en groene vervuiling weg, herstel van steen en voegwerk, veilig voor de gevel. Vraag een gratis offerte aan.",
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: "voegwerk",
|
||||
status: "published",
|
||||
data: {
|
||||
nav_order: 2,
|
||||
nav_title: "Voegwerkzaamheden",
|
||||
nav_text: "Oud en kapot voegwerk uithakken en opnieuw invoegen. Dat houdt de gevel waterdicht en netjes, passend bij het huis.",
|
||||
nav_alt: "Strak nieuw voegwerk tussen rode bakstenen",
|
||||
title: "Voegwerkzaamheden",
|
||||
eyebrow: "Onze diensten",
|
||||
lead: "Oud, uitgesleten of kapot voegwerk vervangen we vakkundig. We hakken de slechte voegen uit en voegen opnieuw in, in een kleur en stijl die bij je huis passen. Zo blijft de gevel waterdicht en netjes.",
|
||||
hero_alt: "Strak nieuw voegwerk tussen rode bakstenen van een gevel",
|
||||
hero_caption: "Vers ingevoegd",
|
||||
points: [{ text: "Past bij je gevel" }, { text: "Weer waterdicht" }, { text: "Eerlijke prijs vooraf" }],
|
||||
intro_heading: "Goed voegwerk houdt je gevel heel en droog",
|
||||
intro_body: [
|
||||
{ text: "Voegen zijn meer dan een afwerking. Ze houden het vocht buiten en de stenen op hun plek. Als de voegen uitslijten, verzanden of loslaten, krijgt water vrij spel en kan er vorstschade en lekkage ontstaan. Wij hakken het slechte voegwerk uit en voegen opnieuw in." },
|
||||
{ text: "We stemmen de kleur en de vorm van de voeg af op je huis, zodat het werk niet opvalt maar gewoon klopt. Bij oudere panden werken we waar nodig met een ademende kalkmortel." },
|
||||
],
|
||||
included_title: "Wat we voor je doen",
|
||||
included: [
|
||||
{ text: "Oude en kapotte voegen uithakken" },
|
||||
{ text: "Opnieuw invoegen in de juiste kleur en vorm" },
|
||||
{ text: "Knip-, snij- of platvol voegwerk naar wens" },
|
||||
{ text: "Passende mortel, ook voor oudere panden" },
|
||||
{ text: "Scheuren en kleine beschadigingen meenemen" },
|
||||
{ text: "Netjes afgewerkt en opgeruimd" },
|
||||
],
|
||||
resultaat_heading: "Zo ziet het resultaat eruit",
|
||||
resultaat_sub: "Een greep uit ons voegwerk: strak ingevoegd, waterdicht en passend bij de gevel.",
|
||||
result_1_alt: "Strak nieuw voegwerk tussen de bakstenen van een gevel",
|
||||
result_1_caption: "Strak ingevoegd",
|
||||
result_1_detail: "Uitgehakt en opnieuw gevoegd in een passende kleur.",
|
||||
result_2_alt: "Hersteld metsel- en voegwerk aan een tuinmuur met trap",
|
||||
result_2_caption: "Metsel- en voegwerk hersteld",
|
||||
result_2_detail: "Loszittende stenen en voegen weer vast en netjes.",
|
||||
result_3_alt: "Verzorgde gevel met strak voegwerk",
|
||||
result_3_caption: "Verzorgd geheel",
|
||||
result_3_detail: "Schone steen en strak voegwerk, weer als nieuw.",
|
||||
aanpak_title: "Zo pakken we het aan",
|
||||
aanpak: [
|
||||
{ title: "Inspectie en advies", text: "We bekijken de voegen en de steen, en bepalen welke mortel en voegvorm passen." },
|
||||
{ title: "Uithakken", text: "Het oude, slechte voegwerk halen we netjes weg tot op de juiste diepte." },
|
||||
{ title: "Opnieuw invoegen", text: "We voegen in met de juiste mortel, kleur en afwerking voor jouw gevel." },
|
||||
{ title: "Strak opgeleverd", text: "We borstelen na, ruimen op en lopen het samen na." },
|
||||
],
|
||||
faq: [
|
||||
{ question: "Wanneer is voegwerk aan vervanging toe?", answer: "Als voegen verzanden, uitslijten, loslaten of als je vocht of vorstschade ziet. Wij beoordelen het vrijblijvend en zeggen eerlijk of het nodig is." },
|
||||
{ question: "Kunnen jullie de kleur van de voeg aanpassen?", answer: "Ja. We stemmen de kleur en de vorm af op je huis, zodat het nieuwe voegwerk natuurlijk overgaat in het bestaande beeld." },
|
||||
{ question: "Doen jullie ook oudere of monumentale panden?", answer: "Ja. Bij oudere panden gebruiken we waar nodig een ademende kalkmortel, zodat de muur droog kan blijven ademen." },
|
||||
{ question: "Moet de hele gevel opnieuw gevoegd worden?", answer: "Niet altijd. Soms volstaat het om de slechte vlakken aan te pakken. We adviseren wat verstandig en kostenefficiënt is." },
|
||||
],
|
||||
meta_title: "Voegwerkzaamheden in Helvoirt en omgeving · Helvoirt Gevelreiniging",
|
||||
meta_description: "Voegwerk vervangen en herstellen door Helvoirt Gevelreiniging. Oude voegen uithakken en opnieuw invoegen in de juiste kleur en mortel, weer waterdicht. Vraag een gratis offerte aan.",
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: "dakreiniging-coating",
|
||||
status: "published",
|
||||
data: {
|
||||
nav_order: 3,
|
||||
nav_title: "Dak reinigen + coating",
|
||||
nav_text: "Mos en aanslag eraf, daarna een beschermende coating. Een schoon dak dat water beter afvoert en langer mooi blijft.",
|
||||
nav_alt: "Schoon dakpannendak na reiniging en coating",
|
||||
title: "Dak reinigen en coaten",
|
||||
eyebrow: "Onze diensten",
|
||||
lead: "Mos, algen en aanslag halen we van je dak, daarna brengen we waar gewenst een beschermende coating aan. Een schoon dak dat water beter afvoert en langer mooi en heel blijft.",
|
||||
hero_alt: "Schoon dakpannendak na reiniging en coating",
|
||||
hero_caption: "Schoon en beschermd",
|
||||
points: [{ text: "Mos en aanslag eraf" }, { text: "Beschermende coating" }, { text: "Eerlijke prijs vooraf" }],
|
||||
intro_heading: "Een schoon dak gaat langer mee",
|
||||
intro_body: [
|
||||
{ text: "Mos en algen houden vocht vast en kunnen pannen poreus maken en de waterafvoer verstoppen. Wij reinigen het dak grondig en verwijderen de begroeiing, zodat het water weer vrij wegloopt en je dak er weer netjes bij ligt." },
|
||||
{ text: "Daarna kun je kiezen voor een beschermende coating. Die sluit de pannen af, gaat nieuwe aangroei tegen en geeft het dak weer een egale, frisse kleur." },
|
||||
],
|
||||
included_title: "Wat we voor je doen",
|
||||
included: [
|
||||
{ text: "Mos, algen en aanslag verwijderen" },
|
||||
{ text: "Dak grondig reinigen, veilig op hoogte" },
|
||||
{ text: "Dakgoten en afvoer meenemen" },
|
||||
{ text: "Optioneel een beschermende coating" },
|
||||
{ text: "Kleurherstel voor een egaal, fris dak" },
|
||||
{ text: "Afgespoelde resten netjes opgeruimd" },
|
||||
],
|
||||
resultaat_heading: "Van mos tot een schoon dak",
|
||||
resultaat_sub: "Mos eraf, grondig gereinigd en waar gewenst beschermend gecoat.",
|
||||
result_1_alt: "Dakpannendak vol mos voor de reiniging",
|
||||
result_1_caption: "Mos en algen verwijderen",
|
||||
result_1_detail: "Begroeiing houdt vocht vast en verstopt de afvoer, die halen we eraf.",
|
||||
result_2_alt: "Dak wordt op hoogte gereinigd",
|
||||
result_2_caption: "Grondig reinigen op hoogte",
|
||||
result_2_detail: "Veilig en zorgvuldig, ook de lastig bereikbare delen.",
|
||||
result_3_alt: "Schoon dakpannendak na reiniging en coating",
|
||||
result_3_caption: "Schoon en beschermd opgeleverd",
|
||||
result_3_detail: "Een egaal, fris dak, klaar voor jaren vooruit.",
|
||||
aanpak_title: "Zo pakken we het aan",
|
||||
aanpak: [
|
||||
{ title: "Inspectie op hoogte", text: "We bekijken het dak, de pannen en de afvoer, en bepalen de juiste aanpak." },
|
||||
{ title: "Reinigen", text: "Mos, algen en aanslag verwijderen we grondig, veilig en netjes op hoogte." },
|
||||
{ title: "Coaten (optioneel)", text: "Een beschermende coating sluit de pannen af en gaat nieuwe aangroei tegen." },
|
||||
{ title: "Strak opgeleverd", text: "We spoelen na, ruimen de resten op en lopen het samen na." },
|
||||
],
|
||||
faq: [
|
||||
{ question: "Is mijn dak geschikt voor coating?", answer: "De meeste pannendaken wel. We beoordelen de staat van de pannen eerst; soms is alleen reinigen verstandiger. We zeggen eerlijk wat het beste is." },
|
||||
{ question: "Hoe lang blijft het dak schoon?", answer: "Dat hangt van de ligging en de begroeiing in de omgeving af. Met een coating gaat het langer mee, omdat mos en vuil minder grip krijgen." },
|
||||
{ question: "Werken jullie veilig op hoogte?", answer: "Ja. We werken met de juiste voorzieningen, zodat het werk veilig en zorgvuldig gebeurt." },
|
||||
{ question: "Beschadigt de reiniging de dakpannen niet?", answer: "Niet als de druk en de methode kloppen. We stemmen die af op de staat van de pannen, zodat het dak schoon wordt zonder schade." },
|
||||
],
|
||||
meta_title: "Dak reinigen en coaten in Helvoirt en omgeving · Helvoirt Gevelreiniging",
|
||||
meta_description: "Dakreiniging en coating door Helvoirt Gevelreiniging. Mos en aanslag eraf, een beschermende coating en betere waterafvoer. Vraag een gratis offerte aan.",
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: "schoorsteen-voegwerk",
|
||||
status: "published",
|
||||
data: {
|
||||
nav_order: 4,
|
||||
nav_title: "Schoorsteen voegwerk",
|
||||
nav_text: "Voeg-, metsel- en loodwerk aan de schoorsteen herstellen, zodat lekkage en verdere schade voorkomen worden.",
|
||||
nav_alt: "Herstelde schoorsteen met nieuw voegwerk op een dak",
|
||||
title: "Schoorsteen voegwerk",
|
||||
eyebrow: "Onze diensten",
|
||||
lead: "Voeg- en metselwerk aan de schoorsteen herstellen we vakkundig, vaak in combinatie met het loodwerk. Zo voorkomen we lekkage en verdere schade, en staat de schoorsteen er weer netjes en veilig bij.",
|
||||
hero_alt: "Herstelde schoorsteen met nieuw voegwerk en loodwerk op een dak",
|
||||
hero_caption: "Schoorsteen hersteld",
|
||||
points: [{ text: "Voorkomt lekkage" }, { text: "Vaak met loodwerk" }, { text: "Eerlijke prijs vooraf" }],
|
||||
intro_heading: "Een schoorsteen die weer waterdicht is",
|
||||
intro_body: [
|
||||
{ text: "De schoorsteen krijgt veel weer te verduren en is een veelvoorkomende bron van lekkage. Loszittend voegwerk, scheuren in het metselwerk of slecht aansluitend lood laten water binnen, soms tot in de woning. Wij herstellen het voeg- en metselwerk en pakken het loodwerk in dezelfde klus mee." },
|
||||
{ text: "Omdat het werk op hoogte gebeurt, werken we veilig en zorgvuldig. Je krijgt vooraf een duidelijke prijs, ook als blijkt dat alleen een deel hersteld hoeft te worden." },
|
||||
],
|
||||
included_title: "Wat we voor je doen",
|
||||
included: [
|
||||
{ text: "Loszittend voegwerk uithakken en opnieuw invoegen" },
|
||||
{ text: "Scheuren en beschadigd metselwerk herstellen" },
|
||||
{ text: "Loodwerk nakijken en waar nodig vervangen" },
|
||||
{ text: "Aansluitingen weer waterdicht maken" },
|
||||
{ text: "Veilig werken op hoogte" },
|
||||
{ text: "Netjes opgeleverd en opgeruimd" },
|
||||
],
|
||||
resultaat_heading: "Zo ziet het resultaat eruit",
|
||||
resultaat_sub: "Schoorstenen met hersteld voeg-, metsel- en loodwerk, weer waterdicht en netjes.",
|
||||
result_1_alt: "Schoorsteen met nieuw aangebracht loodwerk op een dak",
|
||||
result_1_caption: "Nieuw loodwerk",
|
||||
result_1_detail: "Strak aangesloten lood rond de schoorsteen, weer waterdicht.",
|
||||
result_2_alt: "Hersteld metsel- en voegwerk",
|
||||
result_2_caption: "Metselwerk hersteld",
|
||||
result_2_detail: "Loszittende stenen en voegen weer vast en netjes.",
|
||||
result_3_alt: "Herstelde schoorsteen met nieuw voegwerk op een dak",
|
||||
result_3_caption: "Schoorsteen weer netjes",
|
||||
result_3_detail: "Hersteld voeg- en loodwerk, klaar voor jaren vooruit.",
|
||||
aanpak_title: "Zo pakken we het aan",
|
||||
aanpak: [
|
||||
{ title: "Inspectie op hoogte", text: "We bekijken het voeg-, metsel- en loodwerk en sporen de oorzaak van lekkage op." },
|
||||
{ title: "Herstellen", text: "Slecht voegwerk hakken we uit, scheuren en metselwerk herstellen we." },
|
||||
{ title: "Loodwerk waterdicht", text: "Het lood kijken we na en vervangen we waar nodig, zodat alles weer goed aansluit." },
|
||||
{ title: "Veilig opgeleverd", text: "We ruimen op en lopen het werk samen na." },
|
||||
],
|
||||
faq: [
|
||||
{ question: "Hoe weet ik of mijn schoorsteen lekt?", answer: "Vochtplekken op zolder of rond de schoorsteen, of een muffe geur, wijzen vaak op een lekkende schoorsteen. We zoeken vrijblijvend de oorzaak op." },
|
||||
{ question: "Doen jullie ook het loodwerk?", answer: "Ja. Voeg-, metsel- en loodwerk pakken we het liefst in een klus, want de lekkage zit vaak in de combinatie van die drie." },
|
||||
{ question: "Moet de schoorsteen helemaal opnieuw?", answer: "Lang niet altijd. Vaak volstaat herstel van de slechte plekken. We zeggen eerlijk wat nodig is." },
|
||||
{ question: "Werken jullie veilig op hoogte?", answer: "Ja. We werken met de juiste voorzieningen, zodat het werk veilig en netjes gebeurt." },
|
||||
],
|
||||
meta_title: "Schoorsteen voegwerk en herstel in Helvoirt en omgeving · Helvoirt Gevelreiniging",
|
||||
meta_description: "Schoorsteen voegwerk, metselwerk en loodwerk herstellen door Helvoirt Gevelreiniging. Voorkom lekkage en schade, weer waterdicht. Vraag een gratis offerte aan.",
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: "algemeen-reinigen",
|
||||
status: "published",
|
||||
data: {
|
||||
nav_order: 5,
|
||||
nav_title: "Algemeen reinigen",
|
||||
nav_text: "Terras, oprit, bestrating en ander buitenwerk. We pakken het hele plaatje aan en maken de buitenkant weer fris.",
|
||||
nav_alt: "Terras en bestrating worden schoongespoten",
|
||||
title: "Algemeen reinigen",
|
||||
eyebrow: "Onze diensten",
|
||||
lead: "Terras, oprit, bestrating en ander buitenwerk maken we weer fris. We pakken het hele plaatje rond je huis aan, zodat de buitenkant er weer verzorgd en veilig bij ligt.",
|
||||
hero_alt: "Terras en bestrating worden schoongespoten",
|
||||
hero_caption: "Bestrating weer fris",
|
||||
points: [{ text: "Terras en oprit" }, { text: "Heel het buitenwerk" }, { text: "Eerlijke prijs vooraf" }],
|
||||
intro_heading: "De hele buitenkant weer fris",
|
||||
intro_body: [
|
||||
{ text: "Niet alleen de gevel, ook het terras, de oprit, de bestrating en ander buitenwerk vervuilen en vergroenen na verloop van tijd. Groene aanslag maakt bestrating bovendien glad en onveilig. Wij reinigen het grondig, zodat alles er weer netjes en veilig bij ligt." },
|
||||
{ text: "We kijken naar het hele plaatje rond je huis en stemmen de aanpak af op de ondergrond, of het nu om sierbestrating, beton of natuursteen gaat." },
|
||||
],
|
||||
included_title: "Wat we voor je doen",
|
||||
included: [
|
||||
{ text: "Terras, oprit en bestrating reinigen" },
|
||||
{ text: "Groene aanslag en onkruid aanpakken" },
|
||||
{ text: "Tuinmuren, schuttingen en bijgebouwen" },
|
||||
{ text: "Aanpak afgestemd op de ondergrond" },
|
||||
{ text: "Optioneel opnieuw invegen van voegzand" },
|
||||
{ text: "Alles netjes opgeruimd en afgespoeld" },
|
||||
],
|
||||
resultaat_heading: "Zo ziet het resultaat eruit",
|
||||
resultaat_sub: "Een greep uit ons werk: terrassen, bestrating en muren weer schoon en fris.",
|
||||
result_1_alt: "Schone tuinmuur en bestrating na reiniging",
|
||||
result_1_caption: "Tuinmuur en bestrating",
|
||||
result_1_detail: "Muur en terras weer schoon en verzorgd.",
|
||||
result_2_alt: "Schone tuintrap en metselwerk",
|
||||
result_2_caption: "Tuintrap en metselwerk",
|
||||
result_2_detail: "Bestrating en trap weer netjes en veilig.",
|
||||
result_3_alt: "Vers gereinigd terras en bestrating",
|
||||
result_3_caption: "Terras weer fris",
|
||||
result_3_detail: "Groene aanslag eraf, de bestrating is weer veilig.",
|
||||
aanpak_title: "Zo pakken we het aan",
|
||||
aanpak: [
|
||||
{ title: "Inspectie en advies", text: "We bekijken het buitenwerk en de ondergrond, en kiezen de juiste methode." },
|
||||
{ title: "Reinigen op maat", text: "Met de juiste druk halen we aanslag en vuil weg, zonder de bestrating te beschadigen." },
|
||||
{ title: "Afwerken", text: "Waar nodig vegen we voegzand opnieuw in en pakken we onkruid aan." },
|
||||
{ title: "Strak opgeleverd", text: "We spoelen na, ruimen op en lopen het samen na." },
|
||||
],
|
||||
faq: [
|
||||
{ question: "Wat kunnen jullie allemaal reinigen?", answer: "Terras, oprit, bestrating, tuinmuren, schuttingen en bijgebouwen. Vrijwel al het buitenwerk rond je huis." },
|
||||
{ question: "Beschadigt hogedruk de bestrating niet?", answer: "Niet als de druk klopt. We stemmen die af op de ondergrond, zodat het schoon wordt zonder schade." },
|
||||
{ question: "Vegen jullie het voegzand opnieuw in?", answer: "Dat kan. Na het reinigen kunnen we het voegzand aanvullen, zodat de bestrating stevig blijft liggen." },
|
||||
{ question: "Kan dit samen met de gevelreiniging?", answer: "Zeker. Veel klanten combineren het, dan staat de hele buitenkant in een keer weer fris. Dat scheelt ook in de kosten." },
|
||||
],
|
||||
meta_title: "Terras, oprit en bestrating reinigen in Helvoirt en omgeving · Helvoirt Gevelreiniging",
|
||||
meta_description: "Algemeen reinigen door Helvoirt Gevelreiniging: terras, oprit, bestrating en ander buitenwerk weer fris en veilig. Vraag een gratis offerte aan.",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Homepage — tekstinhoud (wave 2a). Eén singleton `homepage` met losse velden + inline
|
||||
* repeaters (vinkjes, werkwijze-stappen, kleine reviews, FAQ). Beeld (hero-cards + galerij)
|
||||
* komt in wave 2b als aparte collecties (repeaters kunnen geen image-subveld bevatten).
|
||||
* `data` = exact de huidige tekst → site blijft identiek zodra index/Hero uit het CMS lezen.
|
||||
*/
|
||||
export const homepage = [
|
||||
{
|
||||
slug: "homepage",
|
||||
label: "Homepage",
|
||||
labelSingular: "Homepage",
|
||||
fields: [
|
||||
{ slug: "hero_title", label: "Hero · titel (H1)", type: "string", required: true },
|
||||
{ slug: "hero_sub", label: "Hero · subtekst", type: "text", required: true },
|
||||
{ slug: "hero_points", label: "Hero · vinkjes (rijen)", type: "repeater", required: false,
|
||||
subFields: [{ slug: "text", type: "string", label: "Tekst", required: true }] },
|
||||
{ slug: "intro_statement", label: "Intro · statement", type: "text", required: true },
|
||||
{ slug: "intro_body", label: "Intro · alinea", type: "text", required: true },
|
||||
{ slug: "intro_meta", label: "Intro · meta-regel", type: "string" },
|
||||
{ slug: "diensten_kicker", label: "Diensten · kicker", type: "string" },
|
||||
{ slug: "diensten_kop", label: "Diensten · kop", type: "string", required: true },
|
||||
{ slug: "werkwijze_kop", label: "Werkwijze · kop", type: "string", required: true },
|
||||
{ slug: "werkwijze", label: "Werkwijze · stappen (rijen)", type: "repeater", required: false,
|
||||
subFields: [
|
||||
{ slug: "title", type: "string", label: "Titel", required: true },
|
||||
{ slug: "text", type: "text", label: "Tekst" },
|
||||
] },
|
||||
{ slug: "proof_kop", label: "Bewijs-galerij · kop", type: "string", required: true },
|
||||
{ slug: "reviews_featured_text", label: "Reviews · uitgelichte quote", type: "text", required: true },
|
||||
{ slug: "reviews_featured_name", label: "Reviews · uitgelichte naam", type: "string" },
|
||||
{ slug: "reviews_featured_place", label: "Reviews · uitgelichte plaats", type: "string" },
|
||||
{ slug: "reviews_small", label: "Reviews · kleine reviews (rijen)", type: "repeater", required: false,
|
||||
subFields: [
|
||||
{ slug: "text", type: "text", label: "Reviewtekst", required: true },
|
||||
{ slug: "name", type: "string", label: "Naam" },
|
||||
] },
|
||||
{ slug: "gebied_kop", label: "Werkgebied · kop", type: "string", required: true },
|
||||
{ slug: "gebied_sub", label: "Werkgebied · subtekst", type: "string" },
|
||||
{ slug: "faq_kop", label: "FAQ · kop", type: "string", required: true },
|
||||
{ slug: "faq_lead", label: "FAQ · inleiding", type: "string" },
|
||||
{ slug: "faq", label: "FAQ · vragen (rijen)", type: "repeater", required: false,
|
||||
subFields: [
|
||||
{ slug: "question", type: "string", label: "Vraag", required: true },
|
||||
{ slug: "answer", type: "text", label: "Antwoord" },
|
||||
] },
|
||||
],
|
||||
content: [
|
||||
{
|
||||
slug: "main",
|
||||
status: "published",
|
||||
data: {
|
||||
hero_title: "Je gevel weer als nieuw.",
|
||||
hero_sub: "Van groene aanslag tot kapot voegwerk: wij maken de buitenkant van je huis weer schoon en heel. Netjes opgeleverd, met een eerlijke prijs vooraf.",
|
||||
hero_points: [
|
||||
{ text: "Eerlijke prijs vooraf" },
|
||||
{ text: "Netjes opgeleverd" },
|
||||
{ text: "Particulier en zakelijk" },
|
||||
],
|
||||
intro_statement: "Schoon, strak en vakkundig. Dat is waar Helvoirt Gevelreiniging voor staat.",
|
||||
intro_body: "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.",
|
||||
intro_meta: "Particulier en zakelijk · eerlijke prijs vooraf · netjes opgeleverd",
|
||||
diensten_kicker: "Onze diensten",
|
||||
diensten_kop: "Vakwerk voor je hele buitenkant",
|
||||
werkwijze_kop: "Van eerste belletje tot schoon opgeleverd",
|
||||
werkwijze: [
|
||||
{ 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." },
|
||||
],
|
||||
proof_kop: "Echt werk, echte resultaten",
|
||||
reviews_featured_text: "Onze gevel zat vol groene aanslag en ziet er nu weer als nieuw uit. Keurig gewerkt, alles netjes afgedekt en opgeruimd. Echt vakwerk.",
|
||||
reviews_featured_name: "Familie Jansen",
|
||||
reviews_featured_place: "Helvoirt",
|
||||
reviews_small: [
|
||||
{ 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" },
|
||||
],
|
||||
gebied_kop: "Werkzaam in Helvoirt en omgeving",
|
||||
gebied_sub: "Staat jouw plaats er niet bij? Bel of mail gerust, vaak komen we er ook.",
|
||||
faq_kop: "Goed om te weten",
|
||||
faq_lead: "De vragen die klanten ons meestal als eerste stellen.",
|
||||
faq: [
|
||||
{ question: "Wat bepaalt de prijs?", answer: "De grootte van het oppervlak, de mate van vervuiling en de bereikbaarheid. Je krijgt vooraf een vaste prijs, zonder verrassingen achteraf." },
|
||||
{ question: "Hoe lang duurt het?", answer: "Dat hangt van het werk af. Bij de offerte hoor je een concrete planning, zodat je weet waar je aan toe bent." },
|
||||
{ question: "Geven jullie garantie?", answer: "Ja. We werken netjes en staan achter ons werk; de afspraken leggen we vast in de offerte." },
|
||||
{ question: "Moet ik thuis zijn?", answer: "Voor de inspectie en de oplevering is het handig, maar lang niet altijd nodig. We stemmen het samen af." },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Content-model voor de Over ons-pagina (wave 1 van de volledige CMS-migratie).
|
||||
* De `data` is exact de huidige hardgecodeerde tekst, zodat de site identiek blijft
|
||||
* zodra de pagina uit het CMS leest (met dezelfde tekst als fallback in de .astro).
|
||||
*/
|
||||
export const overOns = [
|
||||
{
|
||||
slug: "over_ons",
|
||||
label: "Over ons (pagina)",
|
||||
labelSingular: "Over ons",
|
||||
fields: [
|
||||
{ slug: "hero_eyebrow", label: "Hero · kicker", type: "string" },
|
||||
{ slug: "hero_title", label: "Hero · titel (H1)", type: "string", required: true },
|
||||
{ slug: "hero_lead", label: "Hero · inleiding", type: "text" },
|
||||
{ slug: "story_title", label: "Verhaal · titel (H2)", type: "string" },
|
||||
{ slug: "story_body", label: "Verhaal · tekst", type: "portableText" },
|
||||
{ slug: "values_title", label: "Waarden · kop (H2)", type: "string" },
|
||||
{ slug: "values_intro", label: "Waarden · inleiding", type: "string" },
|
||||
{ slug: "meta_title", label: "SEO · paginatitel", type: "string" },
|
||||
{ slug: "meta_description", label: "SEO · omschrijving", type: "text" },
|
||||
],
|
||||
content: [
|
||||
{
|
||||
slug: "main",
|
||||
status: "published",
|
||||
data: {
|
||||
hero_eyebrow: "Over ons",
|
||||
hero_title: "De vakmensen achter een schone gevel",
|
||||
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.",
|
||||
story_title: "Schoon, strak en vakkundig",
|
||||
story_body: [
|
||||
{
|
||||
_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.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
values_title: "Waarom Helvoirt Gevelreiniging",
|
||||
values_intro: "Wat je van ons mag verwachten, bij elke klus.",
|
||||
meta_title: "Over ons · Helvoirt Gevelreiniging",
|
||||
meta_description:
|
||||
"Helvoirt Gevelreiniging is een lokaal gevelreinigings- en renovatiebedrijf. Vakwerk, een eerlijke prijs vooraf en netjes opgeleverd, in Helvoirt en omgeving.",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "waarden",
|
||||
label: "Waarden (over ons)",
|
||||
labelSingular: "Waarde",
|
||||
fields: [
|
||||
{ slug: "title", label: "Titel", type: "string", required: true },
|
||||
{ slug: "text", label: "Tekst", type: "text" },
|
||||
{ slug: "icon", label: "Icoon (SVG-inhoud, geavanceerd)", type: "text" },
|
||||
{ slug: "sort_order", label: "Volgorde", type: "integer" },
|
||||
],
|
||||
content: [
|
||||
{
|
||||
slug: "eerlijke-prijs-vooraf",
|
||||
status: "published",
|
||||
data: {
|
||||
title: "Eerlijke prijs vooraf",
|
||||
text: "Je krijgt een duidelijke offerte op papier. Geen verrassingen achteraf, geen kleine lettertjes.",
|
||||
icon: '<path d="M20.59 13.41 13.42 20.58a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><circle cx="7" cy="7" r="1.4"/>',
|
||||
sort_order: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: "echt-vakwerk",
|
||||
status: "published",
|
||||
data: {
|
||||
title: "Echt vakwerk",
|
||||
text: "We kennen ons vak en de materialen. Elke gevel, elk dak en elke schoorsteen krijgt de juiste aanpak.",
|
||||
icon: '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="m8.5 12 2.2 2.2 4.5-4.5"/>',
|
||||
sort_order: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: "netjes-opgeleverd",
|
||||
status: "published",
|
||||
data: {
|
||||
title: "Netjes opgeleverd",
|
||||
text: "We dekken alles goed af, ruimen op en lopen het werk samen met je na. Je huis blijft schoon achter.",
|
||||
icon: '<path d="M12 3l1.8 5.4L19 10l-5.2 1.6L12 17l-1.8-5.4L5 10l5.2-1.6z"/>',
|
||||
sort_order: 3,
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: "lokaal-en-betrokken",
|
||||
status: "published",
|
||||
data: {
|
||||
title: "Lokaal en betrokken",
|
||||
text: "We werken in Helvoirt en omgeving. Korte lijnen, snel even langs en altijd persoonlijk aanspreekbaar.",
|
||||
icon: '<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/>',
|
||||
sort_order: 4,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Algemene voorwaarden — singleton `voorwaarden`, beheerbaar in het CMS.
|
||||
* Gebaseerd op de voorwaarden van het zusterbedrijf (onderhoudsbedrijfvanhelvoirt.nl),
|
||||
* aangepast naar Helvoirt Gevelreiniging. Artikelen = inline repeater (toevoegen/
|
||||
* verwijderen/verslepen). voorwaarden.astro leest hieruit met de tekst als fallback.
|
||||
*/
|
||||
export const voorwaarden = [
|
||||
{
|
||||
slug: "voorwaarden",
|
||||
label: "Algemene voorwaarden",
|
||||
labelSingular: "Algemene voorwaarden",
|
||||
fields: [
|
||||
{ slug: "title", label: "Titel (H1)", type: "string", required: true },
|
||||
{ slug: "intro", label: "Intro", type: "text" },
|
||||
{ slug: "articles", label: "Artikelen (rijen)", type: "repeater", required: false,
|
||||
subFields: [
|
||||
{ slug: "heading", type: "string", label: "Artikelkop", required: true },
|
||||
{ slug: "body", type: "text", label: "Artikeltekst", required: true },
|
||||
] },
|
||||
{ slug: "meta_title", label: "SEO · paginatitel", type: "string" },
|
||||
{ slug: "meta_description", label: "SEO · omschrijving", type: "text" },
|
||||
],
|
||||
content: [
|
||||
{
|
||||
slug: "main",
|
||||
status: "published",
|
||||
data: {
|
||||
title: "Algemene voorwaarden",
|
||||
intro: "Deze algemene voorwaarden zijn van toepassing op alle aanbiedingen, offertes en overeenkomsten tussen Helvoirt Gevelreiniging en de klant.",
|
||||
articles: [
|
||||
{ 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." },
|
||||
],
|
||||
meta_title: "Algemene voorwaarden · Helvoirt Gevelreiniging",
|
||||
meta_description: "De algemene voorwaarden van Helvoirt Gevelreiniging: offertes, uitvoering, betaling, garantie en aansprakelijkheid.",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,26 @@
|
||||
def lin(c):
|
||||
c/=255
|
||||
return c/12.92 if c<=0.03928 else ((c+0.055)/1.055)**2.4
|
||||
def L(hex):
|
||||
hex=hex.lstrip("#"); r,g,b=int(hex[0:2],16),int(hex[2:4],16),int(hex[4:6],16)
|
||||
return 0.2126*lin(r)+0.7152*lin(g)+0.0722*lin(b)
|
||||
def ratio(a,b):
|
||||
la,lb=L(a),L(b); hi,lo=max(la,lb),min(la,lb)
|
||||
return (hi+0.05)/(lo+0.05)
|
||||
pairs=[
|
||||
("ink-soft op paper","#5b6a70","#f6fafb","body"),
|
||||
("ink-soft op paper-soft","#5b6a70","#eef4f7","body"),
|
||||
("ink-soft op tint","#5b6a70","#e3f6fd","body"),
|
||||
("ink op paper","#1f2a2e","#f6fafb","body"),
|
||||
("action-deep link op paper","#0055ce","#f6fafb","body"),
|
||||
("action(blue) tekst op paper","#0069ff","#f6fafb","body/eyebrow"),
|
||||
("wit op action (CTA)","#ffffff","#0069ff","ui"),
|
||||
("on-dark-soft op petrol","#c7dae0","#1d4852","body"),
|
||||
("brand cyaan op petrol-deep","#4ebfd9","#15363d","label"),
|
||||
("nav wit86 op petrol-deep","#dfe6e8","#15363d","nav"),
|
||||
("apanel desc wit82 op petrol","#d6dee0","#1d4852","body"),
|
||||
]
|
||||
for name,fg,bg,kind in pairs:
|
||||
r=ratio(fg,bg)
|
||||
aa = "AA-ok" if r>=4.5 else ("AA-large-ok" if r>=3 else "FAIL")
|
||||
print(f"{r:4.2f} {aa:12} {name}")
|
||||
@@ -0,0 +1,12 @@
|
||||
import glob, os
|
||||
from PIL import Image, ImageOps
|
||||
src = sorted(glob.glob("input/foto's/*.jpeg"))
|
||||
os.makedirs("public/images", exist_ok=True)
|
||||
for i, p in enumerate(src, 1):
|
||||
im = ImageOps.exif_transpose(Image.open(p)).convert("RGB")
|
||||
w, h = im.size
|
||||
if w > 1600:
|
||||
im = im.resize((1600, round(h*1600/w)))
|
||||
out = f"public/images/werk-{i}.webp"
|
||||
im.save(out, "WEBP", quality=82, method=6)
|
||||
print("OK", out, im.size)
|
||||
@@ -0,0 +1,32 @@
|
||||
from PIL import Image, ImageEnhance, ImageDraw, ImageFilter
|
||||
import random
|
||||
random.seed(7)
|
||||
im = Image.open("public/images/hero-after.webp").convert("RGB")
|
||||
w, h = im.size
|
||||
# ontkleuren + verdonkeren
|
||||
im = ImageEnhance.Color(im).enhance(0.5)
|
||||
im = ImageEnhance.Brightness(im).enhance(0.82)
|
||||
im = ImageEnhance.Contrast(im).enhance(0.92)
|
||||
# groen/grijs algen-overlay, sterker naar onderen
|
||||
ov = Image.new("RGB", (w, h), (60, 78, 55))
|
||||
mask = Image.new("L", (w, h), 0)
|
||||
md = mask.load()
|
||||
for y in range(h):
|
||||
base = int(40 + 150 * (y / h) ** 1.6) # onder veel sterker
|
||||
for x in range(0, w, 4):
|
||||
v = base
|
||||
md[x, y] = max(0, min(190, v))
|
||||
if x+1 < w: md[x+1, y] = md[x, y]
|
||||
if x+2 < w: md[x+2, y] = md[x, y]
|
||||
if x+3 < w: md[x+3, y] = md[x, y]
|
||||
mask = mask.filter(ImageFilter.GaussianBlur(8))
|
||||
im = Image.composite(ov, im, mask)
|
||||
# donkere drip-strepen
|
||||
d = ImageDraw.Draw(im, "RGBA")
|
||||
for _ in range(70):
|
||||
x = random.randint(0, w); top = random.randint(0, int(h*0.5)); ln = random.randint(int(h*0.2), int(h*0.7))
|
||||
a = random.randint(12, 34)
|
||||
d.line([(x, top), (x + random.randint(-6,6), top+ln)], fill=(30, 40, 30, a), width=random.randint(1,3))
|
||||
im = im.filter(ImageFilter.GaussianBlur(0.4))
|
||||
im.save("public/images/hero-before.webp", "WEBP", quality=88, method=6)
|
||||
print("OK hero-before.webp", im.size)
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Vult de lege website-checklist-template in voor Helvoirt Gevelreiniging.
|
||||
Leest de template uit de bovenliggende map (ongemoeid) en schrijft een ingevulde
|
||||
kopie in de helvoirt-map. Kolommen: B=Uitgevoerd, C=Wie, D=Wanneer, E=Opmerking.
|
||||
"""
|
||||
import openpyxl
|
||||
from collections import Counter
|
||||
|
||||
SRC = r"C:/projects/dewebsmid/Checklist - Websites (template, leeg).xlsx"
|
||||
DST = r"C:/projects/dewebsmid/helvoirt/Checklist - Websites - helvoirt (ingevuld).xlsx"
|
||||
|
||||
D = "17-06-2026"
|
||||
WS_ = "De Websmid"
|
||||
KL = "Klant"
|
||||
CO = "Collega"
|
||||
|
||||
# row -> (Uitgevoerd, Wie, Wanneer, Opmerking)
|
||||
F = {
|
||||
5: ("Deels", KL, D, "Livegang akkoord (klant heeft de nameservers omgezet). Definitieve content (reviews, FAQ, echte foto's) nog te bevestigen/aanleveren."),
|
||||
6: ("Ja", KL, D, "Klant heeft de nameservers omgezet = akkoord op livegang."),
|
||||
7: ("Deels", KL, D, "Binnen: telefoon (085 060 4956) + adres. Nog open: e-mail, KvK, BTW, logo, echte foto's, reviews, mailbox-gegevens."),
|
||||
9: ("Ja", WS_, D, "Alle pagina's 200; nav + Diensten-dropdown + CTA's (header/sticky/drawer naar /contact, in-page naar #contact) werken; onbekende route naar 404."),
|
||||
10: ("Deels", WS_, D, "Telefoon correct en in JSON-LD/Instellingen. E-mail nog placeholder (info@helvoirtgevelreiniging.nl) tot de mailbox er is."),
|
||||
11: ("Deels", WS_, D, "Adres Stationsplein 1, 6131 AS Sittard correct. KvK/BTW nog niet ingevuld in Instellingen."),
|
||||
12: ("Actie", KL, "", "Reviews (Familie Jansen e.a.), FAQ-teksten en de AI-foto's zijn placeholder; via het CMS vervangen door echte data."),
|
||||
13: ("Deels", KL, "", "Teksten gecontroleerd, geen bekende spelfouten; definitief klant-akkoord op de teksten nog te bevestigen."),
|
||||
14: ("Ja", WS_, D, "Geen horizontale overflow (1440/768/390), axe-core 0 violations, visueel gecontroleerd via screenshots."),
|
||||
15: ("Ja", WS_, D, "Alle beelden WebP en gecomprimeerd; responsive varianten (768/1280/1920); CMS-media op R2."),
|
||||
16: ("Ja", WS_, D, "Geen title-attributen op afbeeldingen; CMS-beelden via media-URL."),
|
||||
17: ("Ja", WS_, D, "favicon.svg + apple-touch-icon.png aanwezig; merkmark + iconen als (inline) SVG."),
|
||||
18: ("Ja", WS_, D, "Gebrande 1200x630 og-default.webp + Twitter-cards; live geverifieerd (200)."),
|
||||
20: ("Ja", WS_, D, "SSL actief (edge-certificaat), https geeft 200. Workers custom domain: SSL-modus n.v.t. voor de worker-route."),
|
||||
21: ("Ja", WS_, D, "301 via middleware (geverifieerd). Dashboard 'Always Use HTTPS' optioneel als extra."),
|
||||
22: ("Actie", WS_, "", "Nog niet gezet (geen Strict-Transport-Security header). Via dashboard zetten na bevestigd HTTPS (OAuth-token mag geen zone-settings, gaf 403)."),
|
||||
23: ("Ja", WS_, D, "www naar apex 301; canonical naar apex; live geverifieerd."),
|
||||
24: ("Ja", WS_, D, "CMS op /_emdash/admin (geen wp-admin), afgeschermd met magic-link login."),
|
||||
25: ("Ja", WS_, D, "Admin-accounts (role 50): mike@dewebsmid.nl + ries@rsonlinemarketing.nl (SEO-partij). Inloggen via 'Sign in with email link' (magic-link werkt, live getest)."),
|
||||
26: ("N.v.t.", WS_, D, "Nog geen users aangemaakt; ingebouwde defaults (pages/posts) zijn al verwijderd."),
|
||||
27: ("Ja", WS_, D, "Live geverifieerd: X-Content-Type-Options nosniff, X-Frame-Options SAMEORIGIN, Referrer-Policy, Permissions-Policy."),
|
||||
28: ("Ja", WS_, D, "Honeypot (website_url) + time-trap (2s tot 6u) server-side; geen reCAPTCHA."),
|
||||
29: ("Ja", WS_, D, "public/_headers: /images + /fonts immutable (CF-Cache-Status HIT geverifieerd); HTML niet gecached (SSR)."),
|
||||
30: ("Ja", WS_, D, "astro check 0 errors; productie-build schoon."),
|
||||
31: ("Ja", WS_, D, "compatibility_date 2026-03-29 + nodejs_compat."),
|
||||
32: ("Ja", WS_, D, "CONTACT_SMTP_PASS gezet (via bash printf). CONTACT_SMTP_HOST=srv8.dewebsmid.nl in wrangler.jsonc vars. Secrets blijven behouden over deploys."),
|
||||
33: ("Ja", WS_, D, "wrangler.jsonc observability.enabled = true; logs via wrangler tail."),
|
||||
34: ("Ja", WS_, D, "*.workers.dev uitgeschakeld bij de custom-domain-deploy (geverifieerd; alleen het echte domein is live)."),
|
||||
36: ("Ja", WS_, D, "Live getest: 'mail-sent melding-klant'. Aan-adres info@helvoirtgevelreiniging.nl; lokaal afgeleverd op srv8. Bevestig ontvangst in de inbox."),
|
||||
37: ("Ja", WS_, D, "From = 'Helvoirt Gevelreiniging <info@helvoirtgevelreiniging.nl>', envelope = info@ (geauthenticeerde mailbox). House-style website@ optioneel via env."),
|
||||
38: ("Ja", WS_, D, "Melding: onderwerp 'Nieuwe aanvraag van <naam>...' + Reply-To = bezoeker. Autoreply: Reply-To = info@. Live getest."),
|
||||
39: ("Ja", WS_, D, "Live getest: 'mail-sent bevestiging-aanvrager'. Ondertekend 'Team Helvoirt Gevelreiniging' + T/E. Contactpersoon nog te bevestigen."),
|
||||
40: ("Ja", WS_, D, "Tabel contact_submissions; live geverifieerd (rij opgeslagen en opgeruimd)."),
|
||||
41: ("Ja", WS_, D, "Alle paden live getest: valid (200 + D1 + 2 mails), ongeldig (422 + veldfouten), honeypot (200 stil), time-trap (200 stil). Geen testrijen achtergebleven."),
|
||||
42: ("Ja", WS_, D, "AJAX-succesmelding direct in beeld; mobiel design gecontroleerd."),
|
||||
43: ("Ja", WS_, D, "Consistente labels/marges; een herbruikbaar formuliercomponent."),
|
||||
44: ("N.v.t.", "", "", "Geen bijlage-veld in het formulier."),
|
||||
45: ("Ja", WS_, D, "Sentinel-testrijen verwijderd (tabel leeg geverifieerd)."),
|
||||
47: ("Ja", CO, D, "Zone actief op Cloudflare; nameservers cesar/priscilla.ns.cloudflare.com (collega heeft NS omgezet)."),
|
||||
48: ("Ja", WS_, D, "Apex + www gekoppeld via wrangler routes (custom_domain); live geverifieerd."),
|
||||
49: ("Ja", CO, D, "Mailbox info@helvoirtgevelreiniging.nl aangemaakt op srv8.dewebsmid.nl (DirectAdmin)."),
|
||||
50: ("Ja", CO, D, "MX @ -> srv8.dewebsmid.nl (prio 0) staat live, DNS-only. NB: A-record mail.helvoirtgevelreiniging.nl ontbreekt nog (NXDOMAIN) - alleen nodig als het mailprogramma die hostname gebruikt; gebruik anders srv8.dewebsmid.nl."),
|
||||
51: ("Ja", CO, D, "SPF live: v=spf1 a mx include:_spf.dewebsmid.nl ~all."),
|
||||
52: ("Ja", CO, D, "DKIM live: selector x (x._domainkey.helvoirtgevelreiniging.nl, v=DKIM1...)."),
|
||||
53: ("Deels", CO, D, "DMARC live (p=none), MAAR de rua wijst naar info@behangerbrabant.nl (copy-paste uit het zusterproject). Corrigeren naar rua=mailto:info@helvoirtgevelreiniging.nl."),
|
||||
54: ("Actie", WS_, "", "Spamscore op mail-tester.com meten (SPF/DKIM/DMARC staan nu, dus zinvol)."),
|
||||
55: ("Actie", CO, "", "Operationeel; hosting + mailhosting in abonnementen + livegang melden, door de collega."),
|
||||
56: ("N.v.t.", "", "", "Nieuwe site; geen migratie van een bestaande site/mail."),
|
||||
57: ("Actie", WS_, "", "Periodieke back-up van content + leads inregelen (wrangler d1 export)."),
|
||||
59: ("Actie", WS_, "", "Nog niet gemeten (keyless PageSpeed = 429). Meten met een eigen PSI API-key."),
|
||||
60: ("Actie", WS_, "", "Nog niet gemeten; zie de Lighthouse-meting."),
|
||||
61: ("Ja", WS_, D, "Hero-cards: fetchpriority/eager op de eerste, rest lazy; eerste-fold-beelden eager. LCP = de koptekst."),
|
||||
62: ("Ja", WS_, D, "axe-core 0 violations; zichtbare focus; alt-teksten; contrast ruim AA; tap-doelen groter of gelijk aan 44px."),
|
||||
63: ("Ja", WS_, D, "prefers-reduced-motion overal gerespecteerd (reveals, tijdlijn, hero-gloed, formulier-spinner)."),
|
||||
64: ("Ja", WS_, D, "Branded 404 met CTA via [...slug].astro; 404-status live geverifieerd."),
|
||||
66: ("Deels", WS_, D, "Gerenderd/getest in Chromium (Playwright); standaard CSS. Handmatige check in Safari/Firefox/Edge nog aan te raden."),
|
||||
67: ("Deels", WS_, D, "Responsive geverifieerd op 390/768/1440 (geen overflow) + mobiele screenshots; echte-device-test nog aan te raden."),
|
||||
68: ("Ja", WS_, D, "Verse, sessie- en cacheloze fetches (incognito-equivalent) werken; publieke SSR zonder bezoeker-login."),
|
||||
70: ("Ja", WS_, D, "Per-pagina meta-titel/description (CMS-gestuurd) + code-defaults; homepage heeft een goede titel."),
|
||||
71: ("Ja", WS_, D, "Canonical naar apex; index,follow behalve waar noindex bewust is gezet."),
|
||||
72: ("Ja", WS_, D, "Allow /, Disallow /_emdash/ + /api/, sitemap-pointer; geen Disallow /. Live geverifieerd."),
|
||||
73: ("Deels", WS_, D, "sitemap.xml live (vaste routes + CMS-paginas/locaties). Indienen in Search Console nog te doen."),
|
||||
74: ("Ja", WS_, D, "LocalBusiness (site-breed) + Service/Breadcrumb (dienstpagina's). Formele Rich Results-validatie aan te raden."),
|
||||
75: ("Ja", WS_, D, "Admin (/_emdash/) + /api op Disallow in robots.txt."),
|
||||
76: ("Actie", WS_, "", "Search Console-property toevoegen + sitemap indienen na livegang."),
|
||||
77: ("Actie", KL, "", "GTM-veld klaar in Instellingen; klant levert container-ID. Nog geen analytics actief."),
|
||||
78: ("N.v.t.", "", "", "Nieuwe site; geen oude URL's om te redirecten."),
|
||||
79: ("Actie", WS_, "", "Volledige crawl (ScreamingFrog) nog te doen; steekproef: alle interne pagina's 200, geen gebroken links waargenomen."),
|
||||
81: ("Actie", WS_, "", "Monitoring (Uptime Robot) nog in te stellen."),
|
||||
82: ("Actie", WS_, "", "Handover: leads komen binnen in D1 + per mail (na de mailbox). Klant hierover informeren."),
|
||||
83: ("Ja", WS_, D, "/privacy + /voorwaarden live + footer-links. Voorwaarden = algemene basistekst, klant laten controleren."),
|
||||
84: ("Actie", WS_, "", "Tweede-persoons-eindcontrole nog uit te voeren."),
|
||||
}
|
||||
|
||||
wb = openpyxl.load_workbook(SRC)
|
||||
ws = wb["Checklist"]
|
||||
for r, (u, wie, wan, opm) in F.items():
|
||||
ws.cell(row=r, column=2, value=u)
|
||||
ws.cell(row=r, column=3, value=wie)
|
||||
ws.cell(row=r, column=4, value=wan)
|
||||
ws.cell(row=r, column=5, value=opm)
|
||||
wb.save(DST)
|
||||
|
||||
c = Counter(v[0] for v in F.values())
|
||||
print("Opgeslagen:", DST)
|
||||
print("Ingevuld:", len(F), "punten")
|
||||
print("Verdeling:", dict(c))
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Generator: zet een content-model om in D1-SQL die nieuwe emdash-collecties aanmaakt
|
||||
* op een AL-geseede database, door de exacte tabelstructuur van emdash te spiegelen
|
||||
* (overgenomen uit de echte `ec_paginas`-DDL). Per collectie:
|
||||
* - rij in _emdash_collections
|
||||
* - CREATE TABLE ec_<slug> (systeemkolommen + veldkolommen)
|
||||
* - rij in _emdash_fields per veld
|
||||
* - content-rijen in ec_<slug>
|
||||
*
|
||||
* Bewust GEEN "search"/FTS (geen searchable velden) → geen FTS-tabellen nodig, dus
|
||||
* de gegenereerde SQL blijft veilig en compleet. Run: `node scripts/gen-cms-sql.mjs`.
|
||||
*/
|
||||
import { ulid } from "ulidx";
|
||||
import { writeFileSync, mkdirSync } from "node:fs";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const TYPE_COL = {
|
||||
string: "TEXT", text: "TEXT", select: "TEXT", datetime: "TEXT", slug: "TEXT",
|
||||
image: "TEXT", file: "TEXT", reference: "TEXT",
|
||||
number: "REAL", integer: "INTEGER", boolean: "INTEGER",
|
||||
portableText: "JSON", json: "JSON", multiSelect: "JSON", repeater: "JSON",
|
||||
};
|
||||
|
||||
// Systeemkolommen exact zoals emdash ze in ec_paginas aanmaakt.
|
||||
const SYSTEM_COLS =
|
||||
`"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`;
|
||||
|
||||
const q = (s) => "'" + String(s).replace(/'/g, "''") + "'";
|
||||
function val(type, v) {
|
||||
if (v === null || v === undefined) return "NULL";
|
||||
const col = TYPE_COL[type] || "TEXT";
|
||||
if (col === "JSON") return q(JSON.stringify(v));
|
||||
if (col === "INTEGER") return type === "boolean" ? (v ? "1" : "0") : String(parseInt(v, 10));
|
||||
if (col === "REAL") return String(Number(v));
|
||||
if ((type === "image" || type === "file") && typeof v === "object") return q(JSON.stringify(v));
|
||||
return q(v);
|
||||
}
|
||||
|
||||
export function generate(model) {
|
||||
const out = ["-- Auto-gegenereerd door scripts/gen-cms-sql.mjs — NIET met de hand bewerken."];
|
||||
for (const c of model) {
|
||||
const collId = ulid();
|
||||
const supports = JSON.stringify(c.supports ?? ["drafts", "revisions"]);
|
||||
out.push(`\n-- ===== collectie: ${c.slug} (${c.label}) =====`);
|
||||
out.push(
|
||||
`INSERT INTO _emdash_collections (id, slug, label, label_singular, supports, source, has_seo) ` +
|
||||
`VALUES (${q(collId)}, ${q(c.slug)}, ${q(c.label)}, ${q(c.labelSingular ?? c.label)}, ${q(supports)}, 'seed', 0);`
|
||||
);
|
||||
const fieldCols = c.fields
|
||||
.map((f) => {
|
||||
const col = TYPE_COL[f.type] || "TEXT";
|
||||
const nn = f.required && col === "TEXT" ? " NOT NULL DEFAULT ''" : "";
|
||||
return `"${f.slug}" ${col}${nn}`;
|
||||
})
|
||||
.join(", ");
|
||||
out.push(
|
||||
`CREATE TABLE "ec_${c.slug}" (${SYSTEM_COLS}, ${fieldCols}, ` +
|
||||
`constraint "ec_${c.slug}_slug_locale_unique" unique ("slug", "locale"));`
|
||||
);
|
||||
c.fields.forEach((f, i) => {
|
||||
const col = TYPE_COL[f.type] || "TEXT";
|
||||
// Repeater-velden krijgen hun rij-definitie (subFields) mee in de validation-kolom.
|
||||
const validation = f.subFields
|
||||
? JSON.stringify({ subFields: f.subFields, minItems: f.minItems ?? 0, maxItems: f.maxItems ?? 20 })
|
||||
: null;
|
||||
const cols = "id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable" + (validation ? ", validation" : "");
|
||||
const vals =
|
||||
`${q(ulid())}, ${q(collId)}, ${q(f.slug)}, ${q(f.label)}, ${q(f.type)}, ${q(col)}, ${f.required ? 1 : 0}, ${i}, 0, 1` +
|
||||
(validation ? `, ${q(validation)}` : "");
|
||||
out.push(`INSERT INTO _emdash_fields (${cols}) VALUES (${vals});`);
|
||||
});
|
||||
for (const entry of c.content ?? []) {
|
||||
const cols = [`"id"`, `"slug"`, `"status"`, `"locale"`];
|
||||
const vals = [q(ulid()), q(entry.slug), q(entry.status ?? "published"), q("en")];
|
||||
for (const f of c.fields) {
|
||||
if (entry.data && f.slug in entry.data) {
|
||||
cols.push(`"${f.slug}"`);
|
||||
vals.push(val(f.type, entry.data[f.slug]));
|
||||
}
|
||||
}
|
||||
out.push(`INSERT INTO "ec_${c.slug}" (${cols.join(", ")}) VALUES (${vals.join(", ")});`);
|
||||
}
|
||||
}
|
||||
return out.join("\n") + "\n";
|
||||
}
|
||||
|
||||
// ---- Run: genereer per wave een .sql-bestand ----
|
||||
// Gebruik: node scripts/gen-cms-sql.mjs <model-bestand> <output.sql>
|
||||
// Alleen uitvoeren wanneer dit script direct wordt aangeroepen (niet bij import van `generate`).
|
||||
const isMain = import.meta.url === pathToFileURL(process.argv[1] ?? "").href;
|
||||
if (isMain) {
|
||||
const [, , modelPath, outName] = process.argv;
|
||||
if (!modelPath || !outName) {
|
||||
console.error("usage: node scripts/gen-cms-sql.mjs <model.mjs> <out.sql>");
|
||||
process.exit(1);
|
||||
}
|
||||
const mod = await import(pathToFileURL(resolve(modelPath)).href);
|
||||
const model = Object.values(mod).find((v) => Array.isArray(v));
|
||||
mkdirSync("migrations", { recursive: true });
|
||||
writeFileSync(`migrations/${outName}`, generate(model), "utf8");
|
||||
console.log("geschreven: migrations/" + outName);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Zet de dienst-foto's écht in de mediabibliotheek (R2 + `media`-tabel) en koppelt ze aan
|
||||
* de 25 beeldvelden van de 5 diensten (nav/hero + 3 resultaat-slots). Eén media-rij per
|
||||
* unieke bronfoto (13), meerdere velden mogen dezelfde foto delen.
|
||||
* Lokaal provider: storage_key == R2-objectkey; src wordt op displaytijd afgeleid.
|
||||
* Print UPLOAD-regels (storageKey + bronbestand) voor de PowerShell-uploadstap.
|
||||
*/
|
||||
import { ulid } from "ulidx";
|
||||
import { writeFileSync, mkdirSync } from "node:fs";
|
||||
|
||||
// 13 unieke bronfoto's (variant gekozen op formaat: -1280 voor hero/cards, werk-basis voor galerij).
|
||||
const IMAGES = {
|
||||
"dienst-gevel": { file: "dienst-gevelreiniging-1280.webp", w: 1280, h: 956, size: 223140, alt: "Bakstenen gevel halverwege de reiniging" },
|
||||
"dienst-voeg": { file: "dienst-voegwerk-1280.webp", w: 1280, h: 956, size: 158522, alt: "Strak nieuw voegwerk tussen rode bakstenen" },
|
||||
"dienst-dak": { file: "dienst-dakreiniging-1280.webp", w: 1280, h: 956, size: 147566, alt: "Schoon dakpannendak na reiniging en coating" },
|
||||
"dienst-schoorsteen": { file: "dienst-schoorsteen-1280.webp", w: 1280, h: 956, size: 141238, alt: "Herstelde schoorsteen met nieuw voegwerk op een dak" },
|
||||
"dienst-algemeen": { file: "dienst-algemeen-1280.webp", w: 1280, h: 956, size: 283500, alt: "Vers gereinigd terras en bestrating" },
|
||||
"hero-after": { file: "hero-after-1280.webp", w: 1280, h: 714, size: 158222, alt: "Schone bakstenen gevel na reiniging" },
|
||||
"hero-gevel": { file: "hero-gevel-1280.webp", w: 1280, h: 714, size: 192040, alt: "Verzorgde, schone gevel van een woonhuis" },
|
||||
"werk-1": { file: "werk-1.webp", w: 1200, h: 1600, size: 376578, alt: "Schoorsteen met nieuw aangebracht loodwerk op een dak" },
|
||||
"werk-2": { file: "werk-2.webp", w: 1600, h: 1200, size: 341744, alt: "Schone tuinmuur en bestrating na reiniging" },
|
||||
"werk-3": { file: "werk-3.webp", w: 738, h: 1600, size: 181398, alt: "Strak nieuw voegwerk tussen de bakstenen van een gevel" },
|
||||
"werk-4": { file: "werk-4.webp", w: 1200, h: 1600, size: 614486, alt: "Dakpannendak vol mos voor de reiniging" },
|
||||
"werk-5": { file: "werk-5.webp", w: 1200, h: 1600, size: 428016, alt: "Dak wordt op hoogte gereinigd" },
|
||||
"werk-6": { file: "werk-6.webp", w: 1200, h: 1600, size: 293814, alt: "Hersteld metsel- en voegwerk aan een tuinmuur met trap" },
|
||||
};
|
||||
|
||||
// Veld -> foto per dienst (nav==hero per dienst; resultaat = de 3 galerij-foto's).
|
||||
const MAP = {
|
||||
"gevelreiniging": { nav_image: "dienst-gevel", hero_image: "dienst-gevel", result_1_image: "hero-after", result_2_image: "dienst-voeg", result_3_image: "hero-gevel" },
|
||||
"voegwerk": { nav_image: "dienst-voeg", hero_image: "dienst-voeg", result_1_image: "werk-3", result_2_image: "werk-6", result_3_image: "hero-gevel" },
|
||||
"dakreiniging-coating": { nav_image: "dienst-dak", hero_image: "dienst-dak", result_1_image: "werk-4", result_2_image: "werk-5", result_3_image: "dienst-dak" },
|
||||
"schoorsteen-voegwerk": { nav_image: "dienst-schoorsteen", hero_image: "dienst-schoorsteen", result_1_image: "werk-1", result_2_image: "werk-6", result_3_image: "dienst-schoorsteen" },
|
||||
"algemeen-reinigen": { nav_image: "dienst-algemeen", hero_image: "dienst-algemeen", result_1_image: "werk-2", result_2_image: "werk-6", result_3_image: "dienst-algemeen" },
|
||||
};
|
||||
|
||||
const q = (s) => "'" + String(s).replace(/'/g, "''") + "'";
|
||||
|
||||
// Wijs elke unieke foto een ULID + storageKey toe.
|
||||
for (const k of Object.keys(IMAGES)) {
|
||||
const id = ulid();
|
||||
IMAGES[k].id = id;
|
||||
IMAGES[k].storageKey = id + ".webp";
|
||||
}
|
||||
|
||||
function fieldJson(img) {
|
||||
return JSON.stringify({
|
||||
id: img.id,
|
||||
provider: "local",
|
||||
filename: img.file,
|
||||
mimeType: "image/webp",
|
||||
width: img.w,
|
||||
height: img.h,
|
||||
alt: img.alt,
|
||||
meta: { storageKey: img.storageKey },
|
||||
});
|
||||
}
|
||||
|
||||
const lines = ["-- Diensten: foto's in de mediabibliotheek + gekoppeld aan de 25 beeldvelden."];
|
||||
lines.push("INSERT OR REPLACE INTO media (id, filename, mime_type, size, width, height, alt, storage_key, status, created_at) VALUES");
|
||||
const mediaRows = Object.values(IMAGES).map(
|
||||
(img) => ` (${q(img.id)}, ${q(img.file)}, 'image/webp', ${img.size}, ${img.w}, ${img.h}, ${q(img.alt)}, ${q(img.storageKey)}, 'ready', datetime('now'))`,
|
||||
);
|
||||
lines.push(mediaRows.join(",\n") + ";");
|
||||
|
||||
for (const [slug, fields] of Object.entries(MAP)) {
|
||||
for (const [field, imgKey] of Object.entries(fields)) {
|
||||
lines.push(`UPDATE ec_diensten SET ${field}=${q(fieldJson(IMAGES[imgKey]))} WHERE slug=${q(slug)};`);
|
||||
}
|
||||
}
|
||||
|
||||
mkdirSync("migrations", { recursive: true });
|
||||
writeFileSync("migrations/0010_diensten_media.sql", lines.join("\n") + "\n", "utf8");
|
||||
|
||||
// Print de upload-mapping (uniek per bronbestand) voor de R2-stap.
|
||||
for (const img of Object.values(IMAGES)) {
|
||||
console.log(`UPLOAD|public/images/${img.file}|${img.storageKey}`);
|
||||
}
|
||||
console.log("geschreven: migrations/0010_diensten_media.sql");
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Wave 2b — homepage-BEELD in het CMS. Maakt twee kleine collecties:
|
||||
* - `hero_cards` (3 staande werkfoto-cards in de hero)
|
||||
* - `galerij` (6 foto's in de bewijs-sectie "Echt werk, echte resultaten")
|
||||
* met image-rijen (een repeater kan geen image-subveld). De galerij hergebruikt de
|
||||
* werk-foto's die al in R2 staan (uit 0010_diensten_media); de hero gebruikt de kleine
|
||||
* -768-varianten (perf, want boven de vouw) → 2 nieuwe media-rijen + uploads.
|
||||
* Print UPLOAD-regels voor de 2 nieuwe bestanden.
|
||||
*/
|
||||
import { ulid } from "ulidx";
|
||||
import { writeFileSync, mkdirSync } from "node:fs";
|
||||
import { generate } from "./gen-cms-sql.mjs";
|
||||
|
||||
const q = (s) => "'" + String(s).replace(/'/g, "''") + "'";
|
||||
|
||||
// Bestaande media uit 0010_diensten_media (storage_key == id + ".webp").
|
||||
const EXIST = {
|
||||
"werk-1": { id: "01KVABG8HZENXT71V00NY1M2N5", file: "werk-1.webp", w: 1200, h: 1600 },
|
||||
"werk-2": { id: "01KVABG8HZSPH8YKM0GJC0WDE2", file: "werk-2.webp", w: 1600, h: 1200 },
|
||||
"werk-3": { id: "01KVABG8HZN66T8KPTBWHQSFZS", file: "werk-3.webp", w: 738, h: 1600 },
|
||||
"werk-4": { id: "01KVABG8HZ2H1TAB7E4J3QRCSM", file: "werk-4.webp", w: 1200, h: 1600 },
|
||||
"werk-5": { id: "01KVABG8HZ9KFWV8604FYYSEAY", file: "werk-5.webp", w: 1200, h: 1600 },
|
||||
"werk-6": { id: "01KVABG8HZV3AHQ5RQGVAKW29F", file: "werk-6.webp", w: 1200, h: 1600 },
|
||||
};
|
||||
for (const k of Object.keys(EXIST)) EXIST[k].storageKey = EXIST[k].id + ".webp";
|
||||
|
||||
// Nieuwe kleine hero-varianten (worden geupload + krijgen een media-rij).
|
||||
const NEW = {
|
||||
"werk-1-768": { file: "werk-1-768.webp", w: 768, h: 1024, size: 172718, alt: "Herstelde schoorsteen met nieuw loodwerk op een dak" },
|
||||
"werk-5-768": { file: "werk-5-768.webp", w: 768, h: 1024, size: 191256, alt: "Dak wordt op hoogte gereinigd" },
|
||||
};
|
||||
for (const k of Object.keys(NEW)) {
|
||||
NEW[k].id = ulid();
|
||||
NEW[k].storageKey = NEW[k].id + ".webp";
|
||||
}
|
||||
|
||||
function imgJson(m, alt) {
|
||||
return {
|
||||
id: m.id, provider: "local", filename: m.file, mimeType: "image/webp",
|
||||
width: m.w, height: m.h, alt, meta: { storageKey: m.storageKey },
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Model: twee image-collecties met content (image-veld = media-JSON) ----
|
||||
const model = [
|
||||
{
|
||||
slug: "hero_cards",
|
||||
label: "Homepage · hero-foto's",
|
||||
labelSingular: "Hero-foto",
|
||||
supports: ["drafts"],
|
||||
fields: [
|
||||
{ slug: "sort", label: "Volgorde", type: "integer" },
|
||||
{ slug: "image", label: "Foto", type: "image", required: false },
|
||||
{ slug: "title", label: "Titel (op de foto)", type: "string", required: true },
|
||||
{ slug: "sub", label: "Ondertitel (op de foto)", type: "string" },
|
||||
{ slug: "alt", label: "Alt-tekst", type: "string" },
|
||||
],
|
||||
content: [
|
||||
{ slug: "card-1", status: "published", data: { sort: 1, image: imgJson(EXIST["werk-3"], "Strak nieuw voegwerk tussen bakstenen van een gevel"), title: "Voegwerk", sub: "gevel hersteld", alt: "Strak nieuw voegwerk tussen bakstenen van een gevel" } },
|
||||
{ slug: "card-2", status: "published", data: { sort: 2, image: imgJson(NEW["werk-1-768"], "Herstelde schoorsteen met nieuw loodwerk op een dak"), title: "Schoorsteen", sub: "nieuw loodwerk", alt: "Herstelde schoorsteen met nieuw loodwerk op een dak" } },
|
||||
{ slug: "card-3", status: "published", data: { sort: 3, image: imgJson(NEW["werk-5-768"], "Dak wordt op hoogte gereinigd"), title: "Dakreiniging", sub: "op hoogte", alt: "Dak wordt op hoogte gereinigd" } },
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "galerij",
|
||||
label: "Homepage · bewijs-galerij",
|
||||
labelSingular: "Galerij-foto",
|
||||
supports: ["drafts"],
|
||||
fields: [
|
||||
{ slug: "sort", label: "Volgorde", type: "integer" },
|
||||
{ slug: "image", label: "Foto", type: "image", required: false },
|
||||
{ slug: "caption", label: "Bijschrift", type: "string", required: true },
|
||||
{ slug: "featured", label: "Uitgelicht (grote tegel)", type: "boolean" },
|
||||
],
|
||||
content: [
|
||||
{ slug: "g-1", status: "published", data: { sort: 1, image: imgJson(EXIST["werk-3"], "Voegwerk aan de gevel"), caption: "Voegwerk aan de gevel", featured: true } },
|
||||
{ slug: "g-2", status: "published", data: { sort: 2, image: imgJson(EXIST["werk-1"], "Schoorsteen met nieuw loodwerk"), caption: "Schoorsteen met nieuw loodwerk", featured: false } },
|
||||
{ slug: "g-3", status: "published", data: { sort: 3, image: imgJson(EXIST["werk-4"], "Dak vol mos, voor reiniging"), caption: "Dak vol mos, voor reiniging", featured: false } },
|
||||
{ slug: "g-4", status: "published", data: { sort: 4, image: imgJson(EXIST["werk-2"], "Tuinmuur en bestrating"), caption: "Tuinmuur en bestrating", featured: false } },
|
||||
{ slug: "g-5", status: "published", data: { sort: 5, image: imgJson(EXIST["werk-5"], "Dakreiniging op hoogte"), caption: "Dakreiniging op hoogte", featured: false } },
|
||||
{ slug: "g-6", status: "published", data: { sort: 6, image: imgJson(EXIST["werk-6"], "Metselwerk, tuintrap"), caption: "Metselwerk, tuintrap", featured: false } },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// ---- Media-rijen voor de 2 nieuwe hero-varianten ----
|
||||
const mediaSql = [
|
||||
"-- 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",
|
||||
Object.values(NEW)
|
||||
.map((m) => ` (${q(m.id)}, ${q(m.file)}, 'image/webp', ${m.size}, ${m.w}, ${m.h}, ${q(m.alt)}, ${q(m.storageKey)}, 'ready', datetime('now'))`)
|
||||
.join(",\n") + ";",
|
||||
].join("\n");
|
||||
|
||||
const sql = mediaSql + "\n\n" + generate(model);
|
||||
mkdirSync("migrations", { recursive: true });
|
||||
writeFileSync("migrations/0011_homepage_media.sql", sql, "utf8");
|
||||
|
||||
for (const m of Object.values(NEW)) console.log(`UPLOAD|public/images/${m.file}|${m.storageKey}`);
|
||||
console.log("geschreven: migrations/0011_homepage_media.sql");
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Wave 1 — beeldvelden toevoegen aan over_ons (klantfeedback: "ik mis de herobanner").
|
||||
* Twee image-velden (hero_image + story_image), logisch tussen de tekstvelden geplaatst.
|
||||
* Leeg laten = de .astro valt terug op de huidige statische foto's (pagina blijft identiek);
|
||||
* de SEO-partij kan in het veld een eigen afbeelding uploaden.
|
||||
*/
|
||||
import { ulid } from "ulidx";
|
||||
import { writeFileSync, mkdirSync } from "node:fs";
|
||||
|
||||
const coll = "(SELECT id FROM _emdash_collections WHERE slug='over_ons')";
|
||||
const q = (s) => "'" + String(s).replace(/'/g, "''") + "'";
|
||||
const imgField = (slug, label, sort) =>
|
||||
`INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable) ` +
|
||||
`VALUES (${q(ulid())}, ${coll}, ${q(slug)}, ${q(label)}, 'image', 'TEXT', 0, ${sort}, 0, 1);`;
|
||||
|
||||
const sql = `-- 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=${coll} AND slug='story_title';
|
||||
UPDATE _emdash_fields SET sort_order=5 WHERE collection_id=${coll} AND slug='story_body';
|
||||
UPDATE _emdash_fields SET sort_order=7 WHERE collection_id=${coll} AND slug='values_title';
|
||||
UPDATE _emdash_fields SET sort_order=8 WHERE collection_id=${coll} AND slug='values_intro';
|
||||
UPDATE _emdash_fields SET sort_order=9 WHERE collection_id=${coll} AND slug='waarden';
|
||||
UPDATE _emdash_fields SET sort_order=10 WHERE collection_id=${coll} AND slug='meta_title';
|
||||
UPDATE _emdash_fields SET sort_order=11 WHERE collection_id=${coll} AND slug='meta_description';
|
||||
${imgField("hero_image", "Hero · afbeelding (banner) — leeg = standaardfoto", 3)}
|
||||
${imgField("story_image", "Verhaal · foto — leeg = standaardfoto", 6)}
|
||||
`;
|
||||
|
||||
mkdirSync("migrations", { recursive: true });
|
||||
writeFileSync("migrations/0004_over_ons_images.sql", sql, "utf8");
|
||||
console.log("geschreven: migrations/0004_over_ons_images.sql");
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Zet de huidige Over ons-foto's écht in de mediabibliotheek (R2 + `media`-tabel) en
|
||||
* koppelt ze aan de beeldvelden, zodat ze in het CMS-veld zichtbaar zijn (niet leeg).
|
||||
* Lokaal provider: storage_key == R2-objectkey; src wordt op displaytijd afgeleid.
|
||||
* Print de R2-keys zodat de PowerShell-stap de juiste bestanden uploadt.
|
||||
*/
|
||||
import { ulid } from "ulidx";
|
||||
import { writeFileSync, mkdirSync } from "node:fs";
|
||||
|
||||
const heroId = ulid();
|
||||
const storyId = ulid();
|
||||
const q = (s) => "'" + String(s).replace(/'/g, "''") + "'";
|
||||
|
||||
const heroAlt = "Schone, verzorgde gevel van een woonhuis na reiniging";
|
||||
const storyAlt = "Vakman aan het werk aan het voegwerk van een gevel";
|
||||
|
||||
const heroVal = { id: heroId, provider: "local", filename: "over-ons-hero.webp", mimeType: "image/webp", width: 1280, height: 714, alt: heroAlt, meta: { storageKey: heroId + ".webp" } };
|
||||
const storyVal = { id: storyId, provider: "local", filename: "over-ons-verhaal.webp", mimeType: "image/webp", width: 738, height: 983, alt: storyAlt, meta: { storageKey: storyId + ".webp" } };
|
||||
|
||||
const sql = `-- 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
|
||||
(${q(heroId)}, 'over-ons-hero.webp', 'image/webp', 158222, 1280, 714, ${q(heroAlt)}, ${q(heroId + ".webp")}, 'ready', datetime('now')),
|
||||
(${q(storyId)}, 'over-ons-verhaal.webp', 'image/webp', 181398, 738, 983, ${q(storyAlt)}, ${q(storyId + ".webp")}, 'ready', datetime('now'));
|
||||
UPDATE ec_over_ons SET hero_image=${q(JSON.stringify(heroVal))} WHERE slug='main';
|
||||
UPDATE ec_over_ons SET story_image=${q(JSON.stringify(storyVal))} WHERE slug='main';
|
||||
`;
|
||||
|
||||
mkdirSync("migrations", { recursive: true });
|
||||
writeFileSync("migrations/0006_over_ons_media.sql", sql, "utf8");
|
||||
console.log("HERO_KEY=" + heroId + ".webp");
|
||||
console.log("STORY_KEY=" + storyId + ".webp");
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Wave 1 — verfijning Over ons (klantfeedback):
|
||||
* - "Waarden" van een aparte collectie -> een INLINE repeater-veld op `over_ons`
|
||||
* (rijen toevoegen/verwijderen/verslepen binnen de pagina; WP-Bakery-achtig).
|
||||
* - Kern-inhoudvelden verplicht maken zodat "(optional)" daar verdwijnt.
|
||||
* - De losse `waarden`-collectie opruimen.
|
||||
*/
|
||||
import { ulid } from "ulidx";
|
||||
import { writeFileSync, mkdirSync } from "node:fs";
|
||||
|
||||
const waarden = [
|
||||
{ title: "Eerlijke prijs vooraf", text: "Je krijgt een duidelijke offerte op papier. Geen verrassingen achteraf, geen kleine lettertjes.", icon: '<path d="M20.59 13.41 13.42 20.58a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><circle cx="7" cy="7" r="1.4"/>' },
|
||||
{ title: "Echt vakwerk", text: "We kennen ons vak en de materialen. Elke gevel, elk dak en elke schoorsteen krijgt de juiste aanpak.", icon: '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="m8.5 12 2.2 2.2 4.5-4.5"/>' },
|
||||
{ title: "Netjes opgeleverd", text: "We dekken alles goed af, ruimen op en lopen het werk samen met je na. Je huis blijft schoon achter.", icon: '<path d="M12 3l1.8 5.4L19 10l-5.2 1.6L12 17l-1.8-5.4L5 10l5.2-1.6z"/>' },
|
||||
{ title: "Lokaal en betrokken", text: "We werken in Helvoirt en omgeving. Korte lijnen, snel even langs en altijd persoonlijk aanspreekbaar.", icon: '<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/>' },
|
||||
];
|
||||
|
||||
const subFields = [
|
||||
{ slug: "title", type: "string", label: "Titel", required: true },
|
||||
{ slug: "text", type: "text", label: "Tekst" },
|
||||
{ slug: "icon", type: "text", label: "Icoon (SVG-inhoud, geavanceerd)" },
|
||||
];
|
||||
|
||||
const q = (s) => "'" + String(s).replace(/'/g, "''") + "'";
|
||||
const coll = "(SELECT id FROM _emdash_collections WHERE slug='over_ons')";
|
||||
|
||||
const sql = `-- 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=${coll} AND slug='meta_title';
|
||||
UPDATE _emdash_fields SET sort_order=9 WHERE collection_id=${coll} AND slug='meta_description';
|
||||
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order, searchable, translatable, validation)
|
||||
VALUES (${q(ulid())}, ${coll}, 'waarden', 'Waarden (rijen: voeg toe / verwijder / versleep)', 'repeater', 'JSON', 0, 7, 0, 1, ${q(JSON.stringify({ subFields, minItems: 0, maxItems: 8 }))});
|
||||
UPDATE ec_over_ons SET waarden=${q(JSON.stringify(waarden))} WHERE slug='main';
|
||||
|
||||
-- 2. Kern-inhoudvelden verplicht -> "(optional)" verdwijnt daar (SEO blijft optioneel).
|
||||
UPDATE _emdash_fields SET required=1 WHERE collection_id=${coll} 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';
|
||||
`;
|
||||
|
||||
mkdirSync("migrations", { recursive: true });
|
||||
writeFileSync("migrations/0003_over_ons_repeater.sql", sql, "utf8");
|
||||
console.log("geschreven: migrations/0003_over_ons_repeater.sql");
|
||||
@@ -0,0 +1,28 @@
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = ["pillow"]
|
||||
# ///
|
||||
"""Genereer downscaled WebP-varianten voor responsive srcset.
|
||||
Usage: uv run --native-tls scripts/img-variants.py <input.webp> <w1,w2,...> [quality]
|
||||
Schrijft <stem>-<w>.webp voor elke breedte kleiner dan het origineel."""
|
||||
import os
|
||||
import sys
|
||||
from PIL import Image
|
||||
|
||||
inp = sys.argv[1]
|
||||
widths = [int(w) for w in sys.argv[2].split(",")]
|
||||
quality = int(sys.argv[3]) if len(sys.argv) > 3 else 80
|
||||
|
||||
img = Image.open(inp)
|
||||
ow, oh = img.size
|
||||
stem, ext = os.path.splitext(inp)
|
||||
|
||||
for w in widths:
|
||||
if w >= ow:
|
||||
print(f"skip {w} (>= origineel {ow})")
|
||||
continue
|
||||
h = round(oh * w / ow)
|
||||
resized = img.resize((w, h), Image.LANCZOS)
|
||||
out = f"{stem}-{w}.webp"
|
||||
resized.save(out, "WEBP", quality=quality, method=6)
|
||||
print(f"saved {out} ({w}x{h}, {os.path.getsize(out) // 1024} KB)")
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Genereer de social-share-afbeelding (OG-image) 1200x630 voor Helvoirt Gevelreiniging.
|
||||
|
||||
Neemt een echte werkfoto (schone gevel), crop-cover naar 1200x630, legt er een
|
||||
petrol-gradient over (links/onder donker voor leesbaarheid) en zet de wordmark +
|
||||
tagline + 4,9-Google-regel eroverheen. Output: public/images/og-default.webp.
|
||||
|
||||
Draaien (Windows):
|
||||
$py = "C:\\Users\\info\\AppData\\Local\\Programs\\Python\\Python312\\python.exe"
|
||||
& $py scripts/make-og.py
|
||||
"""
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
import os
|
||||
|
||||
W, H = 1200, 630
|
||||
SRC = os.path.join("public", "images", "hero-after.webp")
|
||||
OUT = os.path.join("public", "images", "og-default.webp")
|
||||
|
||||
# Merkkleuren (uit tokens.css)
|
||||
PETROL_DEEP = (21, 54, 61) # #15363d
|
||||
CYAAN = (78, 191, 217) # #4ebfd9
|
||||
STAR = (255, 196, 0)
|
||||
WHITE = (255, 255, 255)
|
||||
SOFT = (199, 218, 224) # #c7dae0 (on-dark-soft)
|
||||
|
||||
FONTS = "C:/Windows/Fonts"
|
||||
|
||||
|
||||
def load(name, size):
|
||||
return ImageFont.truetype(os.path.join(FONTS, name), size)
|
||||
|
||||
|
||||
def cover(im, w, h):
|
||||
"""Schaal + crop zodat de foto het hele vlak vult (object-fit: cover)."""
|
||||
src_ratio = im.width / im.height
|
||||
dst_ratio = w / h
|
||||
if src_ratio > dst_ratio:
|
||||
new_h = h
|
||||
new_w = round(h * src_ratio)
|
||||
else:
|
||||
new_w = w
|
||||
new_h = round(w / src_ratio)
|
||||
im = im.resize((new_w, new_h), Image.LANCZOS)
|
||||
left = (new_w - w) // 2
|
||||
top = (new_h - h) // 2
|
||||
return im.crop((left, top, left + w, top + h))
|
||||
|
||||
|
||||
def main():
|
||||
base = cover(Image.open(SRC).convert("RGB"), W, H)
|
||||
|
||||
# Petrol-gradient: links sterk donker -> rechts transparant (tekst leesbaar links).
|
||||
overlay = Image.new("RGBA", (W, H), (0, 0, 0, 0))
|
||||
od = overlay.load()
|
||||
for x in range(W):
|
||||
# alpha van ~0.92 links naar ~0.05 rechts
|
||||
t = x / W
|
||||
a = int(235 * (1 - t) ** 1.5 + 12)
|
||||
for y in range(H):
|
||||
od[x, y] = (*PETROL_DEEP, a)
|
||||
# extra donkere voet onderaan voor de tagline
|
||||
foot = Image.new("RGBA", (W, H), (0, 0, 0, 0))
|
||||
fd = foot.load()
|
||||
for y in range(H):
|
||||
t = max(0, (y - H * 0.55) / (H * 0.45))
|
||||
a = int(160 * t)
|
||||
for x in range(W):
|
||||
fd[x, y] = (*PETROL_DEEP, a)
|
||||
|
||||
img = Image.alpha_composite(base.convert("RGBA"), overlay)
|
||||
img = Image.alpha_composite(img, foot)
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Cyaan accent-balk links
|
||||
draw.rectangle([0, 0, 12, H], fill=CYAAN)
|
||||
|
||||
pad = 70
|
||||
# Wordmark (twee regels, zwaar)
|
||||
f_brand = load("ariblk.ttf", 86)
|
||||
f_sub = load("ariblk.ttf", 86)
|
||||
draw.text((pad, 150), "Helvoirt", font=f_brand, fill=WHITE)
|
||||
draw.text((pad, 248), "Gevelreiniging", font=f_sub, fill=CYAAN)
|
||||
|
||||
# Tagline
|
||||
f_tag = load("segoeui.ttf", 34)
|
||||
draw.text((pad, 372), "Gevelreiniging · voegwerk · dakreiniging + coating", font=f_tag, fill=SOFT)
|
||||
|
||||
# 4,9 op Google (sterren + tekst). Sterren via Segoe UI Symbol (heeft U+2605).
|
||||
f_score = load("arialbd.ttf", 40)
|
||||
f_stars = load("seguisym.ttf", 34)
|
||||
y_score = 452
|
||||
draw.text((pad, y_score), "★★★★★", font=f_stars, fill=STAR)
|
||||
stars_w = draw.textlength("★★★★★", font=f_stars)
|
||||
draw.text((pad + stars_w + 20, y_score - 3), "4,9 op Google", font=f_score, fill=WHITE)
|
||||
|
||||
# Werkgebied-regel
|
||||
f_region = load("segoeui.ttf", 28)
|
||||
draw.text((pad, 520), "Helvoirt en omgeving", font=f_region, fill=SOFT)
|
||||
|
||||
img.convert("RGB").save(OUT, "WEBP", quality=88, method=6)
|
||||
print("OG-image geschreven:", OUT, Image.open(OUT).size)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,81 @@
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = [
|
||||
# "google-genai",
|
||||
# "pillow",
|
||||
# "truststore>=0.10",
|
||||
# ]
|
||||
# ///
|
||||
"""Wrapper that injects truststore (Windows OS cert store) before calling Gemini.
|
||||
Use to bypass corporate-cert/anti-virus HTTPS interception in environments where the
|
||||
default certifi bundle does not include the intercepting CA."""
|
||||
|
||||
import truststore
|
||||
truststore.inject_into_ssl()
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from google import genai
|
||||
from google.genai import types
|
||||
from PIL import Image
|
||||
import io
|
||||
|
||||
MODEL_IDS = {
|
||||
"flash": "gemini-2.5-flash-image",
|
||||
"pro": "gemini-3-pro-image-preview",
|
||||
"2": "gemini-3.1-flash-image-preview",
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--prompt", required=True)
|
||||
parser.add_argument("--output", required=True)
|
||||
parser.add_argument("--model", default="2", choices=MODEL_IDS.keys())
|
||||
parser.add_argument("--size", default="2K")
|
||||
parser.add_argument("--aspect", default="16:9")
|
||||
args = parser.parse_args()
|
||||
|
||||
api_key = os.environ.get("GEMINI_API_KEY")
|
||||
if not api_key:
|
||||
print("ERROR: GEMINI_API_KEY not set", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
client = genai.Client(api_key=api_key)
|
||||
model_id = MODEL_IDS[args.model]
|
||||
|
||||
cfg = types.GenerateContentConfig(
|
||||
response_modalities=["IMAGE"],
|
||||
image_config=types.ImageConfig(
|
||||
aspect_ratio=args.aspect,
|
||||
image_size=args.size,
|
||||
),
|
||||
)
|
||||
|
||||
print(f"Calling {model_id} aspect={args.aspect} size={args.size}...", flush=True)
|
||||
response = client.models.generate_content(
|
||||
model=model_id,
|
||||
contents=args.prompt,
|
||||
config=cfg,
|
||||
)
|
||||
|
||||
for cand in response.candidates:
|
||||
for part in cand.content.parts:
|
||||
if part.inline_data and part.inline_data.data:
|
||||
img = Image.open(io.BytesIO(part.inline_data.data))
|
||||
ext = os.path.splitext(args.output)[1].lower()
|
||||
if ext == ".webp":
|
||||
img.save(args.output, "WEBP", quality=90, method=6)
|
||||
elif ext in (".jpg", ".jpeg"):
|
||||
img.convert("RGB").save(args.output, "JPEG", quality=90)
|
||||
else:
|
||||
img.save(args.output, "PNG")
|
||||
print(f"OK saved {args.output} ({img.size[0]}x{img.size[1]})", flush=True)
|
||||
return
|
||||
print("ERROR no image data in response", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,49 @@
|
||||
from playwright.sync_api import sync_playwright
|
||||
import os
|
||||
|
||||
OUT = os.path.abspath("shots")
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
|
||||
def set_slider(page, pct):
|
||||
page.evaluate(
|
||||
"""(p) => { const el = document.querySelector('[data-ba]'); if (el) { el.style.animation='none'; el.style.setProperty('--pos', p + '%'); } }""",
|
||||
pct,
|
||||
)
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
ctx = browser.new_context(viewport={"width": 1440, "height": 900}, device_scale_factor=1)
|
||||
page = ctx.new_page()
|
||||
page.goto("http://localhost:4321/", wait_until="networkidle", timeout=60000)
|
||||
page.wait_for_timeout(2800) # laat entrance-animatie settelen
|
||||
|
||||
set_slider(page, 50)
|
||||
page.wait_for_timeout(300)
|
||||
page.screenshot(path=os.path.join(OUT, "home-hero.png"))
|
||||
print("OK home-hero.png")
|
||||
|
||||
set_slider(page, 22)
|
||||
page.wait_for_timeout(300)
|
||||
page.screenshot(path=os.path.join(OUT, "home-hero-before.png"))
|
||||
print("OK home-hero-before.png")
|
||||
|
||||
# scroll voor lazy images + reveals, dan full page
|
||||
height = page.evaluate("document.body.scrollHeight")
|
||||
for y in range(0, height, 400):
|
||||
page.evaluate(f"window.scrollTo(0, {y})")
|
||||
page.wait_for_timeout(110)
|
||||
page.evaluate("window.scrollTo(0, 0)")
|
||||
page.wait_for_load_state("networkidle")
|
||||
page.wait_for_timeout(1000)
|
||||
page.screenshot(path=os.path.join(OUT, "home-full.png"), full_page=True)
|
||||
print("OK home-full.png")
|
||||
|
||||
# mobile
|
||||
m = browser.new_context(viewport={"width": 390, "height": 844}, device_scale_factor=2)
|
||||
mp = m.new_page()
|
||||
mp.goto("http://localhost:4321/", wait_until="networkidle", timeout=60000)
|
||||
mp.wait_for_timeout(2800)
|
||||
mp.screenshot(path=os.path.join(OUT, "home-mobile.png"))
|
||||
print("OK home-mobile.png")
|
||||
|
||||
browser.close()
|
||||
@@ -0,0 +1,22 @@
|
||||
from playwright.sync_api import sync_playwright
|
||||
import os
|
||||
OUT = os.path.abspath("shots"); os.makedirs(OUT, exist_ok=True)
|
||||
with sync_playwright() as p:
|
||||
b = p.chromium.launch(headless=True)
|
||||
pg = b.new_context(viewport={"width":1440,"height":1000}).new_page()
|
||||
pg.goto("http://localhost:4321/", wait_until="networkidle", timeout=60000)
|
||||
for y in range(0, pg.evaluate("document.body.scrollHeight"), 400):
|
||||
pg.evaluate(f"window.scrollTo(0,{y})"); pg.wait_for_timeout(70)
|
||||
pg.evaluate("window.scrollTo(0,0)"); pg.wait_for_timeout(700)
|
||||
el = pg.query_selector(".svc"); el.scroll_into_view_if_needed(); pg.wait_for_timeout(500)
|
||||
el.screenshot(path=os.path.join(OUT,"a-default.png")); print("OK a-default")
|
||||
panels = pg.query_selector_all(".apanel")
|
||||
if len(panels)>=3:
|
||||
panels[2].hover(); pg.wait_for_timeout(900); el.screenshot(path=os.path.join(OUT,"a-open3.png")); print("OK a-open3")
|
||||
if len(panels)>=5:
|
||||
panels[4].hover(); pg.wait_for_timeout(900); el.screenshot(path=os.path.join(OUT,"a-open5.png")); print("OK a-open5")
|
||||
mp = b.new_context(viewport={"width":390,"height":844}, device_scale_factor=2).new_page()
|
||||
mp.goto("http://localhost:4321/", wait_until="networkidle", timeout=60000); mp.wait_for_timeout(1500)
|
||||
ms = mp.query_selector(".svc"); ms.scroll_into_view_if_needed(); mp.wait_for_timeout(400)
|
||||
ms.screenshot(path=os.path.join(OUT,"a-mobile.png")); print("OK a-mobile")
|
||||
b.close()
|
||||
@@ -0,0 +1,14 @@
|
||||
from playwright.sync_api import sync_playwright
|
||||
import os
|
||||
OUT = os.path.abspath("shots"); os.makedirs(OUT, exist_ok=True)
|
||||
with sync_playwright() as p:
|
||||
b = p.chromium.launch(headless=True)
|
||||
pg = b.new_context(viewport={"width":1440,"height":900}).new_page()
|
||||
pg.goto("http://localhost:4321/", wait_until="networkidle", timeout=60000)
|
||||
for y in range(0, pg.evaluate("document.body.scrollHeight"), 400):
|
||||
pg.evaluate(f"window.scrollTo(0,{y})"); pg.wait_for_timeout(70)
|
||||
pg.evaluate("window.scrollTo(0,0)"); pg.wait_for_timeout(700)
|
||||
pg.screenshot(path=os.path.join(OUT,"b2-full.png"), full_page=True); print("OK b2-full")
|
||||
el = pg.query_selector(".flow"); el.scroll_into_view_if_needed(); pg.wait_for_timeout(500)
|
||||
el.screenshot(path=os.path.join(OUT,"b2-flow.png")); print("OK b2-flow")
|
||||
b.close()
|
||||
@@ -0,0 +1,23 @@
|
||||
from playwright.sync_api import sync_playwright
|
||||
import os
|
||||
OUT = os.path.abspath("shots"); os.makedirs(OUT, exist_ok=True)
|
||||
with sync_playwright() as p:
|
||||
b = p.chromium.launch(headless=True)
|
||||
pg = b.new_context(viewport={"width":1440,"height":900}).new_page()
|
||||
pg.goto("http://localhost:4321/", wait_until="networkidle", timeout=60000)
|
||||
pg.wait_for_timeout(2600)
|
||||
pg.screenshot(path=os.path.join(OUT,"b-hero.png")); print("OK b-hero")
|
||||
for y in range(0, pg.evaluate("document.body.scrollHeight"), 400):
|
||||
pg.evaluate(f"window.scrollTo(0,{y})"); pg.wait_for_timeout(80)
|
||||
pg.evaluate("window.scrollTo(0,0)"); pg.wait_for_timeout(700)
|
||||
el = pg.query_selector(".showcase"); el.scroll_into_view_if_needed(); pg.wait_for_timeout(500)
|
||||
el.screenshot(path=os.path.join(OUT,"b-showcase.png")); print("OK b-showcase")
|
||||
tiles = pg.query_selector_all(".stile")
|
||||
if tiles: tiles[0].hover(); pg.wait_for_timeout(700); el.screenshot(path=os.path.join(OUT,"b-showcase-hover.png")); print("OK b-showcase-hover")
|
||||
pg.screenshot(path=os.path.join(OUT,"b-full.png"), full_page=True); print("OK b-full")
|
||||
mp = b.new_context(viewport={"width":390,"height":844}, device_scale_factor=2).new_page()
|
||||
mp.goto("http://localhost:4321/", wait_until="networkidle", timeout=60000); mp.wait_for_timeout(2200)
|
||||
mp.screenshot(path=os.path.join(OUT,"b-mobile-hero.png")); print("OK b-mobile-hero")
|
||||
ms = mp.query_selector(".showcase"); ms.scroll_into_view_if_needed(); mp.wait_for_timeout(500)
|
||||
ms.screenshot(path=os.path.join(OUT,"b-mobile-showcase.png")); print("OK b-mobile-showcase")
|
||||
b.close()
|
||||
@@ -0,0 +1,28 @@
|
||||
from playwright.sync_api import sync_playwright
|
||||
import os
|
||||
|
||||
OUT = os.path.abspath("shots")
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
ctx = browser.new_context(viewport={"width": 1440, "height": 900}, device_scale_factor=2)
|
||||
page = ctx.new_page()
|
||||
page.goto("http://localhost:4321/", wait_until="networkidle", timeout=60000)
|
||||
page.wait_for_timeout(2800)
|
||||
|
||||
# zet slider midden zodat de hero-kop goed leesbaar is
|
||||
page.evaluate("""() => { const el = document.querySelector('[data-ba]'); if (el) { el.style.animation='none'; el.style.setProperty('--pos','50%'); } }""")
|
||||
page.wait_for_timeout(300)
|
||||
|
||||
# header + hero
|
||||
page.screenshot(path=os.path.join(OUT, "brand-hero.png"))
|
||||
print("OK brand-hero.png")
|
||||
|
||||
# tight crop van het logo (linksboven in de header)
|
||||
brand = page.query_selector(".brand")
|
||||
if brand:
|
||||
brand.screenshot(path=os.path.join(OUT, "brand-logo.png"))
|
||||
print("OK brand-logo.png")
|
||||
|
||||
browser.close()
|
||||
@@ -0,0 +1,14 @@
|
||||
from playwright.sync_api import sync_playwright
|
||||
import os
|
||||
OUT=os.path.abspath("shots"); os.makedirs(OUT,exist_ok=True)
|
||||
with sync_playwright() as p:
|
||||
b=p.chromium.launch(headless=True)
|
||||
pg=b.new_context(viewport={"width":1440,"height":950}).new_page()
|
||||
pg.goto("http://localhost:4321/", wait_until="networkidle", timeout=60000)
|
||||
for y in range(0, pg.evaluate("document.body.scrollHeight"), 380):
|
||||
pg.evaluate(f"window.scrollTo(0,{y})"); pg.wait_for_timeout(70)
|
||||
pg.evaluate("window.scrollTo(0,0)"); pg.wait_for_timeout(700)
|
||||
for sel,name in [(".svc","c2-acc"),(".faq","c2-faq"),(".prefooter","c2-form"),(".intro","c2-intro")]:
|
||||
el=pg.query_selector(sel)
|
||||
if el: el.scroll_into_view_if_needed(); pg.wait_for_timeout(400); el.screenshot(path=os.path.join(OUT,name+".png")); print("OK",name)
|
||||
b.close()
|
||||
@@ -0,0 +1,25 @@
|
||||
from playwright.sync_api import sync_playwright
|
||||
import os
|
||||
|
||||
OUT = os.path.abspath("shots")
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
|
||||
with sync_playwright() as p:
|
||||
b = p.chromium.launch(headless=True)
|
||||
pg = b.new_context(viewport={"width": 1440, "height": 820}, device_scale_factor=2).new_page()
|
||||
pg.goto("http://localhost:4321/", wait_until="networkidle", timeout=60000)
|
||||
pg.wait_for_timeout(2600)
|
||||
pg.screenshot(path=os.path.join(OUT, "hero-desktop.png"))
|
||||
print("OK hero-desktop.png")
|
||||
|
||||
# diensten-sectiekop
|
||||
head = pg.query_selector(".svc__head")
|
||||
if head:
|
||||
# scroll de sectie in beeld zodat lazy/reveals laden
|
||||
head.scroll_into_view_if_needed()
|
||||
pg.wait_for_timeout(700)
|
||||
head.screenshot(path=os.path.join(OUT, "diensten-head.png"))
|
||||
lines = pg.eval_on_selector("#diensten-h", "e => { const r = e.getClientRects(); return r.length; }")
|
||||
h = pg.eval_on_selector("#diensten-h", "e => Math.round(e.getBoundingClientRect().height)")
|
||||
print(f"OK diensten-head.png (h2 hoogte={h}px)")
|
||||
b.close()
|
||||
@@ -0,0 +1,20 @@
|
||||
from playwright.sync_api import sync_playwright
|
||||
import os
|
||||
OUT=os.path.abspath("shots"); os.makedirs(OUT,exist_ok=True)
|
||||
with sync_playwright() as p:
|
||||
b=p.chromium.launch(headless=True)
|
||||
pg=b.new_context(viewport={"width":1440,"height":950}).new_page()
|
||||
pg.goto("http://localhost:4321/", wait_until="networkidle", timeout=60000); pg.wait_for_timeout(2600)
|
||||
pg.screenshot(path=os.path.join(OUT,"cur-hero.png"));
|
||||
for y in range(0, pg.evaluate("document.body.scrollHeight"), 380):
|
||||
pg.evaluate(f"window.scrollTo(0,{y})"); pg.wait_for_timeout(70)
|
||||
pg.evaluate("window.scrollTo(0,0)"); pg.wait_for_timeout(800)
|
||||
pg.screenshot(path=os.path.join(OUT,"cur-full.png"), full_page=True)
|
||||
mp=b.new_context(viewport={"width":390,"height":844}, device_scale_factor=2).new_page()
|
||||
mp.goto("http://localhost:4321/", wait_until="networkidle", timeout=60000); mp.wait_for_timeout(2200)
|
||||
for y in range(0, mp.evaluate("document.body.scrollHeight"), 380):
|
||||
mp.evaluate(f"window.scrollTo(0,{y})"); mp.wait_for_timeout(60)
|
||||
mp.evaluate("window.scrollTo(0,0)"); mp.wait_for_timeout(600)
|
||||
mp.screenshot(path=os.path.join(OUT,"cur-mobile-full.png"), full_page=True)
|
||||
print("OK shots")
|
||||
b.close()
|
||||
@@ -0,0 +1,92 @@
|
||||
from playwright.sync_api import sync_playwright
|
||||
import os
|
||||
|
||||
OUT = os.path.abspath("shots")
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
URL = "http://localhost:4321/diensten/gevelreiniging"
|
||||
|
||||
def set_slider(page, pct):
|
||||
page.evaluate(
|
||||
"""(p) => { const el = document.querySelector('[data-ba]'); if (el) { el.style.setProperty('--pos', p + '%'); const r = el.querySelector('.ba__range'); if (r) r.value = p; } }""",
|
||||
pct,
|
||||
)
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
ctx = browser.new_context(viewport={"width": 1440, "height": 900}, device_scale_factor=1)
|
||||
page = ctx.new_page()
|
||||
page.goto(URL, wait_until="networkidle", timeout=60000)
|
||||
page.wait_for_timeout(1500)
|
||||
page.screenshot(path=os.path.join(OUT, "dienst-hero.png"))
|
||||
print("OK dienst-hero.png status-title:", page.title())
|
||||
|
||||
# scroll voor lazy images + reveals
|
||||
height = page.evaluate("document.body.scrollHeight")
|
||||
for y in range(0, height, 420):
|
||||
page.evaluate(f"window.scrollTo(0, {y})")
|
||||
page.wait_for_timeout(110)
|
||||
page.evaluate("window.scrollTo(0, 0)")
|
||||
page.wait_for_timeout(800)
|
||||
|
||||
# resultaat-galerij
|
||||
el = page.query_selector(".svc-result")
|
||||
if el:
|
||||
el.scroll_into_view_if_needed()
|
||||
page.wait_for_timeout(700)
|
||||
el.screenshot(path=os.path.join(OUT, "dienst-result.png")); print("OK dienst-result.png")
|
||||
else:
|
||||
print("!! geen .svc-result gevonden")
|
||||
|
||||
page.evaluate("window.scrollTo(0, 0)")
|
||||
page.wait_for_timeout(400)
|
||||
page.screenshot(path=os.path.join(OUT, "dienst-full.png"), full_page=True)
|
||||
print("OK dienst-full.png")
|
||||
|
||||
# axe
|
||||
try:
|
||||
page.add_script_tag(url="https://cdnjs.cloudflare.com/ajax/libs/axe-core/4.10.2/axe.min.js")
|
||||
page.wait_for_timeout(500)
|
||||
res = page.evaluate("""async () => {
|
||||
const r = await axe.run(document, {runOnly:['wcag2a','wcag2aa','wcag21aa']});
|
||||
return r.violations.map(v=>({id:v.id, impact:v.impact, n:v.nodes.length, help:v.help, sample:(v.nodes[0]&&v.nodes[0].target)||[]}));
|
||||
}""")
|
||||
print("=== AXE ===")
|
||||
if not res:
|
||||
print("geen axe-violations")
|
||||
for v in res:
|
||||
print(f"[{v['impact']}] {v['id']} x{v['n']} :: {v['help']} e.g. {v['sample']}")
|
||||
except Exception as e:
|
||||
print("AXE FAILED:", e)
|
||||
|
||||
# headings
|
||||
heads = page.eval_on_selector_all("h1,h2,h3", "els=>els.map(e=>e.tagName+': '+e.textContent.trim().slice(0,46))")
|
||||
print("=== HEADINGS ==="); [print(h) for h in heads]
|
||||
|
||||
# overflow
|
||||
for w in (1440, 768, 390):
|
||||
c = browser.new_context(viewport={"width": w, "height": 900})
|
||||
pp = c.new_page(); pp.goto(URL, wait_until="networkidle", timeout=60000)
|
||||
sw = pp.evaluate("document.documentElement.scrollWidth"); cw = pp.evaluate("document.documentElement.clientWidth")
|
||||
print(f"viewport {w}: scrollWidth={sw} clientWidth={cw} overflow={'JA' if sw>cw+1 else 'nee'}")
|
||||
c.close()
|
||||
|
||||
# mobile shot
|
||||
m = browser.new_context(viewport={"width": 390, "height": 844}, device_scale_factor=2)
|
||||
mp = m.new_page()
|
||||
mp.goto(URL, wait_until="networkidle", timeout=60000)
|
||||
mp.wait_for_timeout(800)
|
||||
mh = mp.evaluate("document.body.scrollHeight")
|
||||
for y in range(0, mh, 360):
|
||||
mp.evaluate(f"window.scrollTo(0, {y})")
|
||||
mp.wait_for_timeout(90)
|
||||
mp.evaluate("window.scrollTo(0, 0)")
|
||||
mp.wait_for_timeout(700)
|
||||
mp.screenshot(path=os.path.join(OUT, "dienst-mobile.png"), full_page=True)
|
||||
print("OK dienst-mobile.png")
|
||||
|
||||
# 404 check
|
||||
r404 = browser.new_context().new_page()
|
||||
resp = r404.goto("http://localhost:4321/diensten/bestaat-niet", wait_until="domcontentloaded", timeout=30000)
|
||||
print("404-check /diensten/bestaat-niet ->", resp.status if resp else "geen response")
|
||||
|
||||
browser.close()
|
||||
@@ -0,0 +1,49 @@
|
||||
from playwright.sync_api import sync_playwright
|
||||
import os
|
||||
|
||||
OUT = os.path.abspath("shots")
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
SLUGS = ["voegwerk", "dakreiniging-coating", "schoorsteen-voegwerk", "algemeen-reinigen"]
|
||||
BASE = "http://localhost:4321/diensten/"
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
for slug in SLUGS:
|
||||
url = BASE + slug
|
||||
ctx = browser.new_context(viewport={"width": 1440, "height": 900}, device_scale_factor=1)
|
||||
page = ctx.new_page()
|
||||
page.goto(url, wait_until="networkidle", timeout=60000)
|
||||
page.wait_for_timeout(800)
|
||||
h = page.evaluate("document.body.scrollHeight")
|
||||
for y in range(0, h, 420):
|
||||
page.evaluate(f"window.scrollTo(0, {y})")
|
||||
page.wait_for_timeout(90)
|
||||
page.evaluate("window.scrollTo(0, 0)")
|
||||
page.wait_for_timeout(600)
|
||||
page.screenshot(path=os.path.join(OUT, f"d-{slug}.png"), full_page=True)
|
||||
|
||||
# axe
|
||||
try:
|
||||
page.add_script_tag(url="https://cdnjs.cloudflare.com/ajax/libs/axe-core/4.10.2/axe.min.js")
|
||||
page.wait_for_timeout(400)
|
||||
res = page.evaluate("""async () => {
|
||||
const r = await axe.run(document, {runOnly:['wcag2a','wcag2aa','wcag21aa']});
|
||||
return r.violations.map(v=>({id:v.id, impact:v.impact, n:v.nodes.length, sample:(v.nodes[0]&&v.nodes[0].target)||[]}));
|
||||
}""")
|
||||
axe_txt = "axe: clean" if not res else "axe: " + "; ".join(f"[{v['impact']}] {v['id']} x{v['n']} {v['sample']}" for v in res)
|
||||
except Exception as e:
|
||||
axe_txt = f"axe FAILED: {e}"
|
||||
|
||||
# overflow at 3 widths
|
||||
ov = []
|
||||
for w in (1440, 768, 390):
|
||||
c2 = browser.new_context(viewport={"width": w, "height": 900})
|
||||
pp = c2.new_page(); pp.goto(url, wait_until="networkidle", timeout=60000)
|
||||
sw = pp.evaluate("document.documentElement.scrollWidth"); cw = pp.evaluate("document.documentElement.clientWidth")
|
||||
ov.append(f"{w}:{'OVERFLOW' if sw>cw+1 else 'ok'}")
|
||||
c2.close()
|
||||
|
||||
print(f"{slug:24} OK d-{slug}.png {axe_txt} overflow[{', '.join(ov)}]")
|
||||
ctx.close()
|
||||
|
||||
browser.close()
|
||||
@@ -0,0 +1,37 @@
|
||||
from playwright.sync_api import sync_playwright
|
||||
import os
|
||||
|
||||
OUT = os.path.abspath("shots")
|
||||
with sync_playwright() as p:
|
||||
b = p.chromium.launch(headless=True)
|
||||
# /diensten overzicht
|
||||
ctx = b.new_context(viewport={"width": 1440, "height": 900})
|
||||
pg = ctx.new_page()
|
||||
pg.goto("http://localhost:4321/diensten", wait_until="networkidle", timeout=60000)
|
||||
pg.wait_for_timeout(800)
|
||||
h = pg.evaluate("document.body.scrollHeight")
|
||||
for y in range(0, h, 420):
|
||||
pg.evaluate(f"window.scrollTo(0,{y})"); pg.wait_for_timeout(80)
|
||||
pg.evaluate("window.scrollTo(0,0)"); pg.wait_for_timeout(500)
|
||||
pg.screenshot(path=os.path.join(OUT, "diensten-index.png"), full_page=True)
|
||||
try:
|
||||
pg.add_script_tag(url="https://cdnjs.cloudflare.com/ajax/libs/axe-core/4.10.2/axe.min.js")
|
||||
pg.wait_for_timeout(400)
|
||||
res = pg.evaluate("""async()=>{const r=await axe.run(document,{runOnly:['wcag2a','wcag2aa','wcag21aa']});return r.violations.map(v=>v.impact+':'+v.id+' x'+v.nodes.length);}""")
|
||||
print("diensten-index axe:", "clean" if not res else res)
|
||||
except Exception as e:
|
||||
print("axe failed", e)
|
||||
sw = pg.evaluate("document.documentElement.scrollWidth"); cw = pg.evaluate("document.documentElement.clientWidth")
|
||||
print("diensten-index overflow:", "JA" if sw > cw+1 else "nee")
|
||||
ctx.close()
|
||||
|
||||
# breadcrumb op een dienstpagina
|
||||
c2 = b.new_context(viewport={"width": 1440, "height": 700})
|
||||
p2 = c2.new_page()
|
||||
p2.goto("http://localhost:4321/diensten/voegwerk", wait_until="networkidle", timeout=60000)
|
||||
p2.wait_for_timeout(700)
|
||||
crumbs = p2.eval_on_selector_all(".crumbs a, .crumbs [aria-current]", "els=>els.map(e=>e.textContent.trim())")
|
||||
print("voegwerk breadcrumb:", crumbs)
|
||||
p2.screenshot(path=os.path.join(OUT, "crumb-voegwerk.png"), clip={"x":0,"y":80,"width":760,"height":260})
|
||||
c2.close()
|
||||
b.close()
|
||||
@@ -0,0 +1,29 @@
|
||||
from playwright.sync_api import sync_playwright
|
||||
import os
|
||||
|
||||
OUT = os.path.abspath("shots")
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
URL = "https://helvoirt-gevelreiniging.mike-197.workers.dev/"
|
||||
|
||||
widths = [1900, 1440, 1280, 1024]
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
for w in widths:
|
||||
ctx = browser.new_context(viewport={"width": w, "height": 860}, device_scale_factor=1)
|
||||
page = ctx.new_page()
|
||||
page.goto(URL, wait_until="networkidle", timeout=60000)
|
||||
page.wait_for_timeout(2600)
|
||||
page.screenshot(path=os.path.join(OUT, f"hero-{w}.png"))
|
||||
print(f"OK hero-{w}.png")
|
||||
ctx.close()
|
||||
|
||||
# mobile
|
||||
m = browser.new_context(viewport={"width": 390, "height": 844}, device_scale_factor=2)
|
||||
mp = m.new_page()
|
||||
mp.goto(URL, wait_until="networkidle", timeout=60000)
|
||||
mp.wait_for_timeout(2600)
|
||||
mp.screenshot(path=os.path.join(OUT, "hero-mobile.png"), full_page=False)
|
||||
print("OK hero-mobile.png")
|
||||
|
||||
browser.close()
|
||||
@@ -0,0 +1,26 @@
|
||||
from playwright.sync_api import sync_playwright
|
||||
import os
|
||||
|
||||
OUT = os.path.abspath("shots")
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
|
||||
# desktop
|
||||
ctx = browser.new_context(viewport={"width": 1440, "height": 820}, device_scale_factor=2)
|
||||
page = ctx.new_page()
|
||||
page.goto("http://localhost:4321/", wait_until="networkidle", timeout=60000)
|
||||
page.wait_for_timeout(2600) # entrance laten settelen
|
||||
page.screenshot(path=os.path.join(OUT, "hero-desktop.png"))
|
||||
print("OK hero-desktop.png")
|
||||
|
||||
# mobile
|
||||
m = browser.new_context(viewport={"width": 390, "height": 844}, device_scale_factor=2)
|
||||
mp = m.new_page()
|
||||
mp.goto("http://localhost:4321/", wait_until="networkidle", timeout=60000)
|
||||
mp.wait_for_timeout(2600)
|
||||
mp.screenshot(path=os.path.join(OUT, "hero-mobile.png"), full_page=False)
|
||||
print("OK hero-mobile.png")
|
||||
|
||||
browser.close()
|
||||
@@ -0,0 +1,14 @@
|
||||
from playwright.sync_api import sync_playwright
|
||||
url = "https://helvoirt-gevelreiniging.mike-197.workers.dev/"
|
||||
with sync_playwright() as p:
|
||||
b = p.chromium.launch()
|
||||
pg = b.new_page(viewport={"width":1440,"height":900})
|
||||
pg.goto(url, wait_until="networkidle", timeout=60000)
|
||||
pg.wait_for_timeout(1500)
|
||||
pg.screenshot(path="public/_shot-home-hero.png")
|
||||
# scroll to proof gallery
|
||||
pg.evaluate("document.querySelector('.proof')?.scrollIntoView()")
|
||||
pg.wait_for_timeout(1200)
|
||||
pg.screenshot(path="public/_shot-home-proof.png")
|
||||
b.close()
|
||||
print("done")
|
||||
@@ -0,0 +1,61 @@
|
||||
from playwright.sync_api import sync_playwright
|
||||
import os
|
||||
|
||||
OUT = os.path.abspath("shots")
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
PAGES = [("over-ons", "http://localhost:4321/over-ons"), ("contact", "http://localhost:4321/contact")]
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
for name, url in PAGES:
|
||||
ctx = browser.new_context(viewport={"width": 1440, "height": 900}, device_scale_factor=1)
|
||||
page = ctx.new_page()
|
||||
resp = page.goto(url, wait_until="networkidle", timeout=60000)
|
||||
page.wait_for_timeout(800)
|
||||
h = page.evaluate("document.body.scrollHeight")
|
||||
for y in range(0, h, 420):
|
||||
page.evaluate(f"window.scrollTo(0, {y})")
|
||||
page.wait_for_timeout(90)
|
||||
page.evaluate("window.scrollTo(0, 0)")
|
||||
page.wait_for_timeout(600)
|
||||
page.screenshot(path=os.path.join(OUT, f"p-{name}.png"), full_page=True)
|
||||
|
||||
try:
|
||||
page.add_script_tag(url="https://cdnjs.cloudflare.com/ajax/libs/axe-core/4.10.2/axe.min.js")
|
||||
page.wait_for_timeout(400)
|
||||
res = page.evaluate("""async () => {
|
||||
const r = await axe.run(document, {runOnly:['wcag2a','wcag2aa','wcag21aa']});
|
||||
return r.violations.map(v=>({id:v.id, impact:v.impact, n:v.nodes.length, sample:(v.nodes[0]&&v.nodes[0].target)||[]}));
|
||||
}""")
|
||||
axe_txt = "axe: clean" if not res else "axe: " + "; ".join(f"[{v['impact']}] {v['id']} x{v['n']} {v['sample']}" for v in res)
|
||||
except Exception as e:
|
||||
axe_txt = f"axe FAILED: {e}"
|
||||
|
||||
ov = []
|
||||
for w in (1440, 768, 390):
|
||||
c2 = browser.new_context(viewport={"width": w, "height": 900})
|
||||
pp = c2.new_page(); pp.goto(url, wait_until="networkidle", timeout=60000)
|
||||
sw = pp.evaluate("document.documentElement.scrollWidth"); cw = pp.evaluate("document.documentElement.clientWidth")
|
||||
ov.append(f"{w}:{'OVERFLOW' if sw>cw+1 else 'ok'}")
|
||||
c2.close()
|
||||
|
||||
heads = page.eval_on_selector_all("h1,h2", "els=>els.map(e=>e.tagName+':'+e.textContent.trim().slice(0,38))")
|
||||
status = resp.status if resp else "?"
|
||||
print(f"{name:9} status={status} OK p-{name}.png {axe_txt} overflow[{', '.join(ov)}]")
|
||||
print(" heads:", heads)
|
||||
ctx.close()
|
||||
|
||||
# mobile shots
|
||||
for name, url in PAGES:
|
||||
m = browser.new_context(viewport={"width": 390, "height": 844}, device_scale_factor=2)
|
||||
mp = m.new_page(); mp.goto(url, wait_until="networkidle", timeout=60000)
|
||||
mp.wait_for_timeout(700)
|
||||
mh = mp.evaluate("document.body.scrollHeight")
|
||||
for y in range(0, mh, 360):
|
||||
mp.evaluate(f"window.scrollTo(0, {y})"); mp.wait_for_timeout(80)
|
||||
mp.evaluate("window.scrollTo(0, 0)"); mp.wait_for_timeout(500)
|
||||
mp.screenshot(path=os.path.join(OUT, f"p-{name}-mobile.png"), full_page=True)
|
||||
print(f"OK p-{name}-mobile.png")
|
||||
m.close()
|
||||
|
||||
browser.close()
|
||||
@@ -0,0 +1,17 @@
|
||||
from playwright.sync_api import sync_playwright
|
||||
import os
|
||||
OUT = os.path.abspath("shots")
|
||||
with sync_playwright() as p:
|
||||
b = p.chromium.launch(headless=True)
|
||||
pg = b.new_context(viewport={"width": 1440, "height": 900}).new_page()
|
||||
pg.goto("http://localhost:4321/over-ons", wait_until="networkidle", timeout=60000)
|
||||
pg.wait_for_timeout(500)
|
||||
el = pg.query_selector(".prefooter")
|
||||
el.scroll_into_view_if_needed()
|
||||
pg.wait_for_timeout(600)
|
||||
el.screenshot(path=os.path.join(OUT, "prefooter.png"))
|
||||
# measure phone/email link heights
|
||||
h = pg.eval_on_selector_all("a.prefooter__val", "els=>els.map(e=>{const r=e.getBoundingClientRect();return Math.round(r.width)+'x'+Math.round(r.height);})")
|
||||
print("prefooter link sizes:", h)
|
||||
print("OK prefooter.png")
|
||||
b.close()
|
||||
@@ -0,0 +1,16 @@
|
||||
from playwright.sync_api import sync_playwright
|
||||
import os
|
||||
OUT = os.path.abspath("shots"); os.makedirs(OUT, exist_ok=True)
|
||||
with sync_playwright() as p:
|
||||
b = p.chromium.launch(headless=True)
|
||||
pg = b.new_context(viewport={"width":1440,"height":900}).new_page()
|
||||
pg.goto("http://localhost:4321/", wait_until="networkidle", timeout=60000)
|
||||
for y in range(0, pg.evaluate("document.body.scrollHeight"), 400):
|
||||
pg.evaluate(f"window.scrollTo(0,{y})"); pg.wait_for_timeout(80)
|
||||
pg.evaluate("window.scrollTo(0,0)"); pg.wait_for_timeout(800)
|
||||
for sel, name in [(".flow","sec-flow"),(".proof","sec-proof"),(".reviews","sec-reviews"),(".band:first-of-type","sec-band")]:
|
||||
el = pg.query_selector(sel)
|
||||
if el:
|
||||
el.scroll_into_view_if_needed(); pg.wait_for_timeout(400)
|
||||
el.screenshot(path=os.path.join(OUT, name+".png")); print("OK", name)
|
||||
b.close()
|
||||
@@ -0,0 +1,24 @@
|
||||
from playwright.sync_api import sync_playwright
|
||||
import os
|
||||
OUT = os.path.abspath("shots"); os.makedirs(OUT, exist_ok=True)
|
||||
with sync_playwright() as p:
|
||||
b = p.chromium.launch(headless=True)
|
||||
pg = b.new_context(viewport={"width":1440,"height":900}).new_page()
|
||||
pg.goto("http://localhost:4321/", wait_until="networkidle", timeout=60000)
|
||||
for y in range(0, pg.evaluate("document.body.scrollHeight"), 400):
|
||||
pg.evaluate(f"window.scrollTo(0,{y})"); pg.wait_for_timeout(80)
|
||||
pg.evaluate("window.scrollTo(0,0)"); pg.wait_for_timeout(700)
|
||||
el = pg.query_selector(".services")
|
||||
el.scroll_into_view_if_needed(); pg.wait_for_timeout(500)
|
||||
el.screenshot(path=os.path.join(OUT,"sec-services.png")); print("OK sec-services")
|
||||
# hover 3e dienst
|
||||
rows = pg.query_selector_all(".srow")
|
||||
if len(rows) >= 3:
|
||||
rows[2].hover(); pg.wait_for_timeout(800)
|
||||
el.screenshot(path=os.path.join(OUT,"sec-services-hover.png")); print("OK sec-services-hover")
|
||||
# mobile
|
||||
mp = b.new_context(viewport={"width":390,"height":844}, device_scale_factor=2).new_page()
|
||||
mp.goto("http://localhost:4321/", wait_until="networkidle", timeout=60000)
|
||||
mse = mp.query_selector(".services"); mse.scroll_into_view_if_needed(); mp.wait_for_timeout(500)
|
||||
mse.screenshot(path=os.path.join(OUT,"sec-services-mobile.png")); print("OK sec-services-mobile")
|
||||
b.close()
|
||||
@@ -0,0 +1,21 @@
|
||||
from playwright.sync_api import sync_playwright
|
||||
import os
|
||||
OUT = os.path.abspath("shots"); os.makedirs(OUT, exist_ok=True)
|
||||
with sync_playwright() as p:
|
||||
b = p.chromium.launch(headless=True)
|
||||
pg = b.new_context(viewport={"width":1440,"height":900}).new_page()
|
||||
pg.goto("http://localhost:4321/", wait_until="networkidle", timeout=60000)
|
||||
for y in range(0, pg.evaluate("document.body.scrollHeight"), 400):
|
||||
pg.evaluate(f"window.scrollTo(0,{y})"); pg.wait_for_timeout(70)
|
||||
pg.evaluate("window.scrollTo(0,0)"); pg.wait_for_timeout(700)
|
||||
el = pg.query_selector(".svcmenu"); el.scroll_into_view_if_needed(); pg.wait_for_timeout(400)
|
||||
el.screenshot(path=os.path.join(OUT,"m-svc.png")); print("OK m-svc")
|
||||
rows = pg.query_selector_all(".svcrow__link")
|
||||
if len(rows)>=2:
|
||||
rows[1].hover(); pg.wait_for_timeout(700); el.screenshot(path=os.path.join(OUT,"m-svc-hover.png")); print("OK m-svc-hover")
|
||||
pg.screenshot(path=os.path.join(OUT,"m-full.png"), full_page=True); print("OK m-full")
|
||||
mp = b.new_context(viewport={"width":390,"height":844}, device_scale_factor=2).new_page()
|
||||
mp.goto("http://localhost:4321/", wait_until="networkidle", timeout=60000); mp.wait_for_timeout(1500)
|
||||
ms = mp.query_selector(".svcmenu"); ms.scroll_into_view_if_needed(); mp.wait_for_timeout(400)
|
||||
ms.screenshot(path=os.path.join(OUT,"m-svc-mobile.png")); print("OK m-svc-mobile")
|
||||
b.close()
|
||||
@@ -0,0 +1,20 @@
|
||||
from playwright.sync_api import sync_playwright
|
||||
with sync_playwright() as p:
|
||||
b = p.chromium.launch(headless=True)
|
||||
# mobiel touch-targets
|
||||
pg = b.new_context(viewport={"width":390,"height":844}).new_page()
|
||||
pg.goto("http://localhost:4321/", wait_until="networkidle", timeout=60000)
|
||||
sels = {"hamburger":"#hamburger","header phone":".header__phone","sticky CTA":"#stickyMobileCta",
|
||||
"footer link":".footer__nav a","btn-text (proof)":".proof .btn-text"}
|
||||
print("=== MOBIEL TOUCH-TARGETS (min 44px) ===")
|
||||
for name,sel in sels.items():
|
||||
el = pg.query_selector(sel)
|
||||
if el:
|
||||
box = el.bounding_box()
|
||||
if box: print(f" {name:18} {box['width']:.0f} x {box['height']:.0f}")
|
||||
else: print(f" {name:18} (niet gevonden)")
|
||||
# hero image loading attrs
|
||||
print("\n=== HERO BEELDEN ===")
|
||||
imgs = pg.eval_on_selector_all(".ba .ba__img", "els=>els.map(e=>({src:e.currentSrc.split('/').pop(), loading:e.loading, fp:e.getAttribute('fetchpriority')}))")
|
||||
for i in imgs: print(" ", i)
|
||||
b.close()
|
||||
Reference in New Issue
Block a user