@xiwebdesign/xicms-core 8.15.0 → 8.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/dist/XiCMSProvider.js +1 -0
  2. package/dist/XiCMSProvider.js.map +1 -1
  3. package/dist/admin/lib/veldTaal.js +1 -0
  4. package/dist/admin/lib/veldTaal.js.map +1 -1
  5. package/dist/admin/settings/BedrijfInstellingen.js +17 -2
  6. package/dist/admin/settings/BedrijfInstellingen.js.map +1 -1
  7. package/dist/admin/shop/ShopSettingsTab.js +29 -16
  8. package/dist/admin/shop/ShopSettingsTab.js.map +1 -1
  9. package/dist/auth/AuthProvider.js +1 -0
  10. package/dist/auth/AuthProvider.js.map +1 -1
  11. package/dist/bedrijf/bedrijf.d.ts +8 -0
  12. package/dist/bedrijf/bedrijf.js +16 -1
  13. package/dist/bedrijf/bedrijf.js.map +1 -1
  14. package/dist/blocks/builtins/ContactRender.js +8 -0
  15. package/dist/blocks/builtins/ContactRender.js.map +1 -1
  16. package/dist/blocks/builtins/WhatsappChatRender.js +2 -0
  17. package/dist/blocks/builtins/WhatsappChatRender.js.map +1 -1
  18. package/dist/blocks/builtins/consentEmbed.js +26 -9
  19. package/dist/blocks/builtins/consentEmbed.js.map +1 -1
  20. package/dist/components/onderOffset.d.ts +13 -0
  21. package/dist/components/onderOffset.js +74 -0
  22. package/dist/components/onderOffset.js.map +1 -0
  23. package/dist/components/site/Bedrijfsnummers.d.ts +9 -0
  24. package/dist/components/site/Bedrijfsnummers.js +12 -0
  25. package/dist/components/site/Bedrijfsnummers.js.map +1 -0
  26. package/dist/components/site/SiteFooter.js +2 -1
  27. package/dist/components/site/SiteFooter.js.map +1 -1
  28. package/dist/firebase/functionsClient.js +5 -1
  29. package/dist/firebase/functionsClient.js.map +1 -1
  30. package/dist/index.d.ts +2 -1
  31. package/dist/index.js +5 -1
  32. package/dist/index.js.map +1 -1
  33. package/dist/modules/builtins.js +6 -6
  34. package/dist/modules/builtins.js.map +1 -1
  35. package/dist/modules/consent/ConsentBanner.d.ts +5 -0
  36. package/dist/modules/consent/ConsentBanner.js +8 -7
  37. package/dist/modules/consent/ConsentBanner.js.map +1 -1
  38. package/dist/modules/consent/standaard.d.ts +12 -0
  39. package/dist/modules/consent/standaard.js +10 -0
  40. package/dist/modules/consent/standaard.js.map +1 -0
  41. package/dist/modules/consent/useConsent.d.ts +4 -0
  42. package/dist/modules/consent/useConsent.js +30 -1
  43. package/dist/modules/consent/useConsent.js.map +1 -1
  44. package/dist/releaseNotes.js +8 -0
  45. package/dist/releaseNotes.js.map +1 -1
  46. package/dist/schema/blocks.d.ts +3 -0
  47. package/dist/schema/blocks.js +3 -1
  48. package/dist/schema/blocks.js.map +1 -1
  49. package/dist/schema/business.d.ts +1 -0
  50. package/dist/schema/business.js +2 -0
  51. package/dist/schema/business.js.map +1 -1
  52. package/dist/schema/settings.d.ts +2 -0
  53. package/dist/schema/siteConfig.d.ts +1 -0
  54. package/dist/styles.css +32 -4
  55. package/dist/version.js +1 -1
  56. package/package.json +1 -1
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/admin/shop/ShopSettingsTab.tsx"],"sourcesContent":["\"use client\";\n\nimport { useEffect, useState } from \"react\";\nimport { doc, getDoc } from \"firebase/firestore\";\nimport { getDb, getFirebaseApp } from \"../../firebase/client\";\nimport { callable } from \"../../firebase/functionsClient\";\nimport { Button, Card, Field, Input, Textarea } from \"../ui/primitives\";\nimport { Switch } from \"../ui/switch\";\nimport { useToast } from \"../ui/toast\";\nimport { centsToEuroInput, eurosToCents } from \"./lib\";\n\n/**\n * Winkel-instellingen — leest/schrijft DEZELFDE module-config als Instellingen → Modules\n * (settings/public.modules.shop.config; geen tweede bron van waarheid), maar met winkel-\n * presentatie: bedragen in euro's en uitleg per veld. Siteadmin-only (rules).\n */\ninterface ShopCfg {\n shippingFlatCents?: number;\n shippingFreeFromCents?: number;\n orderPrefix?: string;\n currency?: string;\n businessName?: string;\n businessAddress?: string;\n businessZip?: string;\n businessCity?: string;\n businessKvk?: string;\n businessBtw?: string;\n giftWrapEnabled?: boolean;\n giftWrapPriceCents?: number;\n pickupEnabled?: boolean;\n pickupNote?: string;\n [k: string]: unknown;\n}\n\nexport function ShopSettingsTab({ siteId }: { siteId: string }) {\n const { toast } = useToast();\n const [cfg, setCfg] = useState<ShopCfg | null>(null);\n const [shipInput, setShipInput] = useState(\"\");\n const [freeInput, setFreeInput] = useState(\"\");\n const [giftWrapInput, setGiftWrapInput] = useState(\"\");\n const [busy, setBusy] = useState(false);\n const [zones, setZones] = useState<{ naam: string; landen: string; flat: string; free: string }[]>([]);\n\n useEffect(() => {\n const db = getDb();\n if (!db) return;\n void getDoc(doc(db, \"sites\", siteId, \"settings\", \"public\")).then((snap) => {\n const modules = (snap.data()?.modules ?? {}) as Record<string, { config?: ShopCfg } | boolean>;\n const entry = modules.shop;\n const c = typeof entry === \"object\" && entry !== null ? (entry.config ?? {}) : {};\n setCfg(c);\n setShipInput(centsToEuroInput(c.shippingFlatCents ?? 495));\n setFreeInput(c.shippingFreeFromCents ? centsToEuroInput(c.shippingFreeFromCents) : \"\");\n setGiftWrapInput(centsToEuroInput(c.giftWrapPriceCents ?? 0));\n setZones(\n ((c.shippingZones as { naam: string; landen?: string[]; flatCents: number; freeFromCents?: number }[] | undefined) ?? []).map((z) => ({\n naam: z.naam,\n landen: (z.landen ?? []).join(\", \"),\n flat: centsToEuroInput(z.flatCents),\n free: z.freeFromCents ? centsToEuroInput(z.freeFromCents) : \"\",\n })),\n );\n }).catch((e) => {\n // 8.2: zonder catch bleef dit tabblad eeuwig op \"Laden…\" staan bij een leesfout.\n console.warn(\"[xicms] winkelinstellingen niet geladen:\", (e as Error).message);\n setCfg({});\n });\n }, [siteId]);\n\n async function save() {\n const app = getFirebaseApp();\n if (!app || !cfg) return;\n setBusy(true);\n try {\n const next: ShopCfg = {\n ...cfg,\n shippingFlatCents: eurosToCents(shipInput) ?? 495,\n shippingFreeFromCents: freeInput.trim() === \"\" ? 0 : (eurosToCents(freeInput) ?? 0),\n giftWrapPriceCents: eurosToCents(giftWrapInput) ?? 0,\n shippingZones: zones\n .filter((z) => z.naam.trim())\n .map((z) => ({\n naam: z.naam.trim(),\n // \"BE, DE\" → [\"BE\",\"DE\"]; leeg = de \"overige landen\"-zone.\n landen: z.landen.split(/[,;\\s]+/).map((l) => l.trim().toUpperCase()).filter(Boolean).slice(0, 60),\n flatCents: eurosToCents(z.flat) ?? 0,\n freeFromCents: z.free.trim() === \"\" ? 0 : (eurosToCents(z.free) ?? 0),\n })),\n };\n /**\n * Via de saveSiteSettings-callable, NIET rechtstreeks naar Firestore. Deze tab deed nog een\n * directe setDoc op settings/public, maar die deur zit sinds de settings-verstrakking in de\n * rules voor iederéén dicht (ook superadmin) — de klant kreeg letterlijk \"Missing or\n * insufficient permissions\" bij het opslaan van verzendkosten. De callable is bovendien de\n * enige route die site/public consistent houdt én de cache-tag pusht, zodat de nieuwe\n * verzendkosten binnen seconden op de live winkel staan i.p.v. na het ISR-vangnet.\n * set+merge in de callable merged diep: de enabled-vlag en andere modules blijven staan.\n */\n const fn = callable(app, \"saveSiteSettings\");\n await fn({ siteId, settings: { modules: { shop: { config: next } } } });\n setCfg(next);\n toast({ title: \"Instellingen opgeslagen\", tone: \"success\" });\n } catch (e) {\n toast({ title: \"Opslaan mislukt\", description: e instanceof Error ? e.message : undefined, tone: \"error\" });\n } finally {\n setBusy(false);\n }\n }\n\n if (!cfg) return <Card><p className=\"text-sm text-slate-500\">Laden…</p></Card>;\n\n const set = (k: string, v: string) => setCfg((p) => ({ ...p, [k]: v }));\n\n /** Wettelijk minimum op een factuur: wie ben je, waar zit je, en onder welke nummers. */\n const ontbrekend = (\n [\n [\"bedrijfsnaam\", cfg.businessName],\n [\"adres\", cfg.businessAddress],\n [\"postcode\", cfg.businessZip],\n [\"plaats\", cfg.businessCity],\n [\"KVK-nummer\", cfg.businessKvk],\n [\"btw-nummer\", cfg.businessBtw],\n ] as [string, unknown][]\n )\n .filter(([, v]) => !String(v ?? \"\").trim())\n .map(([label]) => label);\n\n return (\n <div className=\"grid gap-4 lg:grid-cols-2\">\n <Card>\n <h3 className=\"mb-3 text-sm font-semibold\">Verzending & bestellen</h3>\n <div className=\"grid gap-4\">\n <Field label=\"Verzendkosten (€)\" hint=\"Vast bedrag per bestelling binnen Nederland\">\n <Input value={shipInput} onChange={(e) => setShipInput(e.target.value)} placeholder=\"4,95\" className=\"w-32\" />\n </Field>\n <Field label=\"Gratis verzending vanaf (€)\" hint=\"Leeg = nooit gratis\">\n <Input value={freeInput} onChange={(e) => setFreeInput(e.target.value)} placeholder=\"50,00\" className=\"w-32\" />\n </Field>\n <Field label=\"Ordernummer-prefix\" hint=\"Bijv. CKC → CKC-2026-0001\">\n <Input value={String(cfg.orderPrefix ?? \"ORDER\")} onChange={(e) => set(\"orderPrefix\", e.target.value.toUpperCase().slice(0, 12))} className=\"w-32\" />\n </Field>\n\n {/* Zones: pas nodig zodra je buiten Nederland verzendt. Zonder zones geldt het tarief\n hierboven voor alles — precies zoals het altijd werkte. */}\n <div className=\"border-t border-slate-100 pt-4\">\n <p className=\"text-sm font-medium text-slate-800\">Verzenden naar het buitenland</p>\n <p className=\"mt-1 mb-3 text-xs text-slate-500\">\n Zonder zones geldt het tarief hierboven voor élke bestelling. Voeg een zone toe om per\n land een eigen tarief te rekenen. Een zone <strong>zonder landen</strong> is \"alle\n overige landen\"; laat je die weg, dan verzend je alléén naar de genoemde landen — de\n afrekenpagina toont dan precies die keuze.\n </p>\n <div className=\"grid gap-2\">\n {zones.map((z, i) => {\n const zet = (patch: Partial<(typeof zones)[number]>) =>\n setZones((p) => p.map((x, j) => (j === i ? { ...x, ...patch } : x)));\n return (\n <div key={i} className=\"grid grid-cols-[1fr_1fr_90px_90px_auto] items-center gap-2\">\n <Input value={z.naam} onChange={(e) => zet({ naam: e.target.value })} placeholder=\"Naam (bv. België)\" />\n <Input value={z.landen} onChange={(e) => zet({ landen: e.target.value })} placeholder=\"BE, DE (leeg = overige)\" />\n <Input value={z.flat} onChange={(e) => zet({ flat: e.target.value })} placeholder=\"Tarief €\" />\n <Input value={z.free} onChange={(e) => zet({ free: e.target.value })} placeholder=\"Gratis v.a. €\" />\n <Button size=\"sm\" variant=\"ghost\" onClick={() => setZones((p) => p.filter((_, j) => j !== i))}>✕</Button>\n </div>\n );\n })}\n <div>\n <Button size=\"sm\" variant=\"secondary\" disabled={zones.length >= 10} onClick={() => setZones((p) => [...p, { naam: \"\", landen: \"\", flat: \"\", free: \"\" }])}>\n + Zone\n </Button>\n </div>\n </div>\n </div>\n </div>\n </Card>\n\n <Card>\n <h3 className=\"mb-3 text-sm font-semibold\">Afhalen</h3>\n <div className=\"grid gap-4\">\n <Switch\n checked={cfg.pickupEnabled ?? false}\n onCheckedChange={(v) => setCfg((p) => ({ ...p, pickupEnabled: v }))}\n label=\"Klanten kunnen kiezen voor afhalen (geen verzendkosten)\"\n />\n {cfg.pickupEnabled && (\n <Field label=\"Toelichting bij afhalen\" hint=\"Bijv. afhaaladres en tijden. De klant ziet dit bij het afrekenen en in de bevestigingsmail.\">\n <Textarea\n rows={4}\n maxLength={1000}\n value={String(cfg.pickupNote ?? \"\")}\n onChange={(e) => set(\"pickupNote\", e.target.value)}\n placeholder=\"Afhalen kan op afspraak aan de Voorbeeldstraat 1 in Plaats. Je krijgt een mail zodra je bestelling klaarstaat.\"\n />\n </Field>\n )}\n </div>\n </Card>\n\n <Card>\n <h3 className=\"mb-3 text-sm font-semibold\">Cadeauverpakking</h3>\n <div className=\"grid gap-4\">\n <Switch\n checked={cfg.giftWrapEnabled ?? false}\n onCheckedChange={(v) => setCfg((p) => ({ ...p, giftWrapEnabled: v }))}\n label=\"Cadeauverpakking aanbieden bij het afrekenen\"\n />\n {cfg.giftWrapEnabled && (\n <Field label=\"Meerprijs (€)\" hint=\"Komt als aparte regel op de bestelling en de factuur; de klant kan een kaartjestekst + opmerking meegeven\">\n <Input value={giftWrapInput} onChange={(e) => setGiftWrapInput(e.target.value)} placeholder=\"3,95\" className=\"w-32\" />\n </Field>\n )}\n </div>\n </Card>\n\n <Card>\n <h3 className=\"mb-1 text-sm font-semibold\">Bedrijfsgegevens (factuur)</h3>\n <p className=\"mb-3 text-xs text-slate-500\">Deze gegevens komen op de factuur-PDF die klanten bij hun bestelling krijgen.</p>\n {/* Zonder deze gegevens gaat er wél een factuur uit, maar zonder afzender — en een factuur\n zonder bedrijfsnaam, adres en KVK/btw is fiscaal niet geldig. Dat gebeurde stil. */}\n {ontbrekend.length > 0 && (\n <p className=\"mb-3 rounded-lg bg-amber-50 px-3 py-2 text-sm text-amber-800\">\n <strong>Nog invullen:</strong> {ontbrekend.join(\", \")}. Zonder deze gegevens staat er geen\n afzender op je factuur, en dan is hij voor de Belastingdienst niet geldig.\n </p>\n )}\n <div className=\"grid gap-3\">\n <Field label=\"Bedrijfsnaam\"><Input value={String(cfg.businessName ?? \"\")} onChange={(e) => set(\"businessName\", e.target.value)} /></Field>\n <Field label=\"Adres\"><Input value={String(cfg.businessAddress ?? \"\")} onChange={(e) => set(\"businessAddress\", e.target.value)} /></Field>\n <div className=\"grid grid-cols-[120px_1fr] gap-3\">\n <Field label=\"Postcode\"><Input value={String(cfg.businessZip ?? \"\")} onChange={(e) => set(\"businessZip\", e.target.value)} /></Field>\n <Field label=\"Plaats\"><Input value={String(cfg.businessCity ?? \"\")} onChange={(e) => set(\"businessCity\", e.target.value)} /></Field>\n </div>\n <div className=\"grid grid-cols-2 gap-3\">\n <Field label=\"KVK-nummer\"><Input value={String(cfg.businessKvk ?? \"\")} onChange={(e) => set(\"businessKvk\", e.target.value)} /></Field>\n <Field label=\"Btw-nummer\"><Input value={String(cfg.businessBtw ?? \"\")} onChange={(e) => set(\"businessBtw\", e.target.value)} /></Field>\n </div>\n <div className=\"grid grid-cols-2 gap-3\">\n <Field label=\"Contact-e-mail\" hint=\"Staat op de factuur en onderaan elke mail\">\n <Input value={String(cfg.businessEmail ?? \"\")} onChange={(e) => set(\"businessEmail\", e.target.value)} />\n </Field>\n <Field label=\"Telefoon (optioneel)\">\n <Input value={String(cfg.businessPhone ?? \"\")} onChange={(e) => set(\"businessPhone\", e.target.value)} />\n </Field>\n </div>\n </div>\n </Card>\n\n <div className=\"lg:col-span-2\">\n <Button onClick={save} disabled={busy}>Opslaan</Button>\n <p className=\"mt-2 text-xs text-slate-500\">\n Paden en valuta van de winkel staan onder Instellingen → Modules → Webwinkel.\n </p>\n </div>\n </div>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../../src/admin/shop/ShopSettingsTab.tsx"],"sourcesContent":["\"use client\";\n\nimport { useEffect, useState } from \"react\";\nimport { doc, getDoc } from \"firebase/firestore\";\nimport { getDb, getFirebaseApp } from \"../../firebase/client\";\nimport { callable } from \"../../firebase/functionsClient\";\nimport { Button, Card, Field, Input, Textarea } from \"../ui/primitives\";\nimport { Switch } from \"../ui/switch\";\nimport { useToast } from \"../ui/toast\";\nimport { centsToEuroInput, eurosToCents } from \"./lib\";\nimport type { Business } from \"../../schema/business\";\n\n/**\n * Winkel-instellingen — leest/schrijft DEZELFDE module-config als Instellingen → Modules\n * (settings/public.modules.shop.config; geen tweede bron van waarheid), maar met winkel-\n * presentatie: bedragen in euro's en uitleg per veld. Siteadmin-only (rules).\n */\ninterface ShopCfg {\n shippingFlatCents?: number;\n shippingFreeFromCents?: number;\n orderPrefix?: string;\n currency?: string;\n businessName?: string;\n businessAddress?: string;\n businessZip?: string;\n businessCity?: string;\n businessKvk?: string;\n businessBtw?: string;\n giftWrapEnabled?: boolean;\n giftWrapPriceCents?: number;\n pickupEnabled?: boolean;\n pickupNote?: string;\n [k: string]: unknown;\n}\n\nexport function ShopSettingsTab({ siteId }: { siteId: string }) {\n const { toast } = useToast();\n const [cfg, setCfg] = useState<ShopCfg | null>(null);\n const [shipInput, setShipInput] = useState(\"\");\n const [freeInput, setFreeInput] = useState(\"\");\n const [giftWrapInput, setGiftWrapInput] = useState(\"\");\n const [busy, setBusy] = useState(false);\n const [zones, setZones] = useState<{ naam: string; landen: string; flat: string; free: string }[]>([]);\n const [profiel, setProfiel] = useState<Business>({});\n\n useEffect(() => {\n const db = getDb();\n if (!db) return;\n void getDoc(doc(db, \"sites\", siteId, \"settings\", \"public\")).then((snap) => {\n const modules = (snap.data()?.modules ?? {}) as Record<string, { config?: ShopCfg } | boolean>;\n setProfiel((snap.data()?.business ?? {}) as Business);\n const entry = modules.shop;\n const c = typeof entry === \"object\" && entry !== null ? (entry.config ?? {}) : {};\n setCfg(c);\n setShipInput(centsToEuroInput(c.shippingFlatCents ?? 495));\n setFreeInput(c.shippingFreeFromCents ? centsToEuroInput(c.shippingFreeFromCents) : \"\");\n setGiftWrapInput(centsToEuroInput(c.giftWrapPriceCents ?? 0));\n setZones(\n ((c.shippingZones as { naam: string; landen?: string[]; flatCents: number; freeFromCents?: number }[] | undefined) ?? []).map((z) => ({\n naam: z.naam,\n landen: (z.landen ?? []).join(\", \"),\n flat: centsToEuroInput(z.flatCents),\n free: z.freeFromCents ? centsToEuroInput(z.freeFromCents) : \"\",\n })),\n );\n }).catch((e) => {\n // 8.2: zonder catch bleef dit tabblad eeuwig op \"Laden…\" staan bij een leesfout.\n console.warn(\"[xicms] winkelinstellingen niet geladen:\", (e as Error).message);\n setCfg({});\n });\n }, [siteId]);\n\n async function save() {\n const app = getFirebaseApp();\n if (!app || !cfg) return;\n setBusy(true);\n try {\n const next: ShopCfg = {\n ...cfg,\n shippingFlatCents: eurosToCents(shipInput) ?? 495,\n shippingFreeFromCents: freeInput.trim() === \"\" ? 0 : (eurosToCents(freeInput) ?? 0),\n giftWrapPriceCents: eurosToCents(giftWrapInput) ?? 0,\n shippingZones: zones\n .filter((z) => z.naam.trim())\n .map((z) => ({\n naam: z.naam.trim(),\n // \"BE, DE\" → [\"BE\",\"DE\"]; leeg = de \"overige landen\"-zone.\n landen: z.landen.split(/[,;\\s]+/).map((l) => l.trim().toUpperCase()).filter(Boolean).slice(0, 60),\n flatCents: eurosToCents(z.flat) ?? 0,\n freeFromCents: z.free.trim() === \"\" ? 0 : (eurosToCents(z.free) ?? 0),\n })),\n };\n /**\n * Via de saveSiteSettings-callable, NIET rechtstreeks naar Firestore. Deze tab deed nog een\n * directe setDoc op settings/public, maar die deur zit sinds de settings-verstrakking in de\n * rules voor iederéén dicht (ook superadmin) — de klant kreeg letterlijk \"Missing or\n * insufficient permissions\" bij het opslaan van verzendkosten. De callable is bovendien de\n * enige route die site/public consistent houdt én de cache-tag pusht, zodat de nieuwe\n * verzendkosten binnen seconden op de live winkel staan i.p.v. na het ISR-vangnet.\n * set+merge in de callable merged diep: de enabled-vlag en andere modules blijven staan.\n */\n const fn = callable(app, \"saveSiteSettings\");\n await fn({ siteId, settings: { modules: { shop: { config: next } } } });\n setCfg(next);\n toast({ title: \"Instellingen opgeslagen\", tone: \"success\" });\n } catch (e) {\n toast({ title: \"Opslaan mislukt\", description: e instanceof Error ? e.message : undefined, tone: \"error\" });\n } finally {\n setBusy(false);\n }\n }\n\n if (!cfg) return <Card><p className=\"text-sm text-slate-500\">Laden…</p></Card>;\n\n const set = (k: string, v: string) => setCfg((p) => ({ ...p, [k]: v }));\n\n /**\n * Wettelijk minimum op een factuur: wie ben je, waar zit je, en onder welke nummers. 8.16: een\n * leeg veld hier valt op de factuur terug op Instellingen → Bedrijf (functions `bedrijfVoorMail`),\n * dus telt het pas als ontbrekend als het daar ook leeg is. Het profiel staat als voorbeeldtekst.\n */\n const uitProfiel: Record<string, string | undefined> = {\n businessName: profiel.name,\n businessAddress: profiel.address?.street,\n businessZip: profiel.address?.postalCode,\n businessCity: profiel.address?.city,\n businessKvk: profiel.kvk,\n businessBtw: profiel.vat,\n businessEmail: profiel.email,\n businessPhone: profiel.phone,\n };\n const ontbrekend = (\n [\n [\"bedrijfsnaam\", \"businessName\"],\n [\"adres\", \"businessAddress\"],\n [\"postcode\", \"businessZip\"],\n [\"plaats\", \"businessCity\"],\n [\"KVK-nummer\", \"businessKvk\"],\n [\"btw-nummer\", \"businessBtw\"],\n ] as [string, string][]\n )\n .filter(([, k]) => !String(cfg[k] ?? \"\").trim() && !uitProfiel[k]?.trim())\n .map(([label]) => label);\n const veld = (k: string) => ({ value: String(cfg[k] ?? \"\"), onChange: (e: { target: { value: string } }) => set(k, e.target.value), placeholder: uitProfiel[k] });\n\n return (\n <div className=\"grid gap-4 lg:grid-cols-2\">\n <Card>\n <h3 className=\"mb-3 text-sm font-semibold\">Verzending & bestellen</h3>\n <div className=\"grid gap-4\">\n <Field label=\"Verzendkosten (€)\" hint=\"Vast bedrag per bestelling binnen Nederland\">\n <Input value={shipInput} onChange={(e) => setShipInput(e.target.value)} placeholder=\"4,95\" className=\"w-32\" />\n </Field>\n <Field label=\"Gratis verzending vanaf (€)\" hint=\"Leeg = nooit gratis\">\n <Input value={freeInput} onChange={(e) => setFreeInput(e.target.value)} placeholder=\"50,00\" className=\"w-32\" />\n </Field>\n <Field label=\"Ordernummer-prefix\" hint=\"Bijv. CKC → CKC-2026-0001\">\n <Input value={String(cfg.orderPrefix ?? \"ORDER\")} onChange={(e) => set(\"orderPrefix\", e.target.value.toUpperCase().slice(0, 12))} className=\"w-32\" />\n </Field>\n\n {/* Zones: pas nodig zodra je buiten Nederland verzendt. Zonder zones geldt het tarief\n hierboven voor alles — precies zoals het altijd werkte. */}\n <div className=\"border-t border-slate-100 pt-4\">\n <p className=\"text-sm font-medium text-slate-800\">Verzenden naar het buitenland</p>\n <p className=\"mt-1 mb-3 text-xs text-slate-500\">\n Zonder zones geldt het tarief hierboven voor élke bestelling. Voeg een zone toe om per\n land een eigen tarief te rekenen. Een zone <strong>zonder landen</strong> is \"alle\n overige landen\"; laat je die weg, dan verzend je alléén naar de genoemde landen — de\n afrekenpagina toont dan precies die keuze.\n </p>\n <div className=\"grid gap-2\">\n {zones.map((z, i) => {\n const zet = (patch: Partial<(typeof zones)[number]>) =>\n setZones((p) => p.map((x, j) => (j === i ? { ...x, ...patch } : x)));\n return (\n <div key={i} className=\"grid grid-cols-[1fr_1fr_90px_90px_auto] items-center gap-2\">\n <Input value={z.naam} onChange={(e) => zet({ naam: e.target.value })} placeholder=\"Naam (bv. België)\" />\n <Input value={z.landen} onChange={(e) => zet({ landen: e.target.value })} placeholder=\"BE, DE (leeg = overige)\" />\n <Input value={z.flat} onChange={(e) => zet({ flat: e.target.value })} placeholder=\"Tarief €\" />\n <Input value={z.free} onChange={(e) => zet({ free: e.target.value })} placeholder=\"Gratis v.a. €\" />\n <Button size=\"sm\" variant=\"ghost\" onClick={() => setZones((p) => p.filter((_, j) => j !== i))}>✕</Button>\n </div>\n );\n })}\n <div>\n <Button size=\"sm\" variant=\"secondary\" disabled={zones.length >= 10} onClick={() => setZones((p) => [...p, { naam: \"\", landen: \"\", flat: \"\", free: \"\" }])}>\n + Zone\n </Button>\n </div>\n </div>\n </div>\n </div>\n </Card>\n\n <Card>\n <h3 className=\"mb-3 text-sm font-semibold\">Afhalen</h3>\n <div className=\"grid gap-4\">\n <Switch\n checked={cfg.pickupEnabled ?? false}\n onCheckedChange={(v) => setCfg((p) => ({ ...p, pickupEnabled: v }))}\n label=\"Klanten kunnen kiezen voor afhalen (geen verzendkosten)\"\n />\n {cfg.pickupEnabled && (\n <Field label=\"Toelichting bij afhalen\" hint=\"Bijv. afhaaladres en tijden. De klant ziet dit bij het afrekenen en in de bevestigingsmail.\">\n <Textarea\n rows={4}\n maxLength={1000}\n value={String(cfg.pickupNote ?? \"\")}\n onChange={(e) => set(\"pickupNote\", e.target.value)}\n placeholder=\"Afhalen kan op afspraak aan de Voorbeeldstraat 1 in Plaats. Je krijgt een mail zodra je bestelling klaarstaat.\"\n />\n </Field>\n )}\n </div>\n </Card>\n\n <Card>\n <h3 className=\"mb-3 text-sm font-semibold\">Cadeauverpakking</h3>\n <div className=\"grid gap-4\">\n <Switch\n checked={cfg.giftWrapEnabled ?? false}\n onCheckedChange={(v) => setCfg((p) => ({ ...p, giftWrapEnabled: v }))}\n label=\"Cadeauverpakking aanbieden bij het afrekenen\"\n />\n {cfg.giftWrapEnabled && (\n <Field label=\"Meerprijs (€)\" hint=\"Komt als aparte regel op de bestelling en de factuur; de klant kan een kaartjestekst + opmerking meegeven\">\n <Input value={giftWrapInput} onChange={(e) => setGiftWrapInput(e.target.value)} placeholder=\"3,95\" className=\"w-32\" />\n </Field>\n )}\n </div>\n </Card>\n\n <Card>\n <h3 className=\"mb-1 text-sm font-semibold\">Bedrijfsgegevens (factuur)</h3>\n <p className=\"mb-3 text-xs text-slate-500\">\n Deze gegevens komen op de factuur-PDF die klanten bij hun bestelling krijgen. Laat je een veld leeg, dan\n gebruiken we wat er bij Instellingen → Bedrijf staat (grijs voorgevuld).\n </p>\n {/* Zonder deze gegevens gaat er wél een factuur uit, maar zonder afzender — en een factuur\n zonder bedrijfsnaam, adres en KVK/btw is fiscaal niet geldig. Dat gebeurde stil. */}\n {ontbrekend.length > 0 && (\n <p className=\"mb-3 rounded-lg bg-amber-50 px-3 py-2 text-sm text-amber-800\">\n <strong>Nog invullen:</strong> {ontbrekend.join(\", \")}. Zonder deze gegevens staat er geen\n afzender op je factuur, en dan is hij voor de Belastingdienst niet geldig.\n </p>\n )}\n <div className=\"grid gap-3\">\n <Field label=\"Bedrijfsnaam\"><Input {...veld(\"businessName\")} /></Field>\n <Field label=\"Adres\"><Input {...veld(\"businessAddress\")} /></Field>\n <div className=\"grid grid-cols-[120px_1fr] gap-3\">\n <Field label=\"Postcode\"><Input {...veld(\"businessZip\")} /></Field>\n <Field label=\"Plaats\"><Input {...veld(\"businessCity\")} /></Field>\n </div>\n <div className=\"grid grid-cols-2 gap-3\">\n <Field label=\"KVK-nummer\"><Input {...veld(\"businessKvk\")} /></Field>\n <Field label=\"Btw-nummer\"><Input {...veld(\"businessBtw\")} /></Field>\n </div>\n <div className=\"grid grid-cols-2 gap-3\">\n <Field label=\"Contact-e-mail\" hint=\"Staat op de factuur en onderaan elke mail\">\n <Input {...veld(\"businessEmail\")} />\n </Field>\n <Field label=\"Telefoon (optioneel)\">\n <Input {...veld(\"businessPhone\")} />\n </Field>\n </div>\n </div>\n </Card>\n\n <div className=\"lg:col-span-2\">\n <Button onClick={save} disabled={busy}>Opslaan</Button>\n <p className=\"mt-2 text-xs text-slate-500\">\n Paden en valuta van de winkel staan onder Instellingen → Modules → Webwinkel.\n </p>\n </div>\n </div>\n );\n}\n"],"mappings":"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","names":[]}
@@ -120,6 +120,7 @@ function AuthProvider({
120
120
  }
121
121
  unsub = sdk.onAuthStateChanged(auth, async (u) => {
122
122
  setUser(u);
123
+ if (u) void import("../firebase/appcheck.js").then((m) => m.initAppCheck());
123
124
  if (u) setAuthHint(true);
124
125
  await loadClaims(u);
125
126
  setLoading(false);
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/auth/AuthProvider.tsx"],"sourcesContent":["\"use client\";\n\nimport {\n createContext,\n useContext,\n useEffect,\n useMemo,\n useState,\n type ReactNode,\n} from \"react\";\nimport type { User } from \"firebase/auth\";\nimport { hasAuthHint, isFirebaseConfigured, setAuthHint } from \"../firebase/config\";\nimport { roleForSite, type Role, type XiClaims } from \"./roles\";\nimport { DEMO_EVENT, isDemoLoggedIn, setDemoLoggedIn } from \"../demo/demoStore\";\n\ninterface AuthState {\n /** Of Firebase überhaupt geconfigureerd is. */\n configured: boolean;\n /** Bezig met laden van de auth-status. */\n loading: boolean;\n user: User | null;\n claims: XiClaims | null;\n /** Effectieve rol op de huidige site (uit `siteId`). */\n role: Role | null;\n signIn: (email: string, password: string) => Promise<void>;\n /** Inloggen met Google (popup). */\n signInWithGoogle: () => Promise<void>;\n /**\n * Stuur een inloglink naar dit adres (magic link). Geen wachtwoord, dus ook niets te vergeten —\n * en anders dan de Google-flow raakt dit geen enkel domein van een derde partij, waardoor het\n * werkt in browsers die opslag van derden blokkeren.\n */\n sendLoginLink: (email: string) => Promise<void>;\n /**\n * Er staat een geldige inloglink in de adresbalk, maar het bijbehorende adres is hier niet\n * bekend — de link is op een ander apparaat geopend dan waar hij is aangevraagd. Het\n * inlogscherm vraagt er dan om; zonder die vraag zou een geldige link doodlopen.\n */\n loginLinkNeedsEmail: boolean;\n /** De link inwisselen met een handmatig opgegeven adres (zie `loginLinkNeedsEmail`). */\n completeLoginLink: (email: string) => Promise<void>;\n /** Ging het inwisselen van de link mis (verlopen, al gebruikt, ander adres)? */\n loginLinkError: unknown;\n signOut: () => Promise<void>;\n /** Forceer het opnieuw inlezen van claims (na een rolwijziging). */\n refresh: () => Promise<void>;\n /** Demo-bewerkmodus actief (config.demoEditing) — pseudo-login, niets wordt opgeslagen. */\n demo: boolean;\n /** Pseudo-login voor de demo: geeft lokaal een siteadmin-rol zonder Firebase. */\n demoSignIn: () => void;\n}\n\nconst AuthContext = createContext<AuthState | null>(null);\n\n/**\n * Het adres waar de inloglink heen ging, bewaard tot de klik terugkomt.\n *\n * Firebase eist bij het afronden hetzelfde adres als waar de link naartoe is gestuurd — dat is de\n * hele beveiliging van deze methode: wie de link onderschept maar het adres niet weet, komt er\n * niet in. Staat het er niet (link geopend op een ander apparaat, of opslag geweigerd), dan vraagt\n * het inlogscherm er alsnog om in plaats van te falen.\n */\nconst LINK_EMAIL_KEY = \"xicms.loginlink.email\";\n\n/**\n * Is deze URL een inloglink? Bewust ZONDER de SDK: deze vraag bepaalt óf we de auth-SDK van ~1 MB\n * gaan laden, dus hem beantwoorden met de SDK zou de besparing meteen opeten. Firebase hangt de\n * link altijd op aan `mode=signIn` met een `oobCode`; dat is genoeg voor de voorselectie, waarna\n * `isSignInWithEmailLink` het echte oordeel velt.\n */\nfunction lijktOpInloglink(): boolean {\n if (typeof window === \"undefined\") return false;\n const q = window.location.search;\n return q.includes(\"mode=signIn\") && q.includes(\"oobCode=\");\n}\n\nexport function AuthProvider({\n siteId,\n demo = false,\n children,\n}: {\n siteId: string;\n demo?: boolean;\n children: ReactNode;\n}) {\n const configured = isFirebaseConfigured();\n const [loading, setLoading] = useState(configured);\n const [user, setUser] = useState<User | null>(null);\n const [claims, setClaims] = useState<XiClaims | null>(null);\n const [demoLoggedIn, setDemoLoggedInState] = useState(false);\n // Er staat een geldige inloglink in de adresbalk, maar we weten niet bij welk adres hij hoort.\n const [linkVraagtEmail, setLinkVraagtEmail] = useState(false);\n const [linkFout, setLinkFout] = useState<unknown>(null);\n // Alleen om het abonnement-effect opnieuw te laten draaien na een login zonder paginawissel.\n const [wakker, setWakker] = useState(0);\n\n /**\n * Na een geslaagde link-login: het bewaarde adres weg, en de `oobCode` uit de adresbalk.\n *\n * Die code is eenmalig, maar hij blijft wél in de geschiedenis en in gedeelde/gekopieerde URL's\n * staan. Hem laten staan levert bovendien een verwarrende herhaling op: één keer verversen en de\n * SDK probeert een verbruikte code opnieuw in te wisselen, met een foutmelding terwijl je gewoon\n * ingelogd bent. `replaceState` haalt hem weg zonder de pagina opnieuw te laden.\n */\n function opruimenNaLinkLogin(): void {\n try {\n window.localStorage.removeItem(LINK_EMAIL_KEY);\n } catch {\n /* niets bewaard, niets op te ruimen */\n }\n setLinkVraagtEmail(false);\n setLinkFout(null);\n try {\n window.history.replaceState({}, \"\", window.location.pathname + window.location.hash);\n } catch {\n /* geschiedenis niet bewerkbaar: onschuldig, de login is al gelukt */\n }\n }\n\n /**\n * Zet de sessie-hint ÉN maak het effect hieronder wakker.\n *\n * Dat tweede is de kern van een bug die zich als \"ik kom er niet in\" voordeed. Het effect dat\n * `onAuthStateChanged` abonneert draait alleen als er bij het LADEN van de pagina al een hint is,\n * en hangt af van `[configured]` — een hint die later wordt gezet laat het dus niet opnieuw\n * draaien. Gevolg: log je in zónder paginawissel (popup of wachtwoord), dan maakt Firebase keurig\n * een sessie, maar luistert er niemand. React hoort het nooit en het inlogscherm blijft staan.\n *\n * Dat het toch vaak leek te werken kwam door de redirect-terugval: die verlaat de pagina en komt\n * terug op een verse load, waar de hint er wél staat. Precies daarom viel dit pas op ná het\n * repareren van de popup — toen verdween de herlaad die het gebrek maskeerde.\n *\n * De teller hoeft nergens te worden uitgelezen; hij bestaat alleen om de afhankelijkheidslijst\n * te laten wijzigen.\n */\n function wekOp(): void {\n setAuthHint(true);\n setWakker((n) => n + 1);\n }\n\n // Demo-login uit de (sessie)opslag inlezen + meeluisteren met wijzigingen (login/logout/reset).\n useEffect(() => {\n if (!demo) return;\n const sync = () => setDemoLoggedInState(isDemoLoggedIn());\n sync();\n window.addEventListener(DEMO_EVENT, sync);\n return () => window.removeEventListener(DEMO_EVENT, sync);\n }, [demo]);\n\n /**\n * Meteen weer uitloggen als dit account geen toegang heeft tot DEZE site.\n *\n * Bij Google kun je niet vooraf weten wie er inlogt — je krijgt het adres pas ná de\n * accountkeuze. Zonder deze controle komt een willekeurige Google-gebruiker binnen met een\n * geldige sessie, ziet hij \"je hebt geen beheerrechten\", en blijft hij ingelogd op een site\n * waar hij niets te zoeken heeft. Dat is niet gevaarlijk (de rules kijken naar claims), maar\n * het is wel een half-open deur en het leest als een fout in plaats van als een grens.\n *\n * `forceRefresh` is nodig: het token dat je meteen na het inloggen krijgt kan nog van vóór een\n * recente rolwijziging zijn, en dan zou een net toegevoegde beheerder onterecht worden\n * geweigerd.\n */\n async function weigerZonderToegang(\n auth: { currentUser: User | null; },\n sdk: { signOut: (a: never) => Promise<void> },\n ): Promise<void> {\n const u = auth.currentUser;\n if (!u) return;\n const token = await u.getIdTokenResult(true);\n const c = token.claims as { superadmin?: boolean; roles?: Record<string, string> };\n const rol = c.roles?.[siteId];\n if (c.superadmin === true || rol === \"siteadmin\" || rol === \"editor\") return;\n\n await sdk.signOut(auth as never);\n const fout = new Error(\"geen-toegang\") as Error & { code?: string };\n fout.code = \"xicms/geen-toegang\";\n throw fout;\n }\n\n /** Laadt de auth-SDK pas wanneer er iets mee moet gebeuren (inloggen, uitloggen, verversen). */\n async function authSdk() {\n const [client, sdk] = await Promise.all([import(\"../firebase/client\"), import(\"firebase/auth\")]);\n const auth = client.getFirebaseAuth();\n if (!auth) throw new Error(\"Firebase is niet geconfigureerd.\");\n return { auth, sdk };\n }\n\n async function loadClaims(u: User | null, forceRefresh = false) {\n if (!u) {\n setClaims(null);\n return;\n }\n // Standaard GEEN geforceerde token-refresh: dat is een netwerk-roundtrip op élke\n // auth-state-change (incl. eerste load van elke ingelogde editor). Alleen refresh() —\n // na een rolwijziging — forceert, want dan is het gecachte token per definitie verouderd.\n const token = await u.getIdTokenResult(forceRefresh);\n setClaims({\n superadmin: token.claims.superadmin === true,\n roles: (token.claims.roles as XiClaims[\"roles\"]) ?? {},\n });\n }\n\n useEffect(() => {\n // Zonder sessie-hint laden we de auth-SDK NIET: een anonieme bezoeker hoeft ~1 MB niet te\n // downloaden om te ontdekken dat hij niet is ingelogd. Zodra hij inlogt of het beheer opent\n // komt de SDK alsnog, en vindt onAuthStateChanged een bestaande sessie meteen terug.\n // …behalve als dit een inloglink IS. Wie op de link in zijn mail klikt komt per definitie\n // zonder sessie-hint binnen — vaak zelfs in een andere browser dan waar hij hem aanvroeg.\n // Zonder deze uitzondering laadt de SDK niet, wordt de link nooit ingewisseld en belandt hij\n // op het inlogscherm met de geldige link ongebruikt in de adresbalk.\n if (!configured || (!hasAuthHint() && !lijktOpInloglink())) {\n setLoading(false);\n return;\n }\n let unsub: (() => void) | undefined;\n let afgebroken = false;\n void (async () => {\n const { auth, sdk } = await authSdk().catch(() => ({ auth: null, sdk: null }) as never);\n if (!auth || afgebroken) {\n setLoading(false);\n return;\n }\n // Terugkeer van een redirect-login afronden (zie signInWithGoogle). `onAuthStateChanged`\n // pikt de sessie zelf al op; deze aanroep is er om een FOUT bij terugkomst niet stil te\n // laten verdwijnen — anders sta je weer op het inlogscherm zonder te weten waarom.\n // Sinds redirect de primaire flow is (7.16) hoort hier ook de site-toegangscontrole die het\n // popup-pad na afloop deed: een geldig Google-account is niet hetzelfde als toegang tot\n // DEZE site (weigerZonderToegang logt dan meteen weer uit).\n void sdk\n .getRedirectResult(auth)\n .then(async (res) => {\n if (res?.user) await weigerZonderToegang(auth, sdk);\n })\n .catch((e: unknown) => {\n console.error(\"[xicms] inloggen via redirect mislukt:\", e);\n });\n\n // Een aangeklikte inloglink meteen inwisselen. Het adres staat lokaal opgeslagen sinds het\n // versturen; ontbreekt het — link geopend op een ander apparaat, of opslag geweigerd — dan\n // laten we de link stáán en vraagt het inlogscherm erom. Weggooien zou de gebruiker terug\n // naar zijn mailbox sturen voor een link die hij al heeft.\n if (sdk.isSignInWithEmailLink(auth, window.location.href)) {\n let bewaard: string | null = null;\n try {\n bewaard = window.localStorage.getItem(LINK_EMAIL_KEY);\n } catch {\n /* opslag geweigerd → hieronder vragen we het adres gewoon */\n }\n if (bewaard) {\n try {\n await sdk.signInWithEmailLink(auth, bewaard, window.location.href);\n opruimenNaLinkLogin();\n } catch (e) {\n console.error(\"[xicms] inloggen via link mislukt:\", e);\n setLinkFout(e);\n }\n } else if (!afgebroken) {\n setLinkVraagtEmail(true);\n }\n }\n\n unsub = sdk.onAuthStateChanged(auth, async (u: User | null) => {\n setUser(u);\n /**\n * Hint alleen ZETTEN bij een sessie — nooit wissen op `null`.\n *\n * `null` betekent hier niet \"uitgelogd\" maar \"(nog) geen sessie\", en dat is óók de\n * toestand vlak vóór een redirect-login: signInWithGoogle zet de hint, en deze listener\n * vuurde daarna met `null` en WISTE hem weer — nog vóór de navigatie naar Google. Terug\n * op de site weigerde het effect hierboven daardoor de auth-SDK te laden, werd\n * getRedirectResult nooit gedraaid en bleef het antwoord van Google onaangeroerd in\n * localStorage liggen (`firebase:authEvent:…` aangetroffen naast een ontbrekende hint,\n * live op christelijkkraamcadeau; hint handmatig terugzetten + herladen logde alsnog in).\n * Wissen hoort alleen bij expliciet uitloggen — dat doet signOut al.\n */\n if (u) setAuthHint(true);\n await loadClaims(u);\n setLoading(false);\n });\n })();\n return () => {\n afgebroken = true;\n unsub?.();\n };\n }, [configured, wakker]);\n\n const value = useMemo<AuthState>(() => {\n // In de demo met actieve pseudo-login: doe alsof een siteadmin is ingelogd (lokaal, geen Firebase).\n const demoActiveLogin = demo && demoLoggedIn;\n const effectiveUser = demoActiveLogin\n ? ({ uid: \"demo\", email: \"demo@voorbeeld.nl\", displayName: \"Demo-beheerder\" } as unknown as User)\n : user;\n const effectiveClaims: XiClaims | null = demoActiveLogin ? { roles: { [siteId]: \"siteadmin\" } } : claims;\n\n return {\n configured,\n loading,\n user: effectiveUser,\n claims: effectiveClaims,\n role: roleForSite(effectiveClaims, siteId),\n async signIn(email, password) {\n const { auth, sdk } = await authSdk();\n await sdk.signInWithEmailAndPassword(auth, email, password);\n // Zelfde grens als bij Google: een geldig wachtwoord is niet hetzelfde als toegang tot\n // DEZE site. Zonder deze regel zou de terugval een ruimere deur zijn dan de hoofdingang.\n await weigerZonderToegang(auth, sdk);\n wekOp();\n },\n async signInWithGoogle() {\n const { auth, sdk } = await authSdk();\n const provider = new sdk.GoogleAuthProvider();\n /**\n * Abonneren VÓÓR de popup opengaat, niet erna.\n *\n * `signInWithPopup` lost pas op als Firebase de hele tokenuitwisseling heeft afgerond —\n * `signInWithIdp`, dan `lookup` — en pas dán sluit de handler zijn eigen venster. Wachtte de\n * UI op die belofte, dan bleef het inlogscherm al die tijd staan terwijl de sessie er vaak\n * al lang was. Voor de gebruiker las dat als \"er gebeurt niets\".\n *\n * Met de luisteraar al actief reageert het scherm op het moment dat de sessie ontstaat, niet\n * op het moment dat Firebase klaar is met opruimen. Het venster sluit daarna alsnog wat later\n * — dat is Firebase' eigen tijd en die willen we niet inkorten, want dan sluiten we het\n * midden in de tokenuitwisseling.\n */\n wekOp();\n /**\n * SAME-ORIGIN-SETUP → REDIRECT ALS PRIMAIRE FLOW (7.16).\n *\n * De popup-flow is met COOP structureel onbetrouwbaar geworden, en dat is niet met onze\n * eigen headers te repareren. Gemeten op christelijkkraamcadeau mét de\n * `same-origin-allow-popups`-header correct live (curl bevestigd, ook op de handler):\n * `signInWithPopup` faalde alsnog met \"auth/popup-closed-by-user\" terwijl het venster\n * gewoon openstond — Google's eigen COOP op accounts.google.com knipt de band tussen\n * opener en popup door, waarna `popup.closed` vals `true` rapporteert. De handler maakt\n * de login ondertussen gewoon af (de sessie komt via onAuthStateChanged alsnog binnen),\n * maar niemand kan het venster meer sluiten: een wees-tab met de handler-URL blijft\n * achter. Precies zo gerapporteerd door de beheerder.\n *\n * Met een same-origin authDomain (de standaard-setup, zie next/authRewrites.ts) is de\n * redirect-flow de betrouwbare route: geen popup, geen COOP, geen wees-venster — de\n * pagina gaat naar Google en komt ingelogd terug. De popup blijft alleen bestaan voor\n * de niet-same-origin-configuratie, waar redirect juist de kapotte kant is.\n */\n if (process.env.NEXT_PUBLIC_FIREBASE_AUTH_SAME_ORIGIN === \"1\") {\n // Hint vóór vertrek, om dezelfde reden als bij de terugval hieronder: zonder hint laadt\n // de teruggekeerde pagina de auth-SDK niet en wordt het antwoord nooit opgehaald.\n setAuthHint(true);\n await sdk.signInWithRedirect(auth, provider);\n return;\n }\n try {\n await sdk.signInWithPopup(auth, provider);\n await weigerZonderToegang(auth, sdk);\n } catch (e) {\n // TERUGVAL OP REDIRECT — maar ALLEEN als de popup niet eens open kón.\n //\n // Dit stond hier breed: elke fout behalve annuleren stuurde de hele pagina naar Google.\n // Dat is destructief zodra de popup het wél doet, en precies dat gebeurde. Waargenomen op\n // christelijkkraamcadeau: je koos je account in de popup, en tegelijk opende de\n // ONDERLIGGENDE beheerpagina een tweede Google-scherm. In de console was ondertussen te\n // zien dat `accounts:signInWithIdp` en `accounts:lookup` gewoon slaagden — Firebase had je\n // dus allang ingelogd. Twee flows die door elkaar heen liepen, met \"Inloggen met Google\n // mislukt of geannuleerd\" als eindresultaat terwijl er niets mislukt was.\n //\n // Een redirect gooit de hele pagina weg. Dat is een zwaar middel en hoort alleen bij de\n // ene fout waarbij er echt geen andere weg is: de browser weigerde het venster te openen.\n // Alle andere fouten — netwerk, configuratie, een afgebroken relay — horen zichtbaar te\n // falen, niet stilzwijgend een tweede inlogpoging te starten.\n //\n // De aanleiding voor de brede terugval was een popup die dichtging zonder foutcode. Die\n // kwam door de COOP-blokkade (zie next/authRewrites.ts), en die is nu bij de bron\n // opgelost. Daarmee vervalt de reden om hier ruim te zijn.\n // Merk op dat hier GEEN `wekOp()` meer staat: de luisteraar is al vóór de popup\n // geabonneerd. Dat dekt meteen het geval dat de belofte afwijst terwijl er tóch een sessie\n // is — in de gemeten gevallen slaagden `signInWithIdp` en `lookup` gewoon terwijl deze\n // catch liep. `onAuthStateChanged` ziet die sessie dan alsnog en het inlogscherm verdwijnt.\n const code = (e as { code?: string })?.code ?? \"\";\n if (code !== \"auth/popup-blocked\") throw e;\n // ZET DE HINT VÓÓR HET VERTREK. Zonder deze regel heeft de redirect-terugval nog nooit\n // gewerkt voor een eerste login, en wel zo stil dat het op de popup werd geschoven. De\n // keten: je gaat naar Google, komt terug op een verse pagina, en daar besluit het effect\n // hierboven de auth-SDK NIET te laden — want er is geen sessie-hint (je was immers nog\n // nooit ingelogd) en het is geen inloglink. Laadt de SDK niet, dan draait\n // `getRedirectResult` niet, en wordt het antwoord van Google nooit opgehaald.\n setAuthHint(true);\n await sdk.signInWithRedirect(auth, provider);\n }\n },\n async sendLoginLink(email: string) {\n const adres = email.trim();\n /**\n * Via de SERVER, niet via de client-SDK.\n *\n * `sendSignInLinkToEmail` stuurt een link naar élk adres dat je intikt. Firebase maakt bij\n * het inwisselen een account aan, dat vervolgens nergens een rol heeft en dus niets kan —\n * functioneel veilig, want de rules kijken naar claims en niet naar \"is ingelogd\". Maar het\n * betekent wel dat iedereen op het internet een account kan aanmaken op dit project, en dat\n * een vreemde een echte, werkende inloglink in zijn mailbox krijgt.\n *\n * De `requestLoginLink`-callable draait de volgorde om: eerst kijken of het adres een rol\n * heeft op DEZE site, en pas dan een link maken en versturen. Hij antwoordt altijd\n * hetzelfde, ook als er niets is verstuurd — anders zou het scherm verklappen wie beheerder\n * is. Daarom mag de bevestiging hierna ook niet beweren dát er een mail onderweg is.\n */\n // DYNAMISCH importeren, net als `authSdk()`. Zowel `firebase/client` als `functionsClient`\n // trekken de Firebase-SDK statisch mee; een gewone import bovenaan dit bestand zou die ~1 MB\n // aan élke anonieme bezoeker uitleveren — precies wat de sessie-hint hierboven voorkomt.\n const [client, fns] = await Promise.all([\n import(\"../firebase/client\"),\n import(\"../firebase/functionsClient\"),\n ]);\n const app = client.getFirebaseApp();\n if (!app) throw new Error(\"Firebase is niet geconfigureerd.\");\n await fns.callable(app, \"requestLoginLink\")({ siteId, email: adres });\n try {\n window.localStorage.setItem(LINK_EMAIL_KEY, adres);\n } catch {\n // Opslag geweigerd (private mode): niet fataal. Bij terugkomst vraagt het inlogscherm\n // het adres alsnog — dezelfde route als bij een link die op een ander apparaat opengaat.\n }\n },\n loginLinkNeedsEmail: linkVraagtEmail,\n loginLinkError: linkFout,\n async completeLoginLink(email: string) {\n const { auth, sdk } = await authSdk();\n await sdk.signInWithEmailLink(auth, email.trim(), window.location.href);\n // Ook hier, ondanks dat de server alleen naar beheerders stuurt: een mail kan worden\n // doorgestuurd, en dan zou de link in andere handen alsnog een sessie opleveren.\n await weigerZonderToegang(auth, sdk);\n opruimenNaLinkLogin();\n wekOp();\n },\n async signOut() {\n if (demo) {\n setDemoLoggedIn(false);\n return;\n }\n const { auth, sdk } = await authSdk();\n await sdk.signOut(auth);\n setAuthHint(false);\n },\n async refresh() {\n // Altijd de actuele Firebase-user (niet de gememoïseerde) — na een snelle\n // uitlog/inlog-wissel zou de closure anders claims van de vorige user verversen.\n // Force-refresh: na een rolwijziging is het gecachte token verouderd.\n const { auth } = await authSdk();\n await loadClaims(auth.currentUser ?? user, true);\n },\n demo,\n demoSignIn() {\n setDemoLoggedIn(true);\n },\n };\n }, [configured, loading, user, claims, siteId, demo, demoLoggedIn, linkVraagtEmail, linkFout]);\n\n return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;\n}\n\nexport function useAuth(): AuthState {\n const ctx = useContext(AuthContext);\n if (!ctx) throw new Error(\"useAuth moet binnen <XiCMSProvider> worden gebruikt.\");\n return ctx;\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/auth/AuthProvider.tsx"],"sourcesContent":["\"use client\";\n\nimport {\n createContext,\n useContext,\n useEffect,\n useMemo,\n useState,\n type ReactNode,\n} from \"react\";\nimport type { User } from \"firebase/auth\";\nimport { hasAuthHint, isFirebaseConfigured, setAuthHint } from \"../firebase/config\";\nimport { roleForSite, type Role, type XiClaims } from \"./roles\";\nimport { DEMO_EVENT, isDemoLoggedIn, setDemoLoggedIn } from \"../demo/demoStore\";\n\ninterface AuthState {\n /** Of Firebase überhaupt geconfigureerd is. */\n configured: boolean;\n /** Bezig met laden van de auth-status. */\n loading: boolean;\n user: User | null;\n claims: XiClaims | null;\n /** Effectieve rol op de huidige site (uit `siteId`). */\n role: Role | null;\n signIn: (email: string, password: string) => Promise<void>;\n /** Inloggen met Google (popup). */\n signInWithGoogle: () => Promise<void>;\n /**\n * Stuur een inloglink naar dit adres (magic link). Geen wachtwoord, dus ook niets te vergeten —\n * en anders dan de Google-flow raakt dit geen enkel domein van een derde partij, waardoor het\n * werkt in browsers die opslag van derden blokkeren.\n */\n sendLoginLink: (email: string) => Promise<void>;\n /**\n * Er staat een geldige inloglink in de adresbalk, maar het bijbehorende adres is hier niet\n * bekend — de link is op een ander apparaat geopend dan waar hij is aangevraagd. Het\n * inlogscherm vraagt er dan om; zonder die vraag zou een geldige link doodlopen.\n */\n loginLinkNeedsEmail: boolean;\n /** De link inwisselen met een handmatig opgegeven adres (zie `loginLinkNeedsEmail`). */\n completeLoginLink: (email: string) => Promise<void>;\n /** Ging het inwisselen van de link mis (verlopen, al gebruikt, ander adres)? */\n loginLinkError: unknown;\n signOut: () => Promise<void>;\n /** Forceer het opnieuw inlezen van claims (na een rolwijziging). */\n refresh: () => Promise<void>;\n /** Demo-bewerkmodus actief (config.demoEditing) — pseudo-login, niets wordt opgeslagen. */\n demo: boolean;\n /** Pseudo-login voor de demo: geeft lokaal een siteadmin-rol zonder Firebase. */\n demoSignIn: () => void;\n}\n\nconst AuthContext = createContext<AuthState | null>(null);\n\n/**\n * Het adres waar de inloglink heen ging, bewaard tot de klik terugkomt.\n *\n * Firebase eist bij het afronden hetzelfde adres als waar de link naartoe is gestuurd — dat is de\n * hele beveiliging van deze methode: wie de link onderschept maar het adres niet weet, komt er\n * niet in. Staat het er niet (link geopend op een ander apparaat, of opslag geweigerd), dan vraagt\n * het inlogscherm er alsnog om in plaats van te falen.\n */\nconst LINK_EMAIL_KEY = \"xicms.loginlink.email\";\n\n/**\n * Is deze URL een inloglink? Bewust ZONDER de SDK: deze vraag bepaalt óf we de auth-SDK van ~1 MB\n * gaan laden, dus hem beantwoorden met de SDK zou de besparing meteen opeten. Firebase hangt de\n * link altijd op aan `mode=signIn` met een `oobCode`; dat is genoeg voor de voorselectie, waarna\n * `isSignInWithEmailLink` het echte oordeel velt.\n */\nfunction lijktOpInloglink(): boolean {\n if (typeof window === \"undefined\") return false;\n const q = window.location.search;\n return q.includes(\"mode=signIn\") && q.includes(\"oobCode=\");\n}\n\nexport function AuthProvider({\n siteId,\n demo = false,\n children,\n}: {\n siteId: string;\n demo?: boolean;\n children: ReactNode;\n}) {\n const configured = isFirebaseConfigured();\n const [loading, setLoading] = useState(configured);\n const [user, setUser] = useState<User | null>(null);\n const [claims, setClaims] = useState<XiClaims | null>(null);\n const [demoLoggedIn, setDemoLoggedInState] = useState(false);\n // Er staat een geldige inloglink in de adresbalk, maar we weten niet bij welk adres hij hoort.\n const [linkVraagtEmail, setLinkVraagtEmail] = useState(false);\n const [linkFout, setLinkFout] = useState<unknown>(null);\n // Alleen om het abonnement-effect opnieuw te laten draaien na een login zonder paginawissel.\n const [wakker, setWakker] = useState(0);\n\n /**\n * Na een geslaagde link-login: het bewaarde adres weg, en de `oobCode` uit de adresbalk.\n *\n * Die code is eenmalig, maar hij blijft wél in de geschiedenis en in gedeelde/gekopieerde URL's\n * staan. Hem laten staan levert bovendien een verwarrende herhaling op: één keer verversen en de\n * SDK probeert een verbruikte code opnieuw in te wisselen, met een foutmelding terwijl je gewoon\n * ingelogd bent. `replaceState` haalt hem weg zonder de pagina opnieuw te laden.\n */\n function opruimenNaLinkLogin(): void {\n try {\n window.localStorage.removeItem(LINK_EMAIL_KEY);\n } catch {\n /* niets bewaard, niets op te ruimen */\n }\n setLinkVraagtEmail(false);\n setLinkFout(null);\n try {\n window.history.replaceState({}, \"\", window.location.pathname + window.location.hash);\n } catch {\n /* geschiedenis niet bewerkbaar: onschuldig, de login is al gelukt */\n }\n }\n\n /**\n * Zet de sessie-hint ÉN maak het effect hieronder wakker.\n *\n * Dat tweede is de kern van een bug die zich als \"ik kom er niet in\" voordeed. Het effect dat\n * `onAuthStateChanged` abonneert draait alleen als er bij het LADEN van de pagina al een hint is,\n * en hangt af van `[configured]` — een hint die later wordt gezet laat het dus niet opnieuw\n * draaien. Gevolg: log je in zónder paginawissel (popup of wachtwoord), dan maakt Firebase keurig\n * een sessie, maar luistert er niemand. React hoort het nooit en het inlogscherm blijft staan.\n *\n * Dat het toch vaak leek te werken kwam door de redirect-terugval: die verlaat de pagina en komt\n * terug op een verse load, waar de hint er wél staat. Precies daarom viel dit pas op ná het\n * repareren van de popup — toen verdween de herlaad die het gebrek maskeerde.\n *\n * De teller hoeft nergens te worden uitgelezen; hij bestaat alleen om de afhankelijkheidslijst\n * te laten wijzigen.\n */\n function wekOp(): void {\n setAuthHint(true);\n setWakker((n) => n + 1);\n }\n\n // Demo-login uit de (sessie)opslag inlezen + meeluisteren met wijzigingen (login/logout/reset).\n useEffect(() => {\n if (!demo) return;\n const sync = () => setDemoLoggedInState(isDemoLoggedIn());\n sync();\n window.addEventListener(DEMO_EVENT, sync);\n return () => window.removeEventListener(DEMO_EVENT, sync);\n }, [demo]);\n\n /**\n * Meteen weer uitloggen als dit account geen toegang heeft tot DEZE site.\n *\n * Bij Google kun je niet vooraf weten wie er inlogt — je krijgt het adres pas ná de\n * accountkeuze. Zonder deze controle komt een willekeurige Google-gebruiker binnen met een\n * geldige sessie, ziet hij \"je hebt geen beheerrechten\", en blijft hij ingelogd op een site\n * waar hij niets te zoeken heeft. Dat is niet gevaarlijk (de rules kijken naar claims), maar\n * het is wel een half-open deur en het leest als een fout in plaats van als een grens.\n *\n * `forceRefresh` is nodig: het token dat je meteen na het inloggen krijgt kan nog van vóór een\n * recente rolwijziging zijn, en dan zou een net toegevoegde beheerder onterecht worden\n * geweigerd.\n */\n async function weigerZonderToegang(\n auth: { currentUser: User | null; },\n sdk: { signOut: (a: never) => Promise<void> },\n ): Promise<void> {\n const u = auth.currentUser;\n if (!u) return;\n const token = await u.getIdTokenResult(true);\n const c = token.claims as { superadmin?: boolean; roles?: Record<string, string> };\n const rol = c.roles?.[siteId];\n if (c.superadmin === true || rol === \"siteadmin\" || rol === \"editor\") return;\n\n await sdk.signOut(auth as never);\n const fout = new Error(\"geen-toegang\") as Error & { code?: string };\n fout.code = \"xicms/geen-toegang\";\n throw fout;\n }\n\n /** Laadt de auth-SDK pas wanneer er iets mee moet gebeuren (inloggen, uitloggen, verversen). */\n async function authSdk() {\n const [client, sdk] = await Promise.all([import(\"../firebase/client\"), import(\"firebase/auth\")]);\n const auth = client.getFirebaseAuth();\n if (!auth) throw new Error(\"Firebase is niet geconfigureerd.\");\n return { auth, sdk };\n }\n\n async function loadClaims(u: User | null, forceRefresh = false) {\n if (!u) {\n setClaims(null);\n return;\n }\n // Standaard GEEN geforceerde token-refresh: dat is een netwerk-roundtrip op élke\n // auth-state-change (incl. eerste load van elke ingelogde editor). Alleen refresh() —\n // na een rolwijziging — forceert, want dan is het gecachte token per definitie verouderd.\n const token = await u.getIdTokenResult(forceRefresh);\n setClaims({\n superadmin: token.claims.superadmin === true,\n roles: (token.claims.roles as XiClaims[\"roles\"]) ?? {},\n });\n }\n\n useEffect(() => {\n // Zonder sessie-hint laden we de auth-SDK NIET: een anonieme bezoeker hoeft ~1 MB niet te\n // downloaden om te ontdekken dat hij niet is ingelogd. Zodra hij inlogt of het beheer opent\n // komt de SDK alsnog, en vindt onAuthStateChanged een bestaande sessie meteen terug.\n // …behalve als dit een inloglink IS. Wie op de link in zijn mail klikt komt per definitie\n // zonder sessie-hint binnen — vaak zelfs in een andere browser dan waar hij hem aanvroeg.\n // Zonder deze uitzondering laadt de SDK niet, wordt de link nooit ingewisseld en belandt hij\n // op het inlogscherm met de geldige link ongebruikt in de adresbalk.\n if (!configured || (!hasAuthHint() && !lijktOpInloglink())) {\n setLoading(false);\n return;\n }\n let unsub: (() => void) | undefined;\n let afgebroken = false;\n void (async () => {\n const { auth, sdk } = await authSdk().catch(() => ({ auth: null, sdk: null }) as never);\n if (!auth || afgebroken) {\n setLoading(false);\n return;\n }\n // Terugkeer van een redirect-login afronden (zie signInWithGoogle). `onAuthStateChanged`\n // pikt de sessie zelf al op; deze aanroep is er om een FOUT bij terugkomst niet stil te\n // laten verdwijnen — anders sta je weer op het inlogscherm zonder te weten waarom.\n // Sinds redirect de primaire flow is (7.16) hoort hier ook de site-toegangscontrole die het\n // popup-pad na afloop deed: een geldig Google-account is niet hetzelfde als toegang tot\n // DEZE site (weigerZonderToegang logt dan meteen weer uit).\n void sdk\n .getRedirectResult(auth)\n .then(async (res) => {\n if (res?.user) await weigerZonderToegang(auth, sdk);\n })\n .catch((e: unknown) => {\n console.error(\"[xicms] inloggen via redirect mislukt:\", e);\n });\n\n // Een aangeklikte inloglink meteen inwisselen. Het adres staat lokaal opgeslagen sinds het\n // versturen; ontbreekt het — link geopend op een ander apparaat, of opslag geweigerd — dan\n // laten we de link stáán en vraagt het inlogscherm erom. Weggooien zou de gebruiker terug\n // naar zijn mailbox sturen voor een link die hij al heeft.\n if (sdk.isSignInWithEmailLink(auth, window.location.href)) {\n let bewaard: string | null = null;\n try {\n bewaard = window.localStorage.getItem(LINK_EMAIL_KEY);\n } catch {\n /* opslag geweigerd → hieronder vragen we het adres gewoon */\n }\n if (bewaard) {\n try {\n await sdk.signInWithEmailLink(auth, bewaard, window.location.href);\n opruimenNaLinkLogin();\n } catch (e) {\n console.error(\"[xicms] inloggen via link mislukt:\", e);\n setLinkFout(e);\n }\n } else if (!afgebroken) {\n setLinkVraagtEmail(true);\n }\n }\n\n unsub = sdk.onAuthStateChanged(auth, async (u: User | null) => {\n setUser(u);\n // 8.16: App Check (reCAPTCHA) pas voor wie ingelogd is en dus naar Firestore schrijft —\n // niet meer bij elke bezoeker (zie firebase/appcheck.ts).\n if (u) void import(\"../firebase/appcheck\").then((m) => m.initAppCheck());\n /**\n * Hint alleen ZETTEN bij een sessie — nooit wissen op `null`.\n *\n * `null` betekent hier niet \"uitgelogd\" maar \"(nog) geen sessie\", en dat is óók de\n * toestand vlak vóór een redirect-login: signInWithGoogle zet de hint, en deze listener\n * vuurde daarna met `null` en WISTE hem weer — nog vóór de navigatie naar Google. Terug\n * op de site weigerde het effect hierboven daardoor de auth-SDK te laden, werd\n * getRedirectResult nooit gedraaid en bleef het antwoord van Google onaangeroerd in\n * localStorage liggen (`firebase:authEvent:…` aangetroffen naast een ontbrekende hint,\n * live op christelijkkraamcadeau; hint handmatig terugzetten + herladen logde alsnog in).\n * Wissen hoort alleen bij expliciet uitloggen — dat doet signOut al.\n */\n if (u) setAuthHint(true);\n await loadClaims(u);\n setLoading(false);\n });\n })();\n return () => {\n afgebroken = true;\n unsub?.();\n };\n }, [configured, wakker]);\n\n const value = useMemo<AuthState>(() => {\n // In de demo met actieve pseudo-login: doe alsof een siteadmin is ingelogd (lokaal, geen Firebase).\n const demoActiveLogin = demo && demoLoggedIn;\n const effectiveUser = demoActiveLogin\n ? ({ uid: \"demo\", email: \"demo@voorbeeld.nl\", displayName: \"Demo-beheerder\" } as unknown as User)\n : user;\n const effectiveClaims: XiClaims | null = demoActiveLogin ? { roles: { [siteId]: \"siteadmin\" } } : claims;\n\n return {\n configured,\n loading,\n user: effectiveUser,\n claims: effectiveClaims,\n role: roleForSite(effectiveClaims, siteId),\n async signIn(email, password) {\n const { auth, sdk } = await authSdk();\n await sdk.signInWithEmailAndPassword(auth, email, password);\n // Zelfde grens als bij Google: een geldig wachtwoord is niet hetzelfde als toegang tot\n // DEZE site. Zonder deze regel zou de terugval een ruimere deur zijn dan de hoofdingang.\n await weigerZonderToegang(auth, sdk);\n wekOp();\n },\n async signInWithGoogle() {\n const { auth, sdk } = await authSdk();\n const provider = new sdk.GoogleAuthProvider();\n /**\n * Abonneren VÓÓR de popup opengaat, niet erna.\n *\n * `signInWithPopup` lost pas op als Firebase de hele tokenuitwisseling heeft afgerond —\n * `signInWithIdp`, dan `lookup` — en pas dán sluit de handler zijn eigen venster. Wachtte de\n * UI op die belofte, dan bleef het inlogscherm al die tijd staan terwijl de sessie er vaak\n * al lang was. Voor de gebruiker las dat als \"er gebeurt niets\".\n *\n * Met de luisteraar al actief reageert het scherm op het moment dat de sessie ontstaat, niet\n * op het moment dat Firebase klaar is met opruimen. Het venster sluit daarna alsnog wat later\n * — dat is Firebase' eigen tijd en die willen we niet inkorten, want dan sluiten we het\n * midden in de tokenuitwisseling.\n */\n wekOp();\n /**\n * SAME-ORIGIN-SETUP → REDIRECT ALS PRIMAIRE FLOW (7.16).\n *\n * De popup-flow is met COOP structureel onbetrouwbaar geworden, en dat is niet met onze\n * eigen headers te repareren. Gemeten op christelijkkraamcadeau mét de\n * `same-origin-allow-popups`-header correct live (curl bevestigd, ook op de handler):\n * `signInWithPopup` faalde alsnog met \"auth/popup-closed-by-user\" terwijl het venster\n * gewoon openstond — Google's eigen COOP op accounts.google.com knipt de band tussen\n * opener en popup door, waarna `popup.closed` vals `true` rapporteert. De handler maakt\n * de login ondertussen gewoon af (de sessie komt via onAuthStateChanged alsnog binnen),\n * maar niemand kan het venster meer sluiten: een wees-tab met de handler-URL blijft\n * achter. Precies zo gerapporteerd door de beheerder.\n *\n * Met een same-origin authDomain (de standaard-setup, zie next/authRewrites.ts) is de\n * redirect-flow de betrouwbare route: geen popup, geen COOP, geen wees-venster — de\n * pagina gaat naar Google en komt ingelogd terug. De popup blijft alleen bestaan voor\n * de niet-same-origin-configuratie, waar redirect juist de kapotte kant is.\n */\n if (process.env.NEXT_PUBLIC_FIREBASE_AUTH_SAME_ORIGIN === \"1\") {\n // Hint vóór vertrek, om dezelfde reden als bij de terugval hieronder: zonder hint laadt\n // de teruggekeerde pagina de auth-SDK niet en wordt het antwoord nooit opgehaald.\n setAuthHint(true);\n await sdk.signInWithRedirect(auth, provider);\n return;\n }\n try {\n await sdk.signInWithPopup(auth, provider);\n await weigerZonderToegang(auth, sdk);\n } catch (e) {\n // TERUGVAL OP REDIRECT — maar ALLEEN als de popup niet eens open kón.\n //\n // Dit stond hier breed: elke fout behalve annuleren stuurde de hele pagina naar Google.\n // Dat is destructief zodra de popup het wél doet, en precies dat gebeurde. Waargenomen op\n // christelijkkraamcadeau: je koos je account in de popup, en tegelijk opende de\n // ONDERLIGGENDE beheerpagina een tweede Google-scherm. In de console was ondertussen te\n // zien dat `accounts:signInWithIdp` en `accounts:lookup` gewoon slaagden — Firebase had je\n // dus allang ingelogd. Twee flows die door elkaar heen liepen, met \"Inloggen met Google\n // mislukt of geannuleerd\" als eindresultaat terwijl er niets mislukt was.\n //\n // Een redirect gooit de hele pagina weg. Dat is een zwaar middel en hoort alleen bij de\n // ene fout waarbij er echt geen andere weg is: de browser weigerde het venster te openen.\n // Alle andere fouten — netwerk, configuratie, een afgebroken relay — horen zichtbaar te\n // falen, niet stilzwijgend een tweede inlogpoging te starten.\n //\n // De aanleiding voor de brede terugval was een popup die dichtging zonder foutcode. Die\n // kwam door de COOP-blokkade (zie next/authRewrites.ts), en die is nu bij de bron\n // opgelost. Daarmee vervalt de reden om hier ruim te zijn.\n // Merk op dat hier GEEN `wekOp()` meer staat: de luisteraar is al vóór de popup\n // geabonneerd. Dat dekt meteen het geval dat de belofte afwijst terwijl er tóch een sessie\n // is — in de gemeten gevallen slaagden `signInWithIdp` en `lookup` gewoon terwijl deze\n // catch liep. `onAuthStateChanged` ziet die sessie dan alsnog en het inlogscherm verdwijnt.\n const code = (e as { code?: string })?.code ?? \"\";\n if (code !== \"auth/popup-blocked\") throw e;\n // ZET DE HINT VÓÓR HET VERTREK. Zonder deze regel heeft de redirect-terugval nog nooit\n // gewerkt voor een eerste login, en wel zo stil dat het op de popup werd geschoven. De\n // keten: je gaat naar Google, komt terug op een verse pagina, en daar besluit het effect\n // hierboven de auth-SDK NIET te laden — want er is geen sessie-hint (je was immers nog\n // nooit ingelogd) en het is geen inloglink. Laadt de SDK niet, dan draait\n // `getRedirectResult` niet, en wordt het antwoord van Google nooit opgehaald.\n setAuthHint(true);\n await sdk.signInWithRedirect(auth, provider);\n }\n },\n async sendLoginLink(email: string) {\n const adres = email.trim();\n /**\n * Via de SERVER, niet via de client-SDK.\n *\n * `sendSignInLinkToEmail` stuurt een link naar élk adres dat je intikt. Firebase maakt bij\n * het inwisselen een account aan, dat vervolgens nergens een rol heeft en dus niets kan —\n * functioneel veilig, want de rules kijken naar claims en niet naar \"is ingelogd\". Maar het\n * betekent wel dat iedereen op het internet een account kan aanmaken op dit project, en dat\n * een vreemde een echte, werkende inloglink in zijn mailbox krijgt.\n *\n * De `requestLoginLink`-callable draait de volgorde om: eerst kijken of het adres een rol\n * heeft op DEZE site, en pas dan een link maken en versturen. Hij antwoordt altijd\n * hetzelfde, ook als er niets is verstuurd — anders zou het scherm verklappen wie beheerder\n * is. Daarom mag de bevestiging hierna ook niet beweren dát er een mail onderweg is.\n */\n // DYNAMISCH importeren, net als `authSdk()`. Zowel `firebase/client` als `functionsClient`\n // trekken de Firebase-SDK statisch mee; een gewone import bovenaan dit bestand zou die ~1 MB\n // aan élke anonieme bezoeker uitleveren — precies wat de sessie-hint hierboven voorkomt.\n const [client, fns] = await Promise.all([\n import(\"../firebase/client\"),\n import(\"../firebase/functionsClient\"),\n ]);\n const app = client.getFirebaseApp();\n if (!app) throw new Error(\"Firebase is niet geconfigureerd.\");\n await fns.callable(app, \"requestLoginLink\")({ siteId, email: adres });\n try {\n window.localStorage.setItem(LINK_EMAIL_KEY, adres);\n } catch {\n // Opslag geweigerd (private mode): niet fataal. Bij terugkomst vraagt het inlogscherm\n // het adres alsnog — dezelfde route als bij een link die op een ander apparaat opengaat.\n }\n },\n loginLinkNeedsEmail: linkVraagtEmail,\n loginLinkError: linkFout,\n async completeLoginLink(email: string) {\n const { auth, sdk } = await authSdk();\n await sdk.signInWithEmailLink(auth, email.trim(), window.location.href);\n // Ook hier, ondanks dat de server alleen naar beheerders stuurt: een mail kan worden\n // doorgestuurd, en dan zou de link in andere handen alsnog een sessie opleveren.\n await weigerZonderToegang(auth, sdk);\n opruimenNaLinkLogin();\n wekOp();\n },\n async signOut() {\n if (demo) {\n setDemoLoggedIn(false);\n return;\n }\n const { auth, sdk } = await authSdk();\n await sdk.signOut(auth);\n setAuthHint(false);\n },\n async refresh() {\n // Altijd de actuele Firebase-user (niet de gememoïseerde) — na een snelle\n // uitlog/inlog-wissel zou de closure anders claims van de vorige user verversen.\n // Force-refresh: na een rolwijziging is het gecachte token verouderd.\n const { auth } = await authSdk();\n await loadClaims(auth.currentUser ?? user, true);\n },\n demo,\n demoSignIn() {\n setDemoLoggedIn(true);\n },\n };\n }, [configured, loading, user, claims, siteId, demo, demoLoggedIn, linkVraagtEmail, linkFout]);\n\n return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;\n}\n\nexport function useAuth(): AuthState {\n const ctx = useContext(AuthContext);\n if (!ctx) throw new Error(\"useAuth moet binnen <XiCMSProvider> worden gebruikt.\");\n return ctx;\n}\n"],"mappings":"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","names":[]}
@@ -23,6 +23,14 @@ export declare function adresRegels(b: Business | undefined): string[];
23
23
  export declare function telLink(nummer: string | undefined): string | undefined;
24
24
  /** Route plannen naar het adres (Google Maps werkt op elk toestel, ook zonder app). */
25
25
  export declare function routeLink(b: Business | undefined): string | undefined;
26
+ /**
27
+ * De wettelijke regel: "KvK 12345678 · Btw NL123456789B01" (8.16). Een ondernemer moet zijn
28
+ * KvK-nummer op de website vermelden (Handelsregisterwet), en wie aan consumenten verkoopt ook
29
+ * het btw-identificatienummer. Leeg profiel → null (dan tonen we niets).
30
+ */
31
+ export declare function bedrijfsnummersTekst(b: Business | undefined): string | null;
32
+ /** Zachte controle in het beheer: klopt de vorm van een Nederlands KvK- of btw-nummer? */
33
+ export declare function bedrijfsnummerAdvies(soort: "kvk" | "vat", waarde: string | undefined): string | null;
26
34
  /** Heeft het profiel genoeg om iets mee te doen? */
27
35
  export declare function heeftProfiel(b: Business | undefined): boolean;
28
36
  /**
@@ -47,8 +47,19 @@ function routeLink(b) {
47
47
  const regels = adresRegels(b);
48
48
  return regels.length ? `https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(regels.join(", "))}` : void 0;
49
49
  }
50
+ function bedrijfsnummersTekst(b) {
51
+ const delen = [b?.kvk?.trim() && `KvK ${b.kvk.trim()}`, b?.vat?.trim() && `Btw ${b.vat.trim()}`].filter(Boolean);
52
+ return delen.length ? delen.join(" \xB7 ") : null;
53
+ }
54
+ function bedrijfsnummerAdvies(soort, waarde) {
55
+ const v = (waarde ?? "").replace(/[\s.]/g, "").toUpperCase();
56
+ if (!v) return null;
57
+ if (soort === "kvk") return /^\d{8}$/.test(v) ? null : "Een KvK-nummer bestaat uit 8 cijfers.";
58
+ if (/^[A-Z]{2}/.test(v) && !v.startsWith("NL")) return null;
59
+ return /^NL\d{9}B\d{2}$/.test(v) ? null : "Een Nederlands btw-nummer ziet eruit als NL123456789B01.";
60
+ }
50
61
  function heeftProfiel(b) {
51
- return Boolean(b && (b.phone || b.email || b.address?.street || b.address?.city || (b.hours?.length ?? 0) > 0));
62
+ return Boolean(b && (b.phone || b.email || b.address?.street || b.address?.city || b.kvk || (b.hours?.length ?? 0) > 0));
52
63
  }
53
64
  function bedrijfJsonLd(b, site) {
54
65
  if (!heeftProfiel(b) || !b) return null;
@@ -91,6 +102,8 @@ function bedrijfJsonLd(b, site) {
91
102
  };
92
103
  });
93
104
  if (speciaal.length) out.specialOpeningHoursSpecification = speciaal;
105
+ if (b.vat?.trim()) out.vatID = b.vat.trim();
106
+ if (b.kvk?.trim()) out.identifier = { "@type": "PropertyValue", propertyID: "KvK", value: b.kvk.trim() };
94
107
  const sameAs = (b.sameAs ?? []).filter((u) => /^https?:\/\//.test(u));
95
108
  if (sameAs.length) out.sameAs = sameAs;
96
109
  return out;
@@ -100,6 +113,8 @@ export {
100
113
  afwijkingDatumTekst,
101
114
  afwijkingOp,
102
115
  bedrijfJsonLd,
116
+ bedrijfsnummerAdvies,
117
+ bedrijfsnummersTekst,
103
118
  heeftProfiel,
104
119
  isOpenNuMetAfwijkingen,
105
120
  isoDatum,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/bedrijf/bedrijf.ts"],"sourcesContent":["import type { Business, BusinessException } from \"../schema/business\";\nimport { isOpenNow, openingHoursSpecJsonLd, parseTimeRange, type HoursRow } from \"../blocks/builtins/lib/openingHours\";\n\n/**\n * Pure hulpjes rond het bedrijfsprofiel (8.13). Geen React, geen Firestore — zodat de server\n * (JSON-LD), de blokken (contact, openingstijden) en de tests dezelfde regels gebruiken.\n */\n\n/** \"2026-12-25\" voor een Date, in lokale tijd (niet UTC: om 00:30 is het in NL al de volgende dag). */\nexport function isoDatum(d: Date): string {\n return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, \"0\")}-${String(d.getDate()).padStart(2, \"0\")}`;\n}\n\n/** De afwijking die op deze dag geldt (een losse dag of een periode), of undefined. */\nexport function afwijkingOp(datum: Date, afwijkingen: BusinessException[] | undefined): BusinessException | undefined {\n const dag = isoDatum(datum);\n return (afwijkingen ?? []).find((a) => (a.until ? a.date <= dag && dag <= a.until : a.date === dag));\n}\n\n/** Afwijkingen die vandaag of binnen `dagen` beginnen, of nu lopen — gesorteerd. */\nexport function komendeAfwijkingen(afwijkingen: BusinessException[] | undefined, nu = new Date(), dagen = 21): BusinessException[] {\n const van = isoDatum(nu);\n const grens = new Date(nu);\n grens.setDate(grens.getDate() + dagen);\n const tot = isoDatum(grens);\n return (afwijkingen ?? [])\n .filter((a) => (a.until ?? a.date) >= van && a.date <= tot)\n .sort((a, b) => a.date.localeCompare(b.date));\n}\n\n/** \"do 25 december\" of \"25 december – 2 januari\". */\nexport function afwijkingDatumTekst(a: BusinessException): string {\n const fmt = (iso: string, metDag: boolean) => {\n const [j, m, d] = iso.split(\"-\").map(Number);\n const datum = new Date(j, m - 1, d);\n return datum.toLocaleDateString(\"nl-NL\", metDag ? { weekday: \"short\", day: \"numeric\", month: \"long\" } : { day: \"numeric\", month: \"long\" });\n };\n return a.until && a.until !== a.date ? `${fmt(a.date, false)} – ${fmt(a.until, false)}` : fmt(a.date, true);\n}\n\n/**\n * Nu open, met de afwijkingen erbij: op een gesloten feestdag is het \"gesloten\", ook al zegt het\n * gewone rooster \"maandag 09:00 – 17:00\". null = niet te bepalen.\n */\nexport function isOpenNuMetAfwijkingen(rijen: HoursRow[], afwijkingen: BusinessException[] | undefined, nu = new Date()): boolean | null {\n const a = afwijkingOp(nu, afwijkingen);\n if (a) {\n const bereik = parseTimeRange(a.hours);\n if (!bereik) return /gesloten/i.test(a.hours) ? false : isOpenNow(rijen, nu);\n const min = nu.getHours() * 60 + nu.getMinutes();\n return min >= bereik.open && min < bereik.close;\n }\n return isOpenNow(rijen, nu);\n}\n\n/** Adres als regels: [\"Tuinderslaan 12\", \"3991 AB Houten\"]. */\nexport function adresRegels(b: Business | undefined): string[] {\n const a = b?.address;\n if (!a) return [];\n const regel2 = [a.postalCode, a.city].filter(Boolean).join(\" \");\n return [a.street, regel2, a.country && a.country.toLowerCase() !== \"nederland\" ? a.country : undefined].filter((x): x is string => Boolean(x?.trim()));\n}\n\n/** Een tel:-link van een telefoonnummer zoals de klant het typt (\"030 – 123 45 67\"). */\nexport function telLink(nummer: string | undefined): string | undefined {\n if (!nummer?.trim()) return undefined;\n const kaal = nummer.replace(/[^\\d+]/g, \"\");\n return kaal ? `tel:${kaal.startsWith(\"0\") && !kaal.startsWith(\"00\") ? `+31${kaal.slice(1)}` : kaal}` : undefined;\n}\n\n/** Route plannen naar het adres (Google Maps werkt op elk toestel, ook zonder app). */\nexport function routeLink(b: Business | undefined): string | undefined {\n if (b?.geo) return `https://www.google.com/maps/dir/?api=1&destination=${b.geo.lat},${b.geo.lng}`;\n const regels = adresRegels(b);\n return regels.length ? `https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(regels.join(\", \"))}` : undefined;\n}\n\n/** Heeft het profiel genoeg om iets mee te doen? */\nexport function heeftProfiel(b: Business | undefined): boolean {\n return Boolean(b && (b.phone || b.email || b.address?.street || b.address?.city || (b.hours?.length ?? 0) > 0));\n}\n\n/**\n * LocalBusiness-JSON-LD uit het profiel, of null als er (nog) niets in staat. Zelfde `@id` als de\n * contact- en openingstijdenblokken (`#business`), zodat Google alles samenvoegt tot één bedrijf.\n */\nexport function bedrijfJsonLd(\n b: Business | undefined,\n site: { name?: string; domain?: string; logo?: string },\n): Record<string, unknown> | null {\n if (!heeftProfiel(b) || !b) return null;\n const base = site.domain ? `https://${site.domain.replace(/^https?:\\/\\//, \"\").replace(/\\/+$/, \"\")}` : undefined;\n const out: Record<string, unknown> = {\n \"@context\": \"https://schema.org\",\n \"@type\": b.type || \"LocalBusiness\",\n \"@id\": \"#business\",\n name: b.name || site.name,\n };\n if (b.description) out.description = b.description;\n if (base) out.url = base;\n if (site.logo) out.image = site.logo;\n if (b.phone) out.telephone = b.phone;\n if (b.email) out.email = b.email;\n if (b.priceRange) out.priceRange = b.priceRange;\n const a = b.address;\n if (a && (a.street || a.city || a.postalCode)) {\n out.address = {\n \"@type\": \"PostalAddress\",\n ...(a.street ? { streetAddress: a.street } : {}),\n ...(a.postalCode ? { postalCode: a.postalCode } : {}),\n ...(a.city ? { addressLocality: a.city } : {}),\n addressCountry: a.country?.trim() || \"NL\",\n };\n }\n if (b.geo) out.geo = { \"@type\": \"GeoCoordinates\", latitude: b.geo.lat, longitude: b.geo.lng };\n const spec = openingHoursSpecJsonLd(b.hours ?? []);\n if (spec.length) out.openingHoursSpecification = spec;\n // Afwijkende dagen (feestdagen, vakantie) — Google toont ze als \"aangepaste openingstijden\".\n const vandaag = isoDatum(new Date());\n const speciaal = (b.exceptions ?? [])\n .filter((x) => (x.until ?? x.date) >= vandaag)\n .map((x) => {\n const bereik = parseTimeRange(x.hours);\n const fmt = (m: number) => `${String(Math.floor(m / 60) % 24).padStart(2, \"0\")}:${String(m % 60).padStart(2, \"0\")}`;\n return {\n \"@type\": \"OpeningHoursSpecification\",\n validFrom: x.date,\n validThrough: x.until ?? x.date,\n opens: bereik ? fmt(bereik.open) : \"00:00\",\n closes: bereik ? fmt(bereik.close) : \"00:00\",\n };\n });\n if (speciaal.length) out.specialOpeningHoursSpecification = speciaal;\n const sameAs = (b.sameAs ?? []).filter((u) => /^https?:\\/\\//.test(u));\n if (sameAs.length) out.sameAs = sameAs;\n return out;\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/bedrijf/bedrijf.ts"],"sourcesContent":["import type { Business, BusinessException } from \"../schema/business\";\nimport { isOpenNow, openingHoursSpecJsonLd, parseTimeRange, type HoursRow } from \"../blocks/builtins/lib/openingHours\";\n\n/**\n * Pure hulpjes rond het bedrijfsprofiel (8.13). Geen React, geen Firestore — zodat de server\n * (JSON-LD), de blokken (contact, openingstijden) en de tests dezelfde regels gebruiken.\n */\n\n/** \"2026-12-25\" voor een Date, in lokale tijd (niet UTC: om 00:30 is het in NL al de volgende dag). */\nexport function isoDatum(d: Date): string {\n return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, \"0\")}-${String(d.getDate()).padStart(2, \"0\")}`;\n}\n\n/** De afwijking die op deze dag geldt (een losse dag of een periode), of undefined. */\nexport function afwijkingOp(datum: Date, afwijkingen: BusinessException[] | undefined): BusinessException | undefined {\n const dag = isoDatum(datum);\n return (afwijkingen ?? []).find((a) => (a.until ? a.date <= dag && dag <= a.until : a.date === dag));\n}\n\n/** Afwijkingen die vandaag of binnen `dagen` beginnen, of nu lopen — gesorteerd. */\nexport function komendeAfwijkingen(afwijkingen: BusinessException[] | undefined, nu = new Date(), dagen = 21): BusinessException[] {\n const van = isoDatum(nu);\n const grens = new Date(nu);\n grens.setDate(grens.getDate() + dagen);\n const tot = isoDatum(grens);\n return (afwijkingen ?? [])\n .filter((a) => (a.until ?? a.date) >= van && a.date <= tot)\n .sort((a, b) => a.date.localeCompare(b.date));\n}\n\n/** \"do 25 december\" of \"25 december – 2 januari\". */\nexport function afwijkingDatumTekst(a: BusinessException): string {\n const fmt = (iso: string, metDag: boolean) => {\n const [j, m, d] = iso.split(\"-\").map(Number);\n const datum = new Date(j, m - 1, d);\n return datum.toLocaleDateString(\"nl-NL\", metDag ? { weekday: \"short\", day: \"numeric\", month: \"long\" } : { day: \"numeric\", month: \"long\" });\n };\n return a.until && a.until !== a.date ? `${fmt(a.date, false)} – ${fmt(a.until, false)}` : fmt(a.date, true);\n}\n\n/**\n * Nu open, met de afwijkingen erbij: op een gesloten feestdag is het \"gesloten\", ook al zegt het\n * gewone rooster \"maandag 09:00 – 17:00\". null = niet te bepalen.\n */\nexport function isOpenNuMetAfwijkingen(rijen: HoursRow[], afwijkingen: BusinessException[] | undefined, nu = new Date()): boolean | null {\n const a = afwijkingOp(nu, afwijkingen);\n if (a) {\n const bereik = parseTimeRange(a.hours);\n if (!bereik) return /gesloten/i.test(a.hours) ? false : isOpenNow(rijen, nu);\n const min = nu.getHours() * 60 + nu.getMinutes();\n return min >= bereik.open && min < bereik.close;\n }\n return isOpenNow(rijen, nu);\n}\n\n/** Adres als regels: [\"Tuinderslaan 12\", \"3991 AB Houten\"]. */\nexport function adresRegels(b: Business | undefined): string[] {\n const a = b?.address;\n if (!a) return [];\n const regel2 = [a.postalCode, a.city].filter(Boolean).join(\" \");\n return [a.street, regel2, a.country && a.country.toLowerCase() !== \"nederland\" ? a.country : undefined].filter((x): x is string => Boolean(x?.trim()));\n}\n\n/** Een tel:-link van een telefoonnummer zoals de klant het typt (\"030 – 123 45 67\"). */\nexport function telLink(nummer: string | undefined): string | undefined {\n if (!nummer?.trim()) return undefined;\n const kaal = nummer.replace(/[^\\d+]/g, \"\");\n return kaal ? `tel:${kaal.startsWith(\"0\") && !kaal.startsWith(\"00\") ? `+31${kaal.slice(1)}` : kaal}` : undefined;\n}\n\n/** Route plannen naar het adres (Google Maps werkt op elk toestel, ook zonder app). */\nexport function routeLink(b: Business | undefined): string | undefined {\n if (b?.geo) return `https://www.google.com/maps/dir/?api=1&destination=${b.geo.lat},${b.geo.lng}`;\n const regels = adresRegels(b);\n return regels.length ? `https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(regels.join(\", \"))}` : undefined;\n}\n\n/**\n * De wettelijke regel: \"KvK 12345678 · Btw NL123456789B01\" (8.16). Een ondernemer moet zijn\n * KvK-nummer op de website vermelden (Handelsregisterwet), en wie aan consumenten verkoopt ook\n * het btw-identificatienummer. Leeg profiel → null (dan tonen we niets).\n */\nexport function bedrijfsnummersTekst(b: Business | undefined): string | null {\n const delen = [b?.kvk?.trim() && `KvK ${b.kvk.trim()}`, b?.vat?.trim() && `Btw ${b.vat.trim()}`].filter(Boolean);\n return delen.length ? delen.join(\" · \") : null;\n}\n\n/** Zachte controle in het beheer: klopt de vorm van een Nederlands KvK- of btw-nummer? */\nexport function bedrijfsnummerAdvies(soort: \"kvk\" | \"vat\", waarde: string | undefined): string | null {\n const v = (waarde ?? \"\").replace(/[\\s.]/g, \"\").toUpperCase();\n if (!v) return null;\n if (soort === \"kvk\") return /^\\d{8}$/.test(v) ? null : \"Een KvK-nummer bestaat uit 8 cijfers.\";\n if (/^[A-Z]{2}/.test(v) && !v.startsWith(\"NL\")) return null; // buitenlands btw-nummer: niet controleren\n return /^NL\\d{9}B\\d{2}$/.test(v) ? null : \"Een Nederlands btw-nummer ziet eruit als NL123456789B01.\";\n}\n\n/** Heeft het profiel genoeg om iets mee te doen? */\nexport function heeftProfiel(b: Business | undefined): boolean {\n return Boolean(b && (b.phone || b.email || b.address?.street || b.address?.city || b.kvk || (b.hours?.length ?? 0) > 0));\n}\n\n/**\n * LocalBusiness-JSON-LD uit het profiel, of null als er (nog) niets in staat. Zelfde `@id` als de\n * contact- en openingstijdenblokken (`#business`), zodat Google alles samenvoegt tot één bedrijf.\n */\nexport function bedrijfJsonLd(\n b: Business | undefined,\n site: { name?: string; domain?: string; logo?: string },\n): Record<string, unknown> | null {\n if (!heeftProfiel(b) || !b) return null;\n const base = site.domain ? `https://${site.domain.replace(/^https?:\\/\\//, \"\").replace(/\\/+$/, \"\")}` : undefined;\n const out: Record<string, unknown> = {\n \"@context\": \"https://schema.org\",\n \"@type\": b.type || \"LocalBusiness\",\n \"@id\": \"#business\",\n name: b.name || site.name,\n };\n if (b.description) out.description = b.description;\n if (base) out.url = base;\n if (site.logo) out.image = site.logo;\n if (b.phone) out.telephone = b.phone;\n if (b.email) out.email = b.email;\n if (b.priceRange) out.priceRange = b.priceRange;\n const a = b.address;\n if (a && (a.street || a.city || a.postalCode)) {\n out.address = {\n \"@type\": \"PostalAddress\",\n ...(a.street ? { streetAddress: a.street } : {}),\n ...(a.postalCode ? { postalCode: a.postalCode } : {}),\n ...(a.city ? { addressLocality: a.city } : {}),\n addressCountry: a.country?.trim() || \"NL\",\n };\n }\n if (b.geo) out.geo = { \"@type\": \"GeoCoordinates\", latitude: b.geo.lat, longitude: b.geo.lng };\n const spec = openingHoursSpecJsonLd(b.hours ?? []);\n if (spec.length) out.openingHoursSpecification = spec;\n // Afwijkende dagen (feestdagen, vakantie) — Google toont ze als \"aangepaste openingstijden\".\n const vandaag = isoDatum(new Date());\n const speciaal = (b.exceptions ?? [])\n .filter((x) => (x.until ?? x.date) >= vandaag)\n .map((x) => {\n const bereik = parseTimeRange(x.hours);\n const fmt = (m: number) => `${String(Math.floor(m / 60) % 24).padStart(2, \"0\")}:${String(m % 60).padStart(2, \"0\")}`;\n return {\n \"@type\": \"OpeningHoursSpecification\",\n validFrom: x.date,\n validThrough: x.until ?? x.date,\n opens: bereik ? fmt(bereik.open) : \"00:00\",\n closes: bereik ? fmt(bereik.close) : \"00:00\",\n };\n });\n if (speciaal.length) out.specialOpeningHoursSpecification = speciaal;\n if (b.vat?.trim()) out.vatID = b.vat.trim();\n if (b.kvk?.trim()) out.identifier = { \"@type\": \"PropertyValue\", propertyID: \"KvK\", value: b.kvk.trim() };\n const sameAs = (b.sameAs ?? []).filter((u) => /^https?:\\/\\//.test(u));\n if (sameAs.length) out.sameAs = sameAs;\n return out;\n}\n"],"mappings":"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","names":[]}
@@ -53,6 +53,14 @@ function ContactRender({ block, mode }) {
53
53
  /* @__PURE__ */ jsx("dt", { children: "WhatsApp" }),
54
54
  /* @__PURE__ */ jsx("dd", { children: /* @__PURE__ */ jsx("a", { href: `https://wa.me/${b.whatsapp.replace(/[^\d]/g, "")}`, target: "_blank", rel: "noopener noreferrer", children: b.whatsapp }) })
55
55
  ] }),
56
+ block.bedrijfsnummers !== false && b?.kvk && /* @__PURE__ */ jsxs("div", { className: "xicms-contact__row", children: [
57
+ /* @__PURE__ */ jsx("dt", { children: "KvK" }),
58
+ /* @__PURE__ */ jsx("dd", { children: b.kvk })
59
+ ] }),
60
+ block.bedrijfsnummers !== false && b?.vat && /* @__PURE__ */ jsxs("div", { className: "xicms-contact__row", children: [
61
+ /* @__PURE__ */ jsx("dt", { children: "Btw-nummer" }),
62
+ /* @__PURE__ */ jsx("dd", { children: b.vat })
63
+ ] }),
56
64
  block.hours && /* @__PURE__ */ jsxs("div", { className: "xicms-contact__row", children: [
57
65
  /* @__PURE__ */ jsx("dt", { children: "Openingstijden" }),
58
66
  /* @__PURE__ */ jsx("dd", { "data-xicms-edit": "hours", children: block.hours })
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/blocks/builtins/ContactRender.tsx"],"sourcesContent":["\"use client\";\n\nimport type { z } from \"zod\";\nimport { jsonLdHtml } from \"./jsonld\";\nimport type { ContactBlockSchema, SocialBlockSchema } from \"../../schema/blocks\";\nimport type { BlockRenderProps } from \"../registry\";\nimport { Heading } from \"../ui\";\nimport { useMergedSiteConfig } from \"../../settings/useSiteSettings\";\nimport { adresRegels, routeLink, telLink } from \"../../bedrijf/bedrijf\";\n\n/* ── Contact ─────────────────────────────────────────────────────────────── */\ntype Contact = z.infer<typeof ContactBlockSchema>;\nexport function contactJsonLd(b: Contact): Record<string, unknown> {\n return {\n \"@context\": \"https://schema.org\",\n \"@type\": \"LocalBusiness\",\n // Gedeeld relatief @id: contact-, openingstijden- en layout-JSON-LD beschrijven hetzelfde\n // bedrijf; zonder @id ziet Google drie conflicterende LocalBusiness-entiteiten per pagina.\n \"@id\": \"#business\",\n // Bewust géén `name: heading` — de blokkop is meestal \"Contact\" en zou als bedrijfsnaam in de\n // samengevoegde #business-entiteit belanden; de naam komt uit de layout-brede businessJsonLd.\n ...(b.address ? { address: b.address } : {}),\n ...(b.phone ? { telephone: b.phone } : {}),\n ...(b.email ? { email: b.email } : {}),\n ...(b.geo ? { geo: { \"@type\": \"GeoCoordinates\", latitude: b.geo.lat, longitude: b.geo.lng } } : {}),\n };\n}\nexport function ContactRender({ block, mode }: BlockRenderProps<Contact>) {\n /**\n * 8.13: een leeg veld valt terug op het bedrijfsprofiel (Instellingen → Bedrijf). Een eigen\n * waarde in het blok wint en blijft op de pagina bewerkbaar; een waarde uit het profiel niet\n * (dan zou je hier iets wijzigen dat elders blijft staan).\n */\n const b = useMergedSiteConfig().business;\n const adres = block.address || adresRegels(b).join(\", \");\n const telefoon = block.phone || b?.phone;\n const email = block.email || b?.email;\n const route = !block.address ? routeLink(b) : undefined;\n if (!adres && !telefoon && !email && !block.hours && !block.heading) {\n return mode === \"preview\" ? <p className=\"xicms-ve__hint\">Contact: vul je gegevens één keer in bij Instellingen → Bedrijf, of geef dit blok eigen gegevens.</p> : null;\n }\n return (\n <section className=\"xicms-contact\">\n {block.heading && <Heading level={2} data-xicms-edit=\"heading\">{block.heading}</Heading>}\n <dl className=\"xicms-contact__list\">\n {adres && (\n <div className=\"xicms-contact__row\">\n <dt>Adres</dt>\n <dd>\n <span data-xicms-edit={block.address ? \"address\" : undefined}>{adres}</span>\n {route && (\n <>\n {\" · \"}\n <a href={route} target=\"_blank\" rel=\"noopener noreferrer\">Route plannen</a>\n </>\n )}\n </dd>\n </div>\n )}\n {telefoon && (\n <div className=\"xicms-contact__row\">\n <dt>Telefoon</dt>\n <dd><a href={telLink(telefoon)} data-xicms-edit={block.phone ? \"phone\" : undefined}>{telefoon}</a></dd>\n </div>\n )}\n {email && (\n <div className=\"xicms-contact__row\">\n <dt>E-mail</dt>\n <dd><a href={`mailto:${email}`} data-xicms-edit={block.email ? \"email\" : undefined}>{email}</a></dd>\n </div>\n )}\n {!block.phone && b?.whatsapp && (\n <div className=\"xicms-contact__row\">\n <dt>WhatsApp</dt>\n <dd><a href={`https://wa.me/${b.whatsapp.replace(/[^\\d]/g, \"\")}`} target=\"_blank\" rel=\"noopener noreferrer\">{b.whatsapp}</a></dd>\n </div>\n )}\n {block.hours && (\n <div className=\"xicms-contact__row\">\n <dt>Openingstijden</dt>\n <dd data-xicms-edit=\"hours\">{block.hours}</dd>\n </div>\n )}\n </dl>\n {block.jsonLd && (\n <script\n type=\"application/ld+json\"\n // eslint-disable-next-line react/no-danger\n dangerouslySetInnerHTML={{ __html: jsonLdHtml(contactJsonLd(block)) }}\n />\n )}\n </section>\n );\n}\n/* ── Social ──────────────────────────────────────────────────────────────── */\ntype Social = z.infer<typeof SocialBlockSchema>;\n\n/**\n * Minimalistische, monochrome merk-iconen (currentColor) — geen icon-dependency nodig.\n * Onbekende platformen vallen terug op de tekstweergave, dus niets breekt.\n */\nfunction SocialIcon({ platform }: { platform: string }) {\n const p = platform.toLowerCase();\n const common = { width: 22, height: 22, viewBox: \"0 0 24 24\", \"aria-hidden\": true } as const;\n if (p.includes(\"instagram\")) {\n return (\n <svg {...common} fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.8\">\n <rect x=\"4\" y=\"4\" width=\"16\" height=\"16\" rx=\"4.5\" />\n <circle cx=\"12\" cy=\"12\" r=\"3.6\" />\n <circle cx=\"16.7\" cy=\"7.3\" r=\"1.1\" fill=\"currentColor\" stroke=\"none\" />\n </svg>\n );\n }\n if (p.includes(\"facebook\")) {\n return (\n <svg {...common} fill=\"currentColor\">\n <path d=\"M13.5 20v-6.2h2.1l.4-2.6h-2.5V9.6c0-.8.3-1.3 1.3-1.3h1.3V6c-.3 0-1-.1-1.9-.1-2 0-3.3 1.2-3.3 3.4v1.9H8.7v2.6h2.2V20h2.6z\" />\n </svg>\n );\n }\n if (p.includes(\"linkedin\")) {\n return (\n <svg {...common} fill=\"currentColor\">\n <circle cx=\"6\" cy=\"6.4\" r=\"1.8\" />\n <rect x=\"4.6\" y=\"9.6\" width=\"2.8\" height=\"9.9\" />\n <path d=\"M10.4 9.6h2.7v1.4c.5-.9 1.6-1.7 3.1-1.7 2.4 0 3.7 1.5 3.7 4.3v5.9h-2.8v-5.4c0-1.5-.6-2.3-1.7-2.3-1.2 0-2.2.8-2.2 2.4v5.3h-2.8V9.6z\" />\n </svg>\n );\n }\n if (p.includes(\"twitter\") || p === \"x\" || p.startsWith(\"x \") || p.includes(\"(x)\")) {\n return (\n <svg {...common} fill=\"currentColor\">\n <path d=\"M4.5 4.5h3.6l4.1 5.6 4.7-5.6h2.6l-6.1 7.2 6.4 8.4h-3.6l-4.4-6-5 6H4.2l6.5-7.7-6.2-7.9z\" />\n </svg>\n );\n }\n if (p.includes(\"youtube\")) {\n return (\n <svg {...common} fill=\"currentColor\">\n <rect x=\"3.2\" y=\"6.2\" width=\"17.6\" height=\"11.6\" rx=\"3.2\" />\n <path d=\"M10.4 9.4v5.2l4.8-2.6z\" fill=\"var(--xicms-surface, #fff)\" />\n </svg>\n );\n }\n if (p.includes(\"whatsapp\")) {\n return (\n <svg {...common} fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.8\">\n <path d=\"M12 4.2a7.8 7.8 0 0 0-6.7 11.8L4.4 19.6l3.7-.9A7.8 7.8 0 1 0 12 4.2z\" />\n <path d=\"M9.3 8.9c.7 2.4 2.6 4.4 5 5l1-1.2 2 1.2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n </svg>\n );\n }\n if (p.includes(\"tiktok\")) {\n return (\n <svg {...common} fill=\"currentColor\">\n <path d=\"M14.5 4h2.6c.2 1.7 1.3 3 3 3.3v2.6a6.6 6.6 0 0 1-3.2-1v5.7a5.4 5.4 0 1 1-5.4-5.4c.2 0 .5 0 .7.1v2.8a2.6 2.6 0 1 0 2.3 2.6V4z\" />\n </svg>\n );\n }\n return null;\n}\n\nexport function SocialRender({ block }: BlockRenderProps<Social>) {\n const items = (block.items ?? []).filter((i) => i.href && i.platform);\n if (items.length === 0) return null;\n return (\n <section className=\"xicms-social\">\n {block.heading && <Heading level={2} data-xicms-edit=\"heading\">{block.heading}</Heading>}\n <ul className=\"xicms-social__list\">\n {items.map((it, i) => {\n // Als functie aangeroepen (geen hooks) zodat `null` echt null is — een JSX-element\n // zou altijd truthy zijn en de tekst-fallback nooit tonen.\n const icon = SocialIcon({ platform: it.platform });\n return (\n <li key={i}>\n <a href={it.href} rel=\"noopener noreferrer\" target=\"_blank\" aria-label={it.platform} title={it.platform} className={icon ? \"xicms-social__iconlink\" : undefined}>\n {icon ?? <span data-xicms-edit={`items.${i}.platform`}>{it.platform}</span>}\n </a>\n </li>\n );\n })}\n </ul>\n </section>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../../src/blocks/builtins/ContactRender.tsx"],"sourcesContent":["\"use client\";\n\nimport type { z } from \"zod\";\nimport { jsonLdHtml } from \"./jsonld\";\nimport type { ContactBlockSchema, SocialBlockSchema } from \"../../schema/blocks\";\nimport type { BlockRenderProps } from \"../registry\";\nimport { Heading } from \"../ui\";\nimport { useMergedSiteConfig } from \"../../settings/useSiteSettings\";\nimport { adresRegels, routeLink, telLink } from \"../../bedrijf/bedrijf\";\n\n/* ── Contact ─────────────────────────────────────────────────────────────── */\ntype Contact = z.infer<typeof ContactBlockSchema>;\nexport function contactJsonLd(b: Contact): Record<string, unknown> {\n return {\n \"@context\": \"https://schema.org\",\n \"@type\": \"LocalBusiness\",\n // Gedeeld relatief @id: contact-, openingstijden- en layout-JSON-LD beschrijven hetzelfde\n // bedrijf; zonder @id ziet Google drie conflicterende LocalBusiness-entiteiten per pagina.\n \"@id\": \"#business\",\n // Bewust géén `name: heading` — de blokkop is meestal \"Contact\" en zou als bedrijfsnaam in de\n // samengevoegde #business-entiteit belanden; de naam komt uit de layout-brede businessJsonLd.\n ...(b.address ? { address: b.address } : {}),\n ...(b.phone ? { telephone: b.phone } : {}),\n ...(b.email ? { email: b.email } : {}),\n ...(b.geo ? { geo: { \"@type\": \"GeoCoordinates\", latitude: b.geo.lat, longitude: b.geo.lng } } : {}),\n };\n}\nexport function ContactRender({ block, mode }: BlockRenderProps<Contact>) {\n /**\n * 8.13: een leeg veld valt terug op het bedrijfsprofiel (Instellingen → Bedrijf). Een eigen\n * waarde in het blok wint en blijft op de pagina bewerkbaar; een waarde uit het profiel niet\n * (dan zou je hier iets wijzigen dat elders blijft staan).\n */\n const b = useMergedSiteConfig().business;\n const adres = block.address || adresRegels(b).join(\", \");\n const telefoon = block.phone || b?.phone;\n const email = block.email || b?.email;\n const route = !block.address ? routeLink(b) : undefined;\n if (!adres && !telefoon && !email && !block.hours && !block.heading) {\n return mode === \"preview\" ? <p className=\"xicms-ve__hint\">Contact: vul je gegevens één keer in bij Instellingen → Bedrijf, of geef dit blok eigen gegevens.</p> : null;\n }\n return (\n <section className=\"xicms-contact\">\n {block.heading && <Heading level={2} data-xicms-edit=\"heading\">{block.heading}</Heading>}\n <dl className=\"xicms-contact__list\">\n {adres && (\n <div className=\"xicms-contact__row\">\n <dt>Adres</dt>\n <dd>\n <span data-xicms-edit={block.address ? \"address\" : undefined}>{adres}</span>\n {route && (\n <>\n {\" · \"}\n <a href={route} target=\"_blank\" rel=\"noopener noreferrer\">Route plannen</a>\n </>\n )}\n </dd>\n </div>\n )}\n {telefoon && (\n <div className=\"xicms-contact__row\">\n <dt>Telefoon</dt>\n <dd><a href={telLink(telefoon)} data-xicms-edit={block.phone ? \"phone\" : undefined}>{telefoon}</a></dd>\n </div>\n )}\n {email && (\n <div className=\"xicms-contact__row\">\n <dt>E-mail</dt>\n <dd><a href={`mailto:${email}`} data-xicms-edit={block.email ? \"email\" : undefined}>{email}</a></dd>\n </div>\n )}\n {!block.phone && b?.whatsapp && (\n <div className=\"xicms-contact__row\">\n <dt>WhatsApp</dt>\n <dd><a href={`https://wa.me/${b.whatsapp.replace(/[^\\d]/g, \"\")}`} target=\"_blank\" rel=\"noopener noreferrer\">{b.whatsapp}</a></dd>\n </div>\n )}\n {block.bedrijfsnummers !== false && b?.kvk && (\n <div className=\"xicms-contact__row\">\n <dt>KvK</dt>\n <dd>{b.kvk}</dd>\n </div>\n )}\n {block.bedrijfsnummers !== false && b?.vat && (\n <div className=\"xicms-contact__row\">\n <dt>Btw-nummer</dt>\n <dd>{b.vat}</dd>\n </div>\n )}\n {block.hours && (\n <div className=\"xicms-contact__row\">\n <dt>Openingstijden</dt>\n <dd data-xicms-edit=\"hours\">{block.hours}</dd>\n </div>\n )}\n </dl>\n {block.jsonLd && (\n <script\n type=\"application/ld+json\"\n // eslint-disable-next-line react/no-danger\n dangerouslySetInnerHTML={{ __html: jsonLdHtml(contactJsonLd(block)) }}\n />\n )}\n </section>\n );\n}\n/* ── Social ──────────────────────────────────────────────────────────────── */\ntype Social = z.infer<typeof SocialBlockSchema>;\n\n/**\n * Minimalistische, monochrome merk-iconen (currentColor) — geen icon-dependency nodig.\n * Onbekende platformen vallen terug op de tekstweergave, dus niets breekt.\n */\nfunction SocialIcon({ platform }: { platform: string }) {\n const p = platform.toLowerCase();\n const common = { width: 22, height: 22, viewBox: \"0 0 24 24\", \"aria-hidden\": true } as const;\n if (p.includes(\"instagram\")) {\n return (\n <svg {...common} fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.8\">\n <rect x=\"4\" y=\"4\" width=\"16\" height=\"16\" rx=\"4.5\" />\n <circle cx=\"12\" cy=\"12\" r=\"3.6\" />\n <circle cx=\"16.7\" cy=\"7.3\" r=\"1.1\" fill=\"currentColor\" stroke=\"none\" />\n </svg>\n );\n }\n if (p.includes(\"facebook\")) {\n return (\n <svg {...common} fill=\"currentColor\">\n <path d=\"M13.5 20v-6.2h2.1l.4-2.6h-2.5V9.6c0-.8.3-1.3 1.3-1.3h1.3V6c-.3 0-1-.1-1.9-.1-2 0-3.3 1.2-3.3 3.4v1.9H8.7v2.6h2.2V20h2.6z\" />\n </svg>\n );\n }\n if (p.includes(\"linkedin\")) {\n return (\n <svg {...common} fill=\"currentColor\">\n <circle cx=\"6\" cy=\"6.4\" r=\"1.8\" />\n <rect x=\"4.6\" y=\"9.6\" width=\"2.8\" height=\"9.9\" />\n <path d=\"M10.4 9.6h2.7v1.4c.5-.9 1.6-1.7 3.1-1.7 2.4 0 3.7 1.5 3.7 4.3v5.9h-2.8v-5.4c0-1.5-.6-2.3-1.7-2.3-1.2 0-2.2.8-2.2 2.4v5.3h-2.8V9.6z\" />\n </svg>\n );\n }\n if (p.includes(\"twitter\") || p === \"x\" || p.startsWith(\"x \") || p.includes(\"(x)\")) {\n return (\n <svg {...common} fill=\"currentColor\">\n <path d=\"M4.5 4.5h3.6l4.1 5.6 4.7-5.6h2.6l-6.1 7.2 6.4 8.4h-3.6l-4.4-6-5 6H4.2l6.5-7.7-6.2-7.9z\" />\n </svg>\n );\n }\n if (p.includes(\"youtube\")) {\n return (\n <svg {...common} fill=\"currentColor\">\n <rect x=\"3.2\" y=\"6.2\" width=\"17.6\" height=\"11.6\" rx=\"3.2\" />\n <path d=\"M10.4 9.4v5.2l4.8-2.6z\" fill=\"var(--xicms-surface, #fff)\" />\n </svg>\n );\n }\n if (p.includes(\"whatsapp\")) {\n return (\n <svg {...common} fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.8\">\n <path d=\"M12 4.2a7.8 7.8 0 0 0-6.7 11.8L4.4 19.6l3.7-.9A7.8 7.8 0 1 0 12 4.2z\" />\n <path d=\"M9.3 8.9c.7 2.4 2.6 4.4 5 5l1-1.2 2 1.2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n </svg>\n );\n }\n if (p.includes(\"tiktok\")) {\n return (\n <svg {...common} fill=\"currentColor\">\n <path d=\"M14.5 4h2.6c.2 1.7 1.3 3 3 3.3v2.6a6.6 6.6 0 0 1-3.2-1v5.7a5.4 5.4 0 1 1-5.4-5.4c.2 0 .5 0 .7.1v2.8a2.6 2.6 0 1 0 2.3 2.6V4z\" />\n </svg>\n );\n }\n return null;\n}\n\nexport function SocialRender({ block }: BlockRenderProps<Social>) {\n const items = (block.items ?? []).filter((i) => i.href && i.platform);\n if (items.length === 0) return null;\n return (\n <section className=\"xicms-social\">\n {block.heading && <Heading level={2} data-xicms-edit=\"heading\">{block.heading}</Heading>}\n <ul className=\"xicms-social__list\">\n {items.map((it, i) => {\n // Als functie aangeroepen (geen hooks) zodat `null` echt null is — een JSX-element\n // zou altijd truthy zijn en de tekst-fallback nooit tonen.\n const icon = SocialIcon({ platform: it.platform });\n return (\n <li key={i}>\n <a href={it.href} rel=\"noopener noreferrer\" target=\"_blank\" aria-label={it.platform} title={it.platform} className={icon ? \"xicms-social__iconlink\" : undefined}>\n {icon ?? <span data-xicms-edit={`items.${i}.platform`}>{it.platform}</span>}\n </a>\n </li>\n );\n })}\n </ul>\n </section>\n );\n}\n"],"mappings":"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","names":[]}
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
  import { jsx, jsxs } from "react/jsx-runtime";
3
+ import { useOnderOffset } from "../../components/onderOffset.js";
3
4
  function waLink(phone, text) {
4
5
  const trimmed = (phone ?? "").trim().replace(/\(0\)/g, "");
5
6
  const digits = trimmed.replace(/\D/g, "");
@@ -9,6 +10,7 @@ function waLink(phone, text) {
9
10
  }
10
11
  function WhatsappChatRender({ block, mode }) {
11
12
  const href = waLink(block.phone, block.prefilledText);
13
+ useOnderOffset(Boolean(href) && mode !== "preview");
12
14
  if (!href) {
13
15
  return mode === "preview" ? /* @__PURE__ */ jsx("p", { className: "xicms-ve__hint", children: "\u{1F4AC} WhatsApp-knop: vul een internationaal telefoonnummer in (bv. +31 6 12345678)." }) : null;
14
16
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/blocks/builtins/WhatsappChatRender.tsx"],"sourcesContent":["\"use client\";\n\nimport type { z } from \"zod\";\nimport type { WhatsappChatBlockSchema } from \"../../schema/blocks\";\nimport type { BlockRenderProps } from \"../registry\";\n\ntype WhatsappChat = z.infer<typeof WhatsappChatBlockSchema>;\n\n/**\n * wa.me accepteert alleen cijfers (internationaal, zonder + of spaties). Een nationaal nummer\n * (\"06-12345678\") heeft geen landcode — die zomaar strippen levert een verkeerd nummer op, dus\n * dan liever geen link (de editor-preview vraagt om internationale notatie).\n */\nexport function waLink(phone: string | undefined, text?: string): string | null {\n // \"(0)\" in \"+31 (0)6 12345678\" is een schrijfconventie, geen kiescijfer — eerst strippen,\n // anders belandt die valse nul midden in het wa.me-nummer.\n const trimmed = (phone ?? \"\").trim().replace(/\\(0\\)/g, \"\");\n const digits = trimmed.replace(/\\D/g, \"\");\n const intl = trimmed.startsWith(\"+\")\n ? digits\n : digits.startsWith(\"00\")\n ? digits.slice(2)\n : digits.startsWith(\"0\")\n ? null\n : digits;\n if (!intl || intl.length < 8 || intl.length > 15) return null;\n return `https://wa.me/${intl}${text ? `?text=${encodeURIComponent(text)}` : \"\"}`;\n}\n\n/**\n * Zwevende WhatsApp-knop — dé laagdrempelige contactknop voor lokale bedrijven. Gewone link\n * (geen embed/scripts), dus geen consent nodig. In de editor-preview inline weergegeven zodat\n * hij het canvas niet overlapt.\n */\nexport function WhatsappChatRender({ block, mode }: BlockRenderProps<WhatsappChat>) {\n const href = waLink(block.phone, block.prefilledText);\n if (!href) {\n return mode === \"preview\" ? <p className=\"xicms-ve__hint\">💬 WhatsApp-knop: vul een internationaal telefoonnummer in (bv. +31 6 12345678).</p> : null;\n }\n const icon = (\n <svg width=\"22\" height=\"22\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.9\" aria-hidden>\n <path d=\"M12 4.2a7.8 7.8 0 0 0-6.7 11.8L4.4 19.6l3.7-.9A7.8 7.8 0 1 0 12 4.2z\" />\n <path d=\"M9.3 8.9c.7 2.4 2.6 4.4 5 5l1-1.2 2 1.2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n </svg>\n );\n return (\n <a\n className=\"xicms-whatsapp\"\n data-position={block.position}\n data-preview={mode === \"preview\" ? \"1\" : undefined}\n href={href}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n aria-label={block.label || \"Chat via WhatsApp\"}\n >\n {icon}\n {block.label && <span className=\"xicms-whatsapp__label\" data-xicms-edit=\"label\">{block.label}</span>}\n </a>\n );\n}\n"],"mappings":";AAqCgC,cAG5B,YAH4B;AAxBzB,SAAS,OAAO,OAA2B,MAA8B;AAG9E,QAAM,WAAW,SAAS,IAAI,KAAK,EAAE,QAAQ,UAAU,EAAE;AACzD,QAAM,SAAS,QAAQ,QAAQ,OAAO,EAAE;AACxC,QAAM,OAAO,QAAQ,WAAW,GAAG,IAC/B,SACA,OAAO,WAAW,IAAI,IACpB,OAAO,MAAM,CAAC,IACd,OAAO,WAAW,GAAG,IACnB,OACA;AACR,MAAI,CAAC,QAAQ,KAAK,SAAS,KAAK,KAAK,SAAS,GAAI,QAAO;AACzD,SAAO,iBAAiB,IAAI,GAAG,OAAO,SAAS,mBAAmB,IAAI,CAAC,KAAK,EAAE;AAChF;AAOO,SAAS,mBAAmB,EAAE,OAAO,KAAK,GAAmC;AAClF,QAAM,OAAO,OAAO,MAAM,OAAO,MAAM,aAAa;AACpD,MAAI,CAAC,MAAM;AACT,WAAO,SAAS,YAAY,oBAAC,OAAE,WAAU,kBAAiB,qGAAgF,IAAO;AAAA,EACnJ;AACA,QAAM,OACJ,qBAAC,SAAI,OAAM,MAAK,QAAO,MAAK,SAAQ,aAAY,MAAK,QAAO,QAAO,gBAAe,aAAY,OAAM,eAAW,MAC7G;AAAA,wBAAC,UAAK,GAAE,wEAAuE;AAAA,IAC/E,oBAAC,UAAK,GAAE,2CAA0C,eAAc,SAAQ,gBAAe,SAAQ;AAAA,KACjG;AAEF,SACE;AAAA,IAAC;AAAA;AAAA,MACC,WAAU;AAAA,MACV,iBAAe,MAAM;AAAA,MACrB,gBAAc,SAAS,YAAY,MAAM;AAAA,MACzC;AAAA,MACA,QAAO;AAAA,MACP,KAAI;AAAA,MACJ,cAAY,MAAM,SAAS;AAAA,MAE1B;AAAA;AAAA,QACA,MAAM,SAAS,oBAAC,UAAK,WAAU,yBAAwB,mBAAgB,SAAS,gBAAM,OAAM;AAAA;AAAA;AAAA,EAC/F;AAEJ;","names":[]}
1
+ {"version":3,"sources":["../../../src/blocks/builtins/WhatsappChatRender.tsx"],"sourcesContent":["\"use client\";\n\nimport { useOnderOffset } from \"../../components/onderOffset\";\nimport type { z } from \"zod\";\nimport type { WhatsappChatBlockSchema } from \"../../schema/blocks\";\nimport type { BlockRenderProps } from \"../registry\";\n\ntype WhatsappChat = z.infer<typeof WhatsappChatBlockSchema>;\n\n/**\n * wa.me accepteert alleen cijfers (internationaal, zonder + of spaties). Een nationaal nummer\n * (\"06-12345678\") heeft geen landcode — die zomaar strippen levert een verkeerd nummer op, dus\n * dan liever geen link (de editor-preview vraagt om internationale notatie).\n */\nexport function waLink(phone: string | undefined, text?: string): string | null {\n // \"(0)\" in \"+31 (0)6 12345678\" is een schrijfconventie, geen kiescijfer — eerst strippen,\n // anders belandt die valse nul midden in het wa.me-nummer.\n const trimmed = (phone ?? \"\").trim().replace(/\\(0\\)/g, \"\");\n const digits = trimmed.replace(/\\D/g, \"\");\n const intl = trimmed.startsWith(\"+\")\n ? digits\n : digits.startsWith(\"00\")\n ? digits.slice(2)\n : digits.startsWith(\"0\")\n ? null\n : digits;\n if (!intl || intl.length < 8 || intl.length > 15) return null;\n return `https://wa.me/${intl}${text ? `?text=${encodeURIComponent(text)}` : \"\"}`;\n}\n\n/**\n * Zwevende WhatsApp-knop — dé laagdrempelige contactknop voor lokale bedrijven. Gewone link\n * (geen embed/scripts), dus geen consent nodig. In de editor-preview inline weergegeven zodat\n * hij het canvas niet overlapt.\n */\nexport function WhatsappChatRender({ block, mode }: BlockRenderProps<WhatsappChat>) {\n const href = waLink(block.phone, block.prefilledText);\n // 8.16: boven een vaste balk onderaan (belbalk, demobalk), niet eroverheen.\n useOnderOffset(Boolean(href) && mode !== \"preview\");\n if (!href) {\n return mode === \"preview\" ? <p className=\"xicms-ve__hint\">💬 WhatsApp-knop: vul een internationaal telefoonnummer in (bv. +31 6 12345678).</p> : null;\n }\n const icon = (\n <svg width=\"22\" height=\"22\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.9\" aria-hidden>\n <path d=\"M12 4.2a7.8 7.8 0 0 0-6.7 11.8L4.4 19.6l3.7-.9A7.8 7.8 0 1 0 12 4.2z\" />\n <path d=\"M9.3 8.9c.7 2.4 2.6 4.4 5 5l1-1.2 2 1.2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n </svg>\n );\n return (\n <a\n className=\"xicms-whatsapp\"\n data-position={block.position}\n data-preview={mode === \"preview\" ? \"1\" : undefined}\n href={href}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n aria-label={block.label || \"Chat via WhatsApp\"}\n >\n {icon}\n {block.label && <span className=\"xicms-whatsapp__label\" data-xicms-edit=\"label\">{block.label}</span>}\n </a>\n );\n}\n"],"mappings":";AAwCgC,cAG5B,YAH4B;AAtChC,SAAS,sBAAsB;AAYxB,SAAS,OAAO,OAA2B,MAA8B;AAG9E,QAAM,WAAW,SAAS,IAAI,KAAK,EAAE,QAAQ,UAAU,EAAE;AACzD,QAAM,SAAS,QAAQ,QAAQ,OAAO,EAAE;AACxC,QAAM,OAAO,QAAQ,WAAW,GAAG,IAC/B,SACA,OAAO,WAAW,IAAI,IACpB,OAAO,MAAM,CAAC,IACd,OAAO,WAAW,GAAG,IACnB,OACA;AACR,MAAI,CAAC,QAAQ,KAAK,SAAS,KAAK,KAAK,SAAS,GAAI,QAAO;AACzD,SAAO,iBAAiB,IAAI,GAAG,OAAO,SAAS,mBAAmB,IAAI,CAAC,KAAK,EAAE;AAChF;AAOO,SAAS,mBAAmB,EAAE,OAAO,KAAK,GAAmC;AAClF,QAAM,OAAO,OAAO,MAAM,OAAO,MAAM,aAAa;AAEpD,iBAAe,QAAQ,IAAI,KAAK,SAAS,SAAS;AAClD,MAAI,CAAC,MAAM;AACT,WAAO,SAAS,YAAY,oBAAC,OAAE,WAAU,kBAAiB,qGAAgF,IAAO;AAAA,EACnJ;AACA,QAAM,OACJ,qBAAC,SAAI,OAAM,MAAK,QAAO,MAAK,SAAQ,aAAY,MAAK,QAAO,QAAO,gBAAe,aAAY,OAAM,eAAW,MAC7G;AAAA,wBAAC,UAAK,GAAE,wEAAuE;AAAA,IAC/E,oBAAC,UAAK,GAAE,2CAA0C,eAAc,SAAQ,gBAAe,SAAQ;AAAA,KACjG;AAEF,SACE;AAAA,IAAC;AAAA;AAAA,MACC,WAAU;AAAA,MACV,iBAAe,MAAM;AAAA,MACrB,gBAAc,SAAS,YAAY,MAAM;AAAA,MACzC;AAAA,MACA,QAAO;AAAA,MACP,KAAI;AAAA,MACJ,cAAY,MAAM,SAAS;AAAA,MAE1B;AAAA;AAAA,QACA,MAAM,SAAS,oBAAC,UAAK,WAAU,yBAAwB,mBAAgB,SAAS,gBAAM,OAAM;AAAA;AAAA;AAAA,EAC/F;AAEJ;","names":[]}
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
- import { useConsent } from "../../modules/consent/useConsent.js";
4
- import { useModuleActive } from "../../modules/context.js";
3
+ import { useConsent, useMeldConsentNodig } from "../../modules/consent/useConsent.js";
4
+ import { useModuleActive, useModuleSettings } from "../../modules/context.js";
5
5
  function ConsentEmbed({
6
6
  gated,
7
7
  category = "marketing",
@@ -9,8 +9,11 @@ function ConsentEmbed({
9
9
  label = "Externe inhoud",
10
10
  children
11
11
  }) {
12
- const { has } = useConsent();
12
+ const { has, state, save } = useConsent();
13
13
  const consentActive = useModuleActive("consent");
14
+ const { version } = useModuleSettings("consent");
15
+ const wacht = !preview && gated && consentActive && !has(category);
16
+ useMeldConsentNodig(category, wacht);
14
17
  if (preview) {
15
18
  return /* @__PURE__ */ jsx("div", { className: "xicms-embed-consent xicms-embed-preview", role: "note", "aria-label": `${label} (voorbeeld)`, children: /* @__PURE__ */ jsxs("p", { children: [
16
19
  "\u{1F50C} ",
@@ -18,12 +21,26 @@ function ConsentEmbed({
18
21
  " \u2014 wordt op de live site geladen (niet in de editor)."
19
22
  ] }) });
20
23
  }
21
- if (!gated || !consentActive || has(category)) return /* @__PURE__ */ jsx(Fragment, { children });
22
- return /* @__PURE__ */ jsx("div", { className: "xicms-embed-consent", role: "note", children: /* @__PURE__ */ jsxs("p", { children: [
23
- "Accepteer cookies (",
24
- category,
25
- ") om deze inhoud te laden."
26
- ] }) });
24
+ if (!wacht) return /* @__PURE__ */ jsx(Fragment, { children });
25
+ const toestaan = () => save({ ...state.categories, necessary: true, [category]: true }, version);
26
+ return /* @__PURE__ */ jsxs("div", { className: "xicms-embed-consent", role: "note", children: [
27
+ /* @__PURE__ */ jsxs("div", { className: "xicms-embed-consent__tekst", children: [
28
+ /* @__PURE__ */ jsx("p", { className: "xicms-embed-consent__titel", children: label }),
29
+ /* @__PURE__ */ jsx("p", { children: "Dit komt van een andere partij en kan cookies plaatsen. Daarom laden we het pas als je dat toestaat." })
30
+ ] }),
31
+ /* @__PURE__ */ jsxs("div", { className: "xicms-embed-consent__knoppen", children: [
32
+ /* @__PURE__ */ jsx("button", { type: "button", className: "xicms-consent__btn xicms-consent__btn--primary", onClick: toestaan, children: "Toestaan en laden" }),
33
+ /* @__PURE__ */ jsx(
34
+ "button",
35
+ {
36
+ type: "button",
37
+ className: "xicms-consent__reopen-link",
38
+ onClick: () => window.dispatchEvent(new Event("xicms:consent:open")),
39
+ children: "Cookie-instellingen"
40
+ }
41
+ )
42
+ ] })
43
+ ] });
27
44
  }
28
45
  export {
29
46
  ConsentEmbed
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/blocks/builtins/consentEmbed.tsx"],"sourcesContent":["\"use client\";\n\nimport type { ReactNode } from \"react\";\nimport { useConsent } from \"../../modules/consent/useConsent\";\nimport { useModuleActive } from \"../../modules/context\";\n\n/**\n * Toont een externe embed (video/kaart/booking) meteen als gating uitstaat óf de consent-module niet\n * actief is; staat consent wél aan, dan pas na toestemming voor de categorie. Anders een nette\n * placeholder i.p.v. de tracker stilletjes te laden (AVG).\n *\n * `preview` (editor): mount NOOIT de echte iframe/het script, maar toon een lichte placeholder. Dit\n * is het `mode === \"preview\"`-contract: in de pagina-editor draaien geen third-party scripts/iframes\n * (geen ResizeObservers, geen externe widgets) — dat houdt de editor licht én voorkomt crashes.\n */\nexport function ConsentEmbed({\n gated,\n category = \"marketing\",\n preview = false,\n label = \"Externe inhoud\",\n children,\n}: {\n gated: boolean;\n category?: string;\n preview?: boolean;\n label?: string;\n children: ReactNode;\n}) {\n const { has } = useConsent();\n const consentActive = useModuleActive(\"consent\");\n if (preview) {\n return (\n <div className=\"xicms-embed-consent xicms-embed-preview\" role=\"note\" aria-label={`${label} (voorbeeld)`}>\n <p>🔌 {label} — wordt op de live site geladen (niet in de editor).</p>\n </div>\n );\n }\n if (!gated || !consentActive || has(category)) return <>{children}</>;\n return (\n <div className=\"xicms-embed-consent\" role=\"note\">\n <p>Accepteer cookies ({category}) om deze inhoud te laden.</p>\n </div>\n );\n}\n"],"mappings":";AAgCM,SAKkD,UALlD,KACE,YADF;AA7BN,SAAS,kBAAkB;AAC3B,SAAS,uBAAuB;AAWzB,SAAS,aAAa;AAAA,EAC3B;AAAA,EACA,WAAW;AAAA,EACX,UAAU;AAAA,EACV,QAAQ;AAAA,EACR;AACF,GAMG;AACD,QAAM,EAAE,IAAI,IAAI,WAAW;AAC3B,QAAM,gBAAgB,gBAAgB,SAAS;AAC/C,MAAI,SAAS;AACX,WACE,oBAAC,SAAI,WAAU,2CAA0C,MAAK,QAAO,cAAY,GAAG,KAAK,gBACvF,+BAAC,OAAE;AAAA;AAAA,MAAI;AAAA,MAAM;AAAA,OAAqD,GACpE;AAAA,EAEJ;AACA,MAAI,CAAC,SAAS,CAAC,iBAAiB,IAAI,QAAQ,EAAG,QAAO,gCAAG,UAAS;AAClE,SACE,oBAAC,SAAI,WAAU,uBAAsB,MAAK,QACxC,+BAAC,OAAE;AAAA;AAAA,IAAoB;AAAA,IAAS;AAAA,KAA0B,GAC5D;AAEJ;","names":[]}
1
+ {"version":3,"sources":["../../../src/blocks/builtins/consentEmbed.tsx"],"sourcesContent":["\"use client\";\n\nimport type { ReactNode } from \"react\";\nimport { useConsent, useMeldConsentNodig } from \"../../modules/consent/useConsent\";\nimport { useModuleActive, useModuleSettings } from \"../../modules/context\";\n\n/**\n * Toont een externe embed (video/kaart/booking) meteen als gating uitstaat óf de consent-module niet\n * actief is; staat consent wél aan, dan pas na toestemming voor de categorie. Anders een nette\n * placeholder i.p.v. de tracker stilletjes te laden (AVG).\n *\n * `preview` (editor): mount NOOIT de echte iframe/het script, maar toon een lichte placeholder. Dit\n * is het `mode === \"preview\"`-contract: in de pagina-editor draaien geen third-party scripts/iframes\n * (geen ResizeObservers, geen externe widgets) — dat houdt de editor licht én voorkomt crashes.\n */\nexport function ConsentEmbed({\n gated,\n category = \"marketing\",\n preview = false,\n label = \"Externe inhoud\",\n children,\n}: {\n gated: boolean;\n category?: string;\n preview?: boolean;\n label?: string;\n children: ReactNode;\n}) {\n const { has, state, save } = useConsent();\n const consentActive = useModuleActive(\"consent\");\n const { version } = useModuleSettings<{ version?: string }>(\"consent\");\n const wacht = !preview && gated && consentActive && !has(category);\n // 8.16: zolang dit op toestemming wacht, mag de cookiebanner verschijnen (zie useConsent).\n useMeldConsentNodig(category, wacht);\n if (preview) {\n return (\n <div className=\"xicms-embed-consent xicms-embed-preview\" role=\"note\" aria-label={`${label} (voorbeeld)`}>\n <p>🔌 {label} — wordt op de live site geladen (niet in de editor).</p>\n </div>\n );\n }\n if (!wacht) return <>{children}</>;\n // 8.16: direct hier toestaan, zonder eerst de banner te zoeken. Telt als keuze voor deze\n // categorie; de rest van de eerdere keuze blijft staan.\n const toestaan = () => save({ ...state.categories, necessary: true, [category]: true }, version);\n return (\n <div className=\"xicms-embed-consent\" role=\"note\">\n <div className=\"xicms-embed-consent__tekst\">\n <p className=\"xicms-embed-consent__titel\">{label}</p>\n <p>Dit komt van een andere partij en kan cookies plaatsen. Daarom laden we het pas als je dat toestaat.</p>\n </div>\n <div className=\"xicms-embed-consent__knoppen\">\n <button type=\"button\" className=\"xicms-consent__btn xicms-consent__btn--primary\" onClick={toestaan}>\n Toestaan en laden\n </button>\n <button\n type=\"button\"\n className=\"xicms-consent__reopen-link\"\n onClick={() => window.dispatchEvent(new Event(\"xicms:consent:open\"))}\n >\n Cookie-instellingen\n </button>\n </div>\n </div>\n );\n}\n"],"mappings":";AAoCM,SAKe,UALf,KACE,YADF;AAjCN,SAAS,YAAY,2BAA2B;AAChD,SAAS,iBAAiB,yBAAyB;AAW5C,SAAS,aAAa;AAAA,EAC3B;AAAA,EACA,WAAW;AAAA,EACX,UAAU;AAAA,EACV,QAAQ;AAAA,EACR;AACF,GAMG;AACD,QAAM,EAAE,KAAK,OAAO,KAAK,IAAI,WAAW;AACxC,QAAM,gBAAgB,gBAAgB,SAAS;AAC/C,QAAM,EAAE,QAAQ,IAAI,kBAAwC,SAAS;AACrE,QAAM,QAAQ,CAAC,WAAW,SAAS,iBAAiB,CAAC,IAAI,QAAQ;AAEjE,sBAAoB,UAAU,KAAK;AACnC,MAAI,SAAS;AACX,WACE,oBAAC,SAAI,WAAU,2CAA0C,MAAK,QAAO,cAAY,GAAG,KAAK,gBACvF,+BAAC,OAAE;AAAA;AAAA,MAAI;AAAA,MAAM;AAAA,OAAqD,GACpE;AAAA,EAEJ;AACA,MAAI,CAAC,MAAO,QAAO,gCAAG,UAAS;AAG/B,QAAM,WAAW,MAAM,KAAK,EAAE,GAAG,MAAM,YAAY,WAAW,MAAM,CAAC,QAAQ,GAAG,KAAK,GAAG,OAAO;AAC/F,SACE,qBAAC,SAAI,WAAU,uBAAsB,MAAK,QACxC;AAAA,yBAAC,SAAI,WAAU,8BACb;AAAA,0BAAC,OAAE,WAAU,8BAA8B,iBAAM;AAAA,MACjD,oBAAC,OAAE,kHAAoG;AAAA,OACzG;AAAA,IACA,qBAAC,SAAI,WAAU,gCACb;AAAA,0BAAC,YAAO,MAAK,UAAS,WAAU,kDAAiD,SAAS,UAAU,+BAEpG;AAAA,MACA;AAAA,QAAC;AAAA;AAAA,UACC,MAAK;AAAA,UACL,WAAU;AAAA,UACV,SAAS,MAAM,OAAO,cAAc,IAAI,MAAM,oBAAoB,CAAC;AAAA,UACpE;AAAA;AAAA,MAED;AAAA,OACF;AAAA,KACF;AAEJ;","names":[]}
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Hoe hoog zijn de vaste balken onderaan het venster? → `--xicms-onder` op `:root` (8.16).
3
+ *
4
+ * De cookiebanner stond `bottom: 0`, en op een telefoon staat daar bijna altijd al iets: de
5
+ * demobalk, de belbalk van SiteHeader, of een eigen "Maak een afspraak"-balk van de klantsite. Op
6
+ * barbier lag de demobalk precies over de knoppen van de banner — de bezoeker kón geen keuze maken.
7
+ *
8
+ * Net als `useBalkOffset` (bovenkant) meten we in plaats van te gokken: elke klantsite noemt zijn
9
+ * balk anders. Telt als balk: `position: fixed`, zichtbaar, raakt de onderrand, minstens 60% van de
10
+ * breedte en niet hoger dan 40% van het venster (geen schermvullende overlay). Een balk die pas bij
11
+ * het scrollen inschuift wordt opnieuw gemeten zodra zijn klasse/stijl verandert of de overgang klaar is.
12
+ */
13
+ export declare function useOnderOffset(actief?: boolean): void;