@xiwebdesign/xicms-core 7.23.0 → 7.25.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 (105) hide show
  1. package/dist/admin/AdminApp.js +2 -1
  2. package/dist/admin/AdminApp.js.map +1 -1
  3. package/dist/admin/CoreVersion.d.ts +6 -0
  4. package/dist/admin/CoreVersion.js +42 -1
  5. package/dist/admin/CoreVersion.js.map +1 -1
  6. package/dist/admin/SuperadminPanel.js +19 -3
  7. package/dist/admin/SuperadminPanel.js.map +1 -1
  8. package/dist/admin/dashboard/DashboardPanel.js +46 -9
  9. package/dist/admin/dashboard/DashboardPanel.js.map +1 -1
  10. package/dist/admin/forms/FormsPanel.d.ts +9 -0
  11. package/dist/admin/forms/FormsPanel.js +102 -16
  12. package/dist/admin/forms/FormsPanel.js.map +1 -1
  13. package/dist/admin/forms/gezien.d.ts +3 -0
  14. package/dist/admin/forms/gezien.js +22 -0
  15. package/dist/admin/forms/gezien.js.map +1 -0
  16. package/dist/admin/lib/kleur.d.ts +32 -0
  17. package/dist/admin/lib/kleur.js +60 -0
  18. package/dist/admin/lib/kleur.js.map +1 -0
  19. package/dist/admin/lib/pagesClient.d.ts +32 -0
  20. package/dist/admin/lib/pagesClient.js +60 -1
  21. package/dist/admin/lib/pagesClient.js.map +1 -1
  22. package/dist/admin/lib/seoHulp.d.ts +28 -0
  23. package/dist/admin/lib/seoHulp.js +33 -0
  24. package/dist/admin/lib/seoHulp.js.map +1 -0
  25. package/dist/admin/media/MediaManager.js +133 -51
  26. package/dist/admin/media/MediaManager.js.map +1 -1
  27. package/dist/admin/pages/PageEditor.d.ts +14 -0
  28. package/dist/admin/pages/PageEditor.js +192 -123
  29. package/dist/admin/pages/PageEditor.js.map +1 -1
  30. package/dist/admin/pages/PagesPanel.js +88 -40
  31. package/dist/admin/pages/PagesPanel.js.map +1 -1
  32. package/dist/admin/settings/SettingsPanel.js +100 -19
  33. package/dist/admin/settings/SettingsPanel.js.map +1 -1
  34. package/dist/admin/shop/OrdersTab.js +171 -3
  35. package/dist/admin/shop/OrdersTab.js.map +1 -1
  36. package/dist/admin/shop/ProductsTab.js +1 -1
  37. package/dist/admin/shop/ProductsTab.js.map +1 -1
  38. package/dist/admin/shop/ShopPanel.js +5 -2
  39. package/dist/admin/shop/ShopPanel.js.map +1 -1
  40. package/dist/admin/stats/StatsPanel.js +30 -2
  41. package/dist/admin/stats/StatsPanel.js.map +1 -1
  42. package/dist/admin/stats/lib.d.ts +14 -1
  43. package/dist/admin/stats/lib.js +46 -2
  44. package/dist/admin/stats/lib.js.map +1 -1
  45. package/dist/admin/visual/VisualEditor.js +80 -4
  46. package/dist/admin/visual/VisualEditor.js.map +1 -1
  47. package/dist/admin.css +1 -1
  48. package/dist/blocks/builtins/FormBlockRender.js +8 -2
  49. package/dist/blocks/builtins/FormBlockRender.js.map +1 -1
  50. package/dist/blocks/builtins/lib/submitForm.d.ts +3 -1
  51. package/dist/blocks/builtins/lib/submitForm.js +2 -2
  52. package/dist/blocks/builtins/lib/submitForm.js.map +1 -1
  53. package/dist/components/EditBar.d.ts +3 -1
  54. package/dist/components/EditBar.js +30 -4
  55. package/dist/components/EditBar.js.map +1 -1
  56. package/dist/components/News.js +8 -4
  57. package/dist/components/News.js.map +1 -1
  58. package/dist/components/PageView.d.ts +5 -0
  59. package/dist/components/PageView.js +24 -2
  60. package/dist/components/PageView.js.map +1 -1
  61. package/dist/components/ResponsiveImage.d.ts +1 -1
  62. package/dist/components/ResponsiveImage.js +3 -1
  63. package/dist/components/ResponsiveImage.js.map +1 -1
  64. package/dist/components/site/SiteNav.d.ts +29 -0
  65. package/dist/components/site/SiteNav.js +82 -0
  66. package/dist/components/site/SiteNav.js.map +1 -0
  67. package/dist/content/types.d.ts +8 -0
  68. package/dist/index.d.ts +3 -1
  69. package/dist/index.js +8 -1
  70. package/dist/index.js.map +1 -1
  71. package/dist/media/ImageContext.d.ts +1 -6
  72. package/dist/media/ImageContext.js +6 -1
  73. package/dist/media/ImageContext.js.map +1 -1
  74. package/dist/releaseNotes.d.ts +15 -0
  75. package/dist/releaseNotes.js +28 -0
  76. package/dist/releaseNotes.js.map +1 -0
  77. package/dist/schema/mail.d.ts +20 -0
  78. package/dist/schema/mail.js +14 -0
  79. package/dist/schema/mail.js.map +1 -1
  80. package/dist/schema/settings.d.ts +105 -0
  81. package/dist/schema/settings.js +45 -5
  82. package/dist/schema/settings.js.map +1 -1
  83. package/dist/schema/shop.d.ts +2 -0
  84. package/dist/schema/shop.js +12 -2
  85. package/dist/schema/shop.js.map +1 -1
  86. package/dist/schema/siteConfig.d.ts +9 -0
  87. package/dist/server/blockData.js +6 -2
  88. package/dist/server/blockData.js.map +1 -1
  89. package/dist/server/draft.d.ts +11 -0
  90. package/dist/server/draft.js +20 -1
  91. package/dist/server/draft.js.map +1 -1
  92. package/dist/server/index.d.ts +3 -1
  93. package/dist/server/index.js +7 -1
  94. package/dist/server/index.js.map +1 -1
  95. package/dist/server/pages.js +4 -1
  96. package/dist/server/pages.js.map +1 -1
  97. package/dist/server/previewToken.d.ts +34 -0
  98. package/dist/server/previewToken.js +44 -0
  99. package/dist/server/previewToken.js.map +1 -0
  100. package/dist/server/redirects.d.ts +15 -0
  101. package/dist/server/redirects.js +15 -0
  102. package/dist/server/redirects.js.map +1 -0
  103. package/dist/styles.css +110 -0
  104. package/dist/version.js +1 -1
  105. package/package.json +1 -1
@@ -20,6 +20,11 @@ import { useToast } from "../ui/toast.js";
20
20
  import { useConfirm } from "../ui/ConfirmDialog.js";
21
21
  import { MailTemplates } from "../settings/MailTemplates.js";
22
22
  import { FORM_TEMPLATE_KEYS } from "../../schema/mail.js";
23
+ import { callable } from "../../firebase/functionsClient.js";
24
+ import { getFirebaseApp } from "../../firebase/client.js";
25
+ import { useAuth } from "../../auth/AuthProvider.js";
26
+ import { canManageSite } from "../../auth/roles.js";
27
+ import { formulierenGezienSinds, markeerFormulierGezien } from "./gezien.js";
23
28
  const FIELD_TYPES = [
24
29
  { value: "text", label: "Tekst" },
25
30
  { value: "email", label: "E-mail" },
@@ -93,6 +98,8 @@ function FormsPanel({ siteId }) {
93
98
  ] })
94
99
  ] });
95
100
  }
101
+ let veldTeller = 0;
102
+ const nieuweVeldId = () => `v${Date.now().toString(36)}${(veldTeller++).toString(36)}`;
96
103
  function FormDetail({
97
104
  siteId,
98
105
  form,
@@ -101,6 +108,20 @@ function FormDetail({
101
108
  }) {
102
109
  const [def, setDef] = useState(form);
103
110
  const confirm = useConfirm();
111
+ const [veldIds, setVeldIds] = useState(() => form.fields.map(() => nieuweVeldId()));
112
+ function zetVelden(fields, ids) {
113
+ setDef((d) => ({ ...d, fields }));
114
+ setVeldIds(ids);
115
+ }
116
+ function verplaatsVeld(i, richting) {
117
+ const j = i + richting;
118
+ if (j < 0 || j >= def.fields.length) return;
119
+ const fields = [...def.fields];
120
+ const ids = [...veldIds];
121
+ [fields[i], fields[j]] = [fields[j], fields[i]];
122
+ [ids[i], ids[j]] = [ids[j], ids[i]];
123
+ zetVelden(fields, ids);
124
+ }
104
125
  async function save() {
105
126
  const db = getDb();
106
127
  if (!db) return;
@@ -184,7 +205,11 @@ function FormDetail({
184
205
  /* @__PURE__ */ jsx(Field, { label: "Label", children: /* @__PURE__ */ jsx(Input, { value: f.label, onChange: (e) => setField(i, { label: e.target.value }) }) }),
185
206
  /* @__PURE__ */ jsx(Field, { label: "Type", children: /* @__PURE__ */ jsx(Select, { value: f.type, onValueChange: (v) => setField(i, { type: v }), options: FIELD_TYPES }) }),
186
207
  /* @__PURE__ */ jsx(Switch, { checked: f.required, onCheckedChange: (v) => setField(i, { required: v }), label: "Verplicht" }),
187
- /* @__PURE__ */ jsx(Button, { variant: "ghost", size: "sm", onClick: () => setDef({ ...def, fields: def.fields.filter((_, idx) => idx !== i) }), children: "\u{1F5D1}" }),
208
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-1", children: [
209
+ /* @__PURE__ */ jsx(Button, { variant: "ghost", size: "sm", "aria-label": "Veld omhoog", disabled: i === 0, onClick: () => verplaatsVeld(i, -1), children: "\u2191" }),
210
+ /* @__PURE__ */ jsx(Button, { variant: "ghost", size: "sm", "aria-label": "Veld omlaag", disabled: i === def.fields.length - 1, onClick: () => verplaatsVeld(i, 1), children: "\u2193" }),
211
+ /* @__PURE__ */ jsx(Button, { variant: "ghost", size: "sm", "aria-label": "Veld verwijderen", onClick: () => zetVelden(def.fields.filter((_, idx) => idx !== i), veldIds.filter((_, idx) => idx !== i)), children: "\u{1F5D1}" })
212
+ ] }),
188
213
  (f.type === "select" || f.type === "radio") && /* @__PURE__ */ jsx("div", { className: "md:col-span-5", children: /* @__PURE__ */ jsx(Field, { label: "Opties (komma-gescheiden)", children: /* @__PURE__ */ jsx(
189
214
  Input,
190
215
  {
@@ -192,44 +217,105 @@ function FormDetail({
192
217
  onChange: (e) => setField(i, { options: e.target.value.split(",").map((s) => s.trim()).filter(Boolean) })
193
218
  }
194
219
  ) }) })
195
- ] }, i)) }),
220
+ ] }, veldIds[i] ?? i)) }),
196
221
  /* @__PURE__ */ jsx("div", { className: "mt-3", children: /* @__PURE__ */ jsx(
197
222
  Button,
198
223
  {
199
224
  variant: "secondary",
200
225
  size: "sm",
201
- onClick: () => setDef({ ...def, fields: [...def.fields, { name: `veld${def.fields.length + 1}`, label: "Nieuw veld", type: "text", required: false }] }),
226
+ onClick: () => zetVelden([...def.fields, { name: `veld${def.fields.length + 1}`, label: "Nieuw veld", type: "text", required: false }], [...veldIds, nieuweVeldId()]),
202
227
  children: "+ Veld toevoegen"
203
228
  }
204
229
  ) })
205
230
  ] }),
206
- /* @__PURE__ */ jsx(Submissions, { siteId, formId: form.id })
231
+ /* @__PURE__ */ jsx(Submissions, { siteId, formId: form.id, velden: def.fields.map((f) => f.name) })
207
232
  ] });
208
233
  }
209
- function Submissions({ siteId, formId }) {
234
+ function datumVan(s) {
235
+ const d = s.createdAt && typeof s.createdAt.toDate === "function" ? s.createdAt.toDate() : null;
236
+ return d ? d.toLocaleString("nl-NL", { dateStyle: "short", timeStyle: "short" }) : "";
237
+ }
238
+ function inzendingenCsv(velden, subs) {
239
+ const cel = (v) => `"${String(v ?? "").replace(/"/g, '""')}"`;
240
+ const kop = ["datum", ...velden].map(cel).join(";");
241
+ const rijen = subs.map((s) => [datumVan(s), ...velden.map((k) => s.values?.[k])].map(cel).join(";"));
242
+ return "\uFEFF" + [kop, ...rijen].join("\r\n");
243
+ }
244
+ function Submissions({ siteId, formId, velden }) {
210
245
  const [subs, setSubs] = useState([]);
246
+ const { toast } = useToast();
247
+ const confirm = useConfirm();
248
+ const { role } = useAuth();
249
+ const magVerwijderen = canManageSite(role);
250
+ const [sinds] = useState(() => formulierenGezienSinds(siteId, formId));
251
+ useEffect(() => () => markeerFormulierGezien(siteId, formId), [siteId, formId]);
211
252
  useEffect(() => {
212
253
  const db = getDb();
213
254
  if (!db) return;
214
255
  const q = query(collection(db, "sites", siteId, "forms", formId, "submissions"), orderBy("createdAt", "desc"));
215
256
  return onSnapshot(q, (snap) => setSubs(snap.docs.map((d) => ({ id: d.id, ...d.data() }))));
216
257
  }, [siteId, formId]);
258
+ const isNieuw = (s) => Boolean(sinds && s.createdAt && s.createdAt.toMillis() > sinds.toMillis());
259
+ const nieuw = subs.filter(isNieuw).length;
260
+ function exporteer() {
261
+ const blob = new Blob([inzendingenCsv(velden, subs)], { type: "text/csv;charset=utf-8" });
262
+ const url = URL.createObjectURL(blob);
263
+ const a = document.createElement("a");
264
+ a.href = url;
265
+ a.download = `inzendingen-${formId}-${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}.csv`;
266
+ a.click();
267
+ URL.revokeObjectURL(url);
268
+ }
269
+ async function verwijder(s) {
270
+ const ok = await confirm({
271
+ title: "Inzending verwijderen?",
272
+ description: "Bijvoorbeeld op verzoek van de inzender (AVG). Dit kan niet ongedaan worden gemaakt.",
273
+ confirmLabel: "Verwijderen",
274
+ tone: "danger"
275
+ });
276
+ if (!ok) return;
277
+ const app = getFirebaseApp();
278
+ if (!app) return;
279
+ try {
280
+ await callable(app, "deleteSubmission")({ siteId, formId, submissionId: s.id });
281
+ toast({ title: "Inzending verwijderd" });
282
+ } catch (e) {
283
+ toast({ title: "Verwijderen mislukt", description: e.message, tone: "error" });
284
+ }
285
+ }
217
286
  return /* @__PURE__ */ jsxs(Card, { children: [
218
- /* @__PURE__ */ jsxs("h4", { className: "mb-3 text-sm font-semibold", children: [
219
- "Inzendingen ",
220
- /* @__PURE__ */ jsx(Badge, { children: subs.length })
287
+ /* @__PURE__ */ jsxs("div", { className: "mb-3 flex flex-wrap items-center justify-between gap-2", children: [
288
+ /* @__PURE__ */ jsxs("h4", { className: "text-sm font-semibold", children: [
289
+ "Inzendingen ",
290
+ /* @__PURE__ */ jsx(Badge, { children: subs.length }),
291
+ nieuw > 0 && /* @__PURE__ */ jsxs(Badge, { tone: "amber", children: [
292
+ nieuw,
293
+ " nieuw"
294
+ ] })
295
+ ] }),
296
+ subs.length > 0 && /* @__PURE__ */ jsx(Button, { variant: "secondary", size: "sm", onClick: exporteer, children: "Exporteer CSV" })
221
297
  ] }),
222
- subs.length === 0 ? /* @__PURE__ */ jsx("p", { className: "text-sm text-slate-500", children: "Nog geen inzendingen." }) : /* @__PURE__ */ jsx("div", { className: "grid gap-2", children: subs.map((s) => /* @__PURE__ */ jsx("div", { className: "rounded-lg border border-slate-200 p-3 text-sm", children: Object.entries(s.values ?? {}).map(([k, v]) => /* @__PURE__ */ jsxs("div", { children: [
223
- /* @__PURE__ */ jsxs("span", { className: "font-medium text-slate-600", children: [
224
- k,
225
- ":"
298
+ subs.length === 0 ? /* @__PURE__ */ jsx("p", { className: "text-sm text-slate-500", children: "Nog geen inzendingen." }) : /* @__PURE__ */ jsx("div", { className: "grid gap-2", children: subs.map((s) => /* @__PURE__ */ jsxs("div", { className: `rounded-lg border p-3 text-sm ${isNieuw(s) ? "border-amber-300 bg-amber-50/40" : "border-slate-200"}`, children: [
299
+ /* @__PURE__ */ jsxs("div", { className: "mb-1 flex items-center justify-between gap-2 text-xs text-slate-400", children: [
300
+ /* @__PURE__ */ jsxs("span", { children: [
301
+ datumVan(s),
302
+ isNieuw(s) ? " \xB7 nieuw" : ""
303
+ ] }),
304
+ magVerwijderen && /* @__PURE__ */ jsx("button", { type: "button", className: "text-red-600 hover:underline", onClick: () => void verwijder(s), children: "Verwijderen" })
226
305
  ] }),
227
- " ",
228
- String(v)
229
- ] }, k)) }, s.id)) })
306
+ Object.entries(s.values ?? {}).map(([k, v]) => /* @__PURE__ */ jsxs("div", { children: [
307
+ /* @__PURE__ */ jsxs("span", { className: "font-medium text-slate-600", children: [
308
+ k,
309
+ ":"
310
+ ] }),
311
+ " ",
312
+ String(v)
313
+ ] }, k))
314
+ ] }, s.id)) })
230
315
  ] });
231
316
  }
232
317
  export {
233
- FormsPanel
318
+ FormsPanel,
319
+ inzendingenCsv
234
320
  };
235
321
  //# sourceMappingURL=FormsPanel.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/admin/forms/FormsPanel.tsx"],"sourcesContent":["\"use client\";\n\nimport { useEffect, useState } from \"react\";\nimport {\n addDoc,\n collection,\n deleteDoc,\n doc,\n onSnapshot,\n orderBy,\n query,\n serverTimestamp,\n setDoc,\n} from \"firebase/firestore\";\nimport { getDb } from \"../../firebase/client\";\nimport { FormSchema, type FormDefinition, type FormField, type FieldType } from \"../../schema/forms\";\nimport { Button, Card, Field, Input, Badge } from \"../ui/primitives\";\nimport { Select } from \"../ui/select\";\nimport { Switch } from \"../ui/switch\";\nimport { useToast } from \"../ui/toast\";\nimport { useConfirm } from \"../ui/ConfirmDialog\";\nimport { MailTemplates } from \"../settings/MailTemplates\";\nimport { FORM_TEMPLATE_KEYS } from \"../../schema/mail\";\n\ntype StoredForm = FormDefinition & { id: string };\n\nconst FIELD_TYPES: { value: FieldType; label: string }[] = [\n { value: \"text\", label: \"Tekst\" },\n { value: \"email\", label: \"E-mail\" },\n { value: \"tel\", label: \"Telefoon\" },\n { value: \"textarea\", label: \"Tekstvak\" },\n { value: \"select\", label: \"Keuzelijst\" },\n { value: \"radio\", label: \"Keuzerondjes\" },\n { value: \"checkbox\", label: \"Vinkje\" },\n { value: \"date\", label: \"Datum\" },\n { value: \"number\", label: \"Getal\" },\n];\n\nexport function FormsPanel({ siteId }: { siteId: string }) {\n const { toast } = useToast();\n const [forms, setForms] = useState<StoredForm[]>([]);\n const [selected, setSelected] = useState<string | null>(null);\n\n useEffect(() => {\n const db = getDb();\n if (!db) return;\n return onSnapshot(collection(db, \"sites\", siteId, \"forms\"), (snap) =>\n setForms(snap.docs.map((d) => ({ id: d.id, ...(d.data() as Omit<StoredForm, \"id\">) }))),\n );\n }, [siteId]);\n\n const current = forms.find((f) => f.id === selected) ?? null;\n\n async function createForm() {\n const db = getDb();\n if (!db) return;\n const ref = await addDoc(collection(db, \"sites\", siteId, \"forms\"), {\n name: \"Nieuw formulier\",\n fields: [{ name: \"naam\", label: \"Naam\", type: \"text\", required: true }],\n submitLabel: \"Versturen\",\n successMessage: \"Bedankt! We nemen snel contact op.\",\n } satisfies FormDefinition);\n setSelected(ref.id);\n }\n\n return (\n <div className=\"grid gap-6\">\n <div className=\"grid gap-4 md:grid-cols-[260px_1fr]\">\n <Card className=\"h-fit\">\n <div className=\"mb-3 flex items-center justify-between\">\n <h3 className=\"text-sm font-semibold\">Formulieren</h3>\n <Button size=\"sm\" onClick={createForm}>+ Nieuw</Button>\n </div>\n <ul className=\"grid gap-1\">\n {forms.map((f) => (\n <li key={f.id}>\n <button\n onClick={() => setSelected(f.id)}\n className={`w-full truncate rounded-lg px-3 py-2 text-left text-sm ${\n f.id === selected ? \"bg-brand-50 text-brand-700\" : \"hover:bg-slate-100\"\n }`}\n >\n {f.name}\n <span className=\"ml-1 text-xs text-slate-400\">#{f.id.slice(0, 5)}</span>\n </button>\n </li>\n ))}\n {forms.length === 0 && <li className=\"px-3 py-2 text-sm text-slate-500\">Nog geen formulieren.</li>}\n </ul>\n </Card>\n {current ? (\n <FormDetail key={current.id} siteId={siteId} form={current} onDeleted={() => setSelected(null)} onToast={toast} />\n ) : (\n <Card>\n <p className=\"text-sm text-slate-500\">Selecteer of maak een formulier.</p>\n </Card>\n )}\n </div>\n\n <div>\n <h3 className=\"mb-1 text-base font-semibold\">E-mailteksten voor formulieren</h3>\n <p className=\"mb-3 text-sm text-slate-600\">\n Gelden voor álle formulieren: de notificatie naar jou en de bevestiging naar de invuller. De opmaak\n (huisstijl) en verzendprovider stel je site-breed in bij <strong>Instellingen → E-mail</strong>.\n </p>\n <MailTemplates siteId={siteId} keys={FORM_TEMPLATE_KEYS} />\n </div>\n </div>\n );\n}\n\nfunction FormDetail({\n siteId,\n form,\n onDeleted,\n onToast,\n}: {\n siteId: string;\n form: StoredForm;\n onDeleted: () => void;\n onToast: (t: { title: string; description?: string; tone?: \"default\" | \"success\" | \"error\" }) => void;\n}) {\n const [def, setDef] = useState<FormDefinition>(form);\n const confirm = useConfirm();\n\n async function save() {\n const db = getDb();\n if (!db) return;\n const parsed = FormSchema.safeParse(def);\n if (!parsed.success) {\n onToast({ title: \"Ongeldig formulier\", description: parsed.error.issues[0]?.message, tone: \"error\" });\n return;\n }\n await setDoc(doc(db, \"sites\", siteId, \"forms\", form.id), parsed.data, { merge: true });\n onToast({ title: \"Opgeslagen\", tone: \"success\" });\n }\n\n async function remove() {\n const db = getDb();\n if (!db) return;\n const ok = await confirm({ title: \"Formulier verwijderen?\", description: \"Het formulier wordt verwijderd. Inzendingen blijven bewaard.\", confirmLabel: \"Verwijderen\", tone: \"danger\" });\n if (!ok) return;\n await deleteDoc(doc(db, \"sites\", siteId, \"forms\", form.id));\n onDeleted();\n }\n\n function setField(i: number, part: Partial<FormField>) {\n const fields = [...def.fields];\n fields[i] = { ...fields[i], ...part };\n setDef({ ...def, fields });\n }\n\n return (\n <div className=\"grid gap-4\">\n <Card>\n <div className=\"mb-3 flex items-center justify-between\">\n <h3 className=\"text-base font-semibold\">Formulier bewerken</h3>\n <div className=\"flex gap-2\">\n <Button variant=\"danger\" size=\"sm\" onClick={remove}>Verwijderen</Button>\n <Button size=\"sm\" onClick={save}>Opslaan</Button>\n </div>\n </div>\n <div className=\"grid gap-3 md:grid-cols-2\">\n <Field label=\"Naam\"><Input value={def.name} onChange={(e) => setDef({ ...def, name: e.target.value })} /></Field>\n <Field label=\"Verstuurknop\"><Input value={def.submitLabel} onChange={(e) => setDef({ ...def, submitLabel: e.target.value })} /></Field>\n <Field label=\"Bedankbericht\"><Input value={def.successMessage} onChange={(e) => setDef({ ...def, successMessage: e.target.value })} /></Field>\n <Field label=\"Bedankt-pagina (optioneel)\" hint=\"Na verzenden doorsturen naar dit pad, bv. /bedankt\">\n <Input value={def.successRedirect ?? \"\"} onChange={(e) => setDef({ ...def, successRedirect: e.target.value || undefined })} />\n </Field>\n </div>\n\n <div className=\"mt-4 rounded-lg border border-slate-200 p-4\">\n <h4 className=\"text-sm font-semibold\">Wat gebeurt er bij een inzending?</h4>\n <p className=\"mb-3 mt-0.5 text-xs text-slate-500\">\n Elke inzending wordt altijd bewaard onder <strong>Inzendingen</strong>. Daarnaast kun je een kopie\n mailen naar jezelf en/of een bevestiging naar de invuller. De opmaak en verzendprovider stel je\n site-breed in bij <strong>Instellingen → E-mail</strong>.\n </p>\n <div className=\"grid gap-3 md:grid-cols-2\">\n <Field label=\"Kopie van inzending naar\" hint=\"Leeg = het notificatie-adres uit Instellingen → E-mail.\">\n <Input\n type=\"email\"\n value={def.notifyTo ?? \"\"}\n onChange={(e) => setDef({ ...def, notifyTo: e.target.value || undefined })}\n placeholder=\"jij@voorbeeld.nl\"\n />\n </Field>\n <label className=\"mt-6 flex h-9 items-center gap-2 text-sm\">\n <input\n type=\"checkbox\"\n checked={Boolean(def.confirmSubmitter)}\n onChange={(e) => setDef({ ...def, confirmSubmitter: e.target.checked })}\n />\n Stuur de invuller ook een bevestiging (naar het eerste e-mailveld)\n </label>\n </div>\n </div>\n <p className=\"mt-3 text-xs text-slate-500\">Formulier-id (voor het Formulier-blok): <code>{form.id}</code></p>\n </Card>\n\n <Card>\n <h4 className=\"mb-3 text-sm font-semibold\">Velden</h4>\n <div className=\"grid gap-3\">\n {def.fields.map((f, i) => (\n <div key={i} className=\"grid items-end gap-2 rounded-lg border border-slate-200 p-3 md:grid-cols-[1fr_1fr_140px_auto_auto]\">\n <Field label=\"Naam (sleutel)\"><Input value={f.name} onChange={(e) => setField(i, { name: e.target.value })} /></Field>\n <Field label=\"Label\"><Input value={f.label} onChange={(e) => setField(i, { label: e.target.value })} /></Field>\n <Field label=\"Type\">\n <Select value={f.type} onValueChange={(v) => setField(i, { type: v as FieldType })} options={FIELD_TYPES} />\n </Field>\n <Switch checked={f.required} onCheckedChange={(v) => setField(i, { required: v })} label=\"Verplicht\" />\n <Button variant=\"ghost\" size=\"sm\" onClick={() => setDef({ ...def, fields: def.fields.filter((_, idx) => idx !== i) })}>🗑</Button>\n {(f.type === \"select\" || f.type === \"radio\") && (\n <div className=\"md:col-span-5\">\n <Field label=\"Opties (komma-gescheiden)\">\n <Input\n value={(f.options ?? []).join(\", \")}\n onChange={(e) => setField(i, { options: e.target.value.split(\",\").map((s) => s.trim()).filter(Boolean) })}\n />\n </Field>\n </div>\n )}\n </div>\n ))}\n </div>\n <div className=\"mt-3\">\n <Button\n variant=\"secondary\"\n size=\"sm\"\n onClick={() => setDef({ ...def, fields: [...def.fields, { name: `veld${def.fields.length + 1}`, label: \"Nieuw veld\", type: \"text\", required: false }] })}\n >\n + Veld toevoegen\n </Button>\n </div>\n </Card>\n\n <Submissions siteId={siteId} formId={form.id} />\n </div>\n );\n}\n\nfunction Submissions({ siteId, formId }: { siteId: string; formId: string }) {\n const [subs, setSubs] = useState<{ id: string; values: Record<string, unknown>; createdAt?: unknown }[]>([]);\n useEffect(() => {\n const db = getDb();\n if (!db) return;\n const q = query(collection(db, \"sites\", siteId, \"forms\", formId, \"submissions\"), orderBy(\"createdAt\", \"desc\"));\n return onSnapshot(q, (snap) => setSubs(snap.docs.map((d) => ({ id: d.id, ...(d.data() as { values: Record<string, unknown> }) }))));\n }, [siteId, formId]);\n\n return (\n <Card>\n <h4 className=\"mb-3 text-sm font-semibold\">Inzendingen <Badge>{subs.length}</Badge></h4>\n {subs.length === 0 ? (\n <p className=\"text-sm text-slate-500\">Nog geen inzendingen.</p>\n ) : (\n <div className=\"grid gap-2\">\n {subs.map((s) => (\n <div key={s.id} className=\"rounded-lg border border-slate-200 p-3 text-sm\">\n {Object.entries(s.values ?? {}).map(([k, v]) => (\n <div key={k}>\n <span className=\"font-medium text-slate-600\">{k}:</span> {String(v)}\n </div>\n ))}\n </div>\n ))}\n </div>\n )}\n </Card>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../../src/admin/forms/FormsPanel.tsx"],"sourcesContent":["\"use client\";\n\nimport { useEffect, useState } from \"react\";\nimport {\n addDoc,\n collection,\n deleteDoc,\n doc,\n onSnapshot,\n orderBy,\n query,\n serverTimestamp,\n setDoc,\n} from \"firebase/firestore\";\nimport { getDb } from \"../../firebase/client\";\nimport { FormSchema, type FormDefinition, type FormField, type FieldType } from \"../../schema/forms\";\nimport { Button, Card, Field, Input, Badge } from \"../ui/primitives\";\nimport { Select } from \"../ui/select\";\nimport { Switch } from \"../ui/switch\";\nimport { useToast } from \"../ui/toast\";\nimport { useConfirm } from \"../ui/ConfirmDialog\";\nimport { MailTemplates } from \"../settings/MailTemplates\";\nimport { FORM_TEMPLATE_KEYS } from \"../../schema/mail\";\nimport { callable } from \"../../firebase/functionsClient\";\nimport { getFirebaseApp } from \"../../firebase/client\";\nimport { useAuth } from \"../../auth/AuthProvider\";\nimport { canManageSite } from \"../../auth/roles\";\nimport { formulierenGezienSinds, markeerFormulierGezien } from \"./gezien\";\nimport type { Timestamp } from \"firebase/firestore\";\n\ntype StoredForm = FormDefinition & { id: string };\n\nconst FIELD_TYPES: { value: FieldType; label: string }[] = [\n { value: \"text\", label: \"Tekst\" },\n { value: \"email\", label: \"E-mail\" },\n { value: \"tel\", label: \"Telefoon\" },\n { value: \"textarea\", label: \"Tekstvak\" },\n { value: \"select\", label: \"Keuzelijst\" },\n { value: \"radio\", label: \"Keuzerondjes\" },\n { value: \"checkbox\", label: \"Vinkje\" },\n { value: \"date\", label: \"Datum\" },\n { value: \"number\", label: \"Getal\" },\n];\n\nexport function FormsPanel({ siteId }: { siteId: string }) {\n const { toast } = useToast();\n const [forms, setForms] = useState<StoredForm[]>([]);\n const [selected, setSelected] = useState<string | null>(null);\n\n useEffect(() => {\n const db = getDb();\n if (!db) return;\n return onSnapshot(collection(db, \"sites\", siteId, \"forms\"), (snap) =>\n setForms(snap.docs.map((d) => ({ id: d.id, ...(d.data() as Omit<StoredForm, \"id\">) }))),\n );\n }, [siteId]);\n\n const current = forms.find((f) => f.id === selected) ?? null;\n\n async function createForm() {\n const db = getDb();\n if (!db) return;\n const ref = await addDoc(collection(db, \"sites\", siteId, \"forms\"), {\n name: \"Nieuw formulier\",\n fields: [{ name: \"naam\", label: \"Naam\", type: \"text\", required: true }],\n submitLabel: \"Versturen\",\n successMessage: \"Bedankt! We nemen snel contact op.\",\n } satisfies FormDefinition);\n setSelected(ref.id);\n }\n\n return (\n <div className=\"grid gap-6\">\n <div className=\"grid gap-4 md:grid-cols-[260px_1fr]\">\n <Card className=\"h-fit\">\n <div className=\"mb-3 flex items-center justify-between\">\n <h3 className=\"text-sm font-semibold\">Formulieren</h3>\n <Button size=\"sm\" onClick={createForm}>+ Nieuw</Button>\n </div>\n <ul className=\"grid gap-1\">\n {forms.map((f) => (\n <li key={f.id}>\n <button\n onClick={() => setSelected(f.id)}\n className={`w-full truncate rounded-lg px-3 py-2 text-left text-sm ${\n f.id === selected ? \"bg-brand-50 text-brand-700\" : \"hover:bg-slate-100\"\n }`}\n >\n {f.name}\n <span className=\"ml-1 text-xs text-slate-400\">#{f.id.slice(0, 5)}</span>\n </button>\n </li>\n ))}\n {forms.length === 0 && <li className=\"px-3 py-2 text-sm text-slate-500\">Nog geen formulieren.</li>}\n </ul>\n </Card>\n {current ? (\n <FormDetail key={current.id} siteId={siteId} form={current} onDeleted={() => setSelected(null)} onToast={toast} />\n ) : (\n <Card>\n <p className=\"text-sm text-slate-500\">Selecteer of maak een formulier.</p>\n </Card>\n )}\n </div>\n\n <div>\n <h3 className=\"mb-1 text-base font-semibold\">E-mailteksten voor formulieren</h3>\n <p className=\"mb-3 text-sm text-slate-600\">\n Gelden voor álle formulieren: de notificatie naar jou en de bevestiging naar de invuller. De opmaak\n (huisstijl) en verzendprovider stel je site-breed in bij <strong>Instellingen → E-mail</strong>.\n </p>\n <MailTemplates siteId={siteId} keys={FORM_TEMPLATE_KEYS} />\n </div>\n </div>\n );\n}\n\nlet veldTeller = 0;\nconst nieuweVeldId = () => `v${Date.now().toString(36)}${(veldTeller++).toString(36)}`;\n\nfunction FormDetail({\n siteId,\n form,\n onDeleted,\n onToast,\n}: {\n siteId: string;\n form: StoredForm;\n onDeleted: () => void;\n onToast: (t: { title: string; description?: string; tone?: \"default\" | \"success\" | \"error\" }) => void;\n}) {\n const [def, setDef] = useState<FormDefinition>(form);\n const confirm = useConfirm();\n /**\n * Stabiele React-sleutels per veld (7.24). Op index (`key={i}`) erven rijen elkaars state bij\n * verwijderen of verplaatsen — dezelfde stille corruptie die de vorige review in ArrayEditor\n * dichtte. De ids bestaan alleen hier (niet in het schema) en lopen mee met elke mutatie.\n */\n const [veldIds, setVeldIds] = useState<string[]>(() => form.fields.map(() => nieuweVeldId()));\n function zetVelden(fields: FormField[], ids: string[]) {\n setDef((d) => ({ ...d, fields }));\n setVeldIds(ids);\n }\n function verplaatsVeld(i: number, richting: -1 | 1) {\n const j = i + richting;\n if (j < 0 || j >= def.fields.length) return;\n const fields = [...def.fields];\n const ids = [...veldIds];\n [fields[i], fields[j]] = [fields[j], fields[i]];\n [ids[i], ids[j]] = [ids[j], ids[i]];\n zetVelden(fields, ids);\n }\n\n async function save() {\n const db = getDb();\n if (!db) return;\n const parsed = FormSchema.safeParse(def);\n if (!parsed.success) {\n onToast({ title: \"Ongeldig formulier\", description: parsed.error.issues[0]?.message, tone: \"error\" });\n return;\n }\n await setDoc(doc(db, \"sites\", siteId, \"forms\", form.id), parsed.data, { merge: true });\n onToast({ title: \"Opgeslagen\", tone: \"success\" });\n }\n\n async function remove() {\n const db = getDb();\n if (!db) return;\n const ok = await confirm({ title: \"Formulier verwijderen?\", description: \"Het formulier wordt verwijderd. Inzendingen blijven bewaard.\", confirmLabel: \"Verwijderen\", tone: \"danger\" });\n if (!ok) return;\n await deleteDoc(doc(db, \"sites\", siteId, \"forms\", form.id));\n onDeleted();\n }\n\n function setField(i: number, part: Partial<FormField>) {\n const fields = [...def.fields];\n fields[i] = { ...fields[i], ...part };\n setDef({ ...def, fields });\n }\n\n return (\n <div className=\"grid gap-4\">\n <Card>\n <div className=\"mb-3 flex items-center justify-between\">\n <h3 className=\"text-base font-semibold\">Formulier bewerken</h3>\n <div className=\"flex gap-2\">\n <Button variant=\"danger\" size=\"sm\" onClick={remove}>Verwijderen</Button>\n <Button size=\"sm\" onClick={save}>Opslaan</Button>\n </div>\n </div>\n <div className=\"grid gap-3 md:grid-cols-2\">\n <Field label=\"Naam\"><Input value={def.name} onChange={(e) => setDef({ ...def, name: e.target.value })} /></Field>\n <Field label=\"Verstuurknop\"><Input value={def.submitLabel} onChange={(e) => setDef({ ...def, submitLabel: e.target.value })} /></Field>\n <Field label=\"Bedankbericht\"><Input value={def.successMessage} onChange={(e) => setDef({ ...def, successMessage: e.target.value })} /></Field>\n <Field label=\"Bedankt-pagina (optioneel)\" hint=\"Na verzenden doorsturen naar dit pad, bv. /bedankt\">\n <Input value={def.successRedirect ?? \"\"} onChange={(e) => setDef({ ...def, successRedirect: e.target.value || undefined })} />\n </Field>\n </div>\n\n <div className=\"mt-4 rounded-lg border border-slate-200 p-4\">\n <h4 className=\"text-sm font-semibold\">Wat gebeurt er bij een inzending?</h4>\n <p className=\"mb-3 mt-0.5 text-xs text-slate-500\">\n Elke inzending wordt altijd bewaard onder <strong>Inzendingen</strong>. Daarnaast kun je een kopie\n mailen naar jezelf en/of een bevestiging naar de invuller. De opmaak en verzendprovider stel je\n site-breed in bij <strong>Instellingen → E-mail</strong>.\n </p>\n <div className=\"grid gap-3 md:grid-cols-2\">\n <Field label=\"Kopie van inzending naar\" hint=\"Leeg = het notificatie-adres uit Instellingen → E-mail.\">\n <Input\n type=\"email\"\n value={def.notifyTo ?? \"\"}\n onChange={(e) => setDef({ ...def, notifyTo: e.target.value || undefined })}\n placeholder=\"jij@voorbeeld.nl\"\n />\n </Field>\n <label className=\"mt-6 flex h-9 items-center gap-2 text-sm\">\n <input\n type=\"checkbox\"\n checked={Boolean(def.confirmSubmitter)}\n onChange={(e) => setDef({ ...def, confirmSubmitter: e.target.checked })}\n />\n Stuur de invuller ook een bevestiging (naar het eerste e-mailveld)\n </label>\n </div>\n </div>\n <p className=\"mt-3 text-xs text-slate-500\">Formulier-id (voor het Formulier-blok): <code>{form.id}</code></p>\n </Card>\n\n <Card>\n <h4 className=\"mb-3 text-sm font-semibold\">Velden</h4>\n <div className=\"grid gap-3\">\n {def.fields.map((f, i) => (\n <div key={veldIds[i] ?? i} className=\"grid items-end gap-2 rounded-lg border border-slate-200 p-3 md:grid-cols-[1fr_1fr_140px_auto_auto]\">\n <Field label=\"Naam (sleutel)\"><Input value={f.name} onChange={(e) => setField(i, { name: e.target.value })} /></Field>\n <Field label=\"Label\"><Input value={f.label} onChange={(e) => setField(i, { label: e.target.value })} /></Field>\n <Field label=\"Type\">\n <Select value={f.type} onValueChange={(v) => setField(i, { type: v as FieldType })} options={FIELD_TYPES} />\n </Field>\n <Switch checked={f.required} onCheckedChange={(v) => setField(i, { required: v })} label=\"Verplicht\" />\n <div className=\"flex items-center gap-1\">\n <Button variant=\"ghost\" size=\"sm\" aria-label=\"Veld omhoog\" disabled={i === 0} onClick={() => verplaatsVeld(i, -1)}>↑</Button>\n <Button variant=\"ghost\" size=\"sm\" aria-label=\"Veld omlaag\" disabled={i === def.fields.length - 1} onClick={() => verplaatsVeld(i, 1)}>↓</Button>\n <Button variant=\"ghost\" size=\"sm\" aria-label=\"Veld verwijderen\" onClick={() => zetVelden(def.fields.filter((_, idx) => idx !== i), veldIds.filter((_, idx) => idx !== i))}>🗑</Button>\n </div>\n {(f.type === \"select\" || f.type === \"radio\") && (\n <div className=\"md:col-span-5\">\n <Field label=\"Opties (komma-gescheiden)\">\n <Input\n value={(f.options ?? []).join(\", \")}\n onChange={(e) => setField(i, { options: e.target.value.split(\",\").map((s) => s.trim()).filter(Boolean) })}\n />\n </Field>\n </div>\n )}\n </div>\n ))}\n </div>\n <div className=\"mt-3\">\n <Button\n variant=\"secondary\"\n size=\"sm\"\n onClick={() => zetVelden([...def.fields, { name: `veld${def.fields.length + 1}`, label: \"Nieuw veld\", type: \"text\", required: false }], [...veldIds, nieuweVeldId()])}\n >\n + Veld toevoegen\n </Button>\n </div>\n </Card>\n\n <Submissions siteId={siteId} formId={form.id} velden={def.fields.map((f) => f.name)} />\n </div>\n );\n}\n\ntype Inzending = { id: string; values: Record<string, unknown>; createdAt?: Timestamp };\n\nfunction datumVan(s: Inzending): string {\n const d = s.createdAt && typeof s.createdAt.toDate === \"function\" ? s.createdAt.toDate() : null;\n return d ? d.toLocaleString(\"nl-NL\", { dateStyle: \"short\", timeStyle: \"short\" }) : \"\";\n}\n\n/** CSV met Excel-vriendelijke opmaak: BOM, puntkomma, alles gequoot. Kolommen = de velden van het formulier. */\nexport function inzendingenCsv(velden: string[], subs: Inzending[]): string {\n const cel = (v: unknown) => `\"${String(v ?? \"\").replace(/\"/g, '\"\"')}\"`;\n const kop = [\"datum\", ...velden].map(cel).join(\";\");\n const rijen = subs.map((s) => [datumVan(s), ...velden.map((k) => s.values?.[k])].map(cel).join(\";\"));\n return \"\\ufeff\" + [kop, ...rijen].join(\"\\r\\n\");\n}\n\nfunction Submissions({ siteId, formId, velden }: { siteId: string; formId: string; velden: string[] }) {\n const [subs, setSubs] = useState<Inzending[]>([]);\n const { toast } = useToast();\n const confirm = useConfirm();\n const { role } = useAuth();\n const magVerwijderen = canManageSite(role);\n // \"Nieuw sinds je laatste bezoek\" — het moment van openen; markeren gebeurt bij het sluiten\n // (unmount), zodat je tijdens het kijken nog ziet wát er nieuw was.\n const [sinds] = useState(() => formulierenGezienSinds(siteId, formId));\n useEffect(() => () => markeerFormulierGezien(siteId, formId), [siteId, formId]);\n\n useEffect(() => {\n const db = getDb();\n if (!db) return;\n const q = query(collection(db, \"sites\", siteId, \"forms\", formId, \"submissions\"), orderBy(\"createdAt\", \"desc\"));\n return onSnapshot(q, (snap) => setSubs(snap.docs.map((d) => ({ id: d.id, ...(d.data() as Omit<Inzending, \"id\">) }))));\n }, [siteId, formId]);\n\n const isNieuw = (s: Inzending) => Boolean(sinds && s.createdAt && s.createdAt.toMillis() > sinds.toMillis());\n const nieuw = subs.filter(isNieuw).length;\n\n function exporteer() {\n const blob = new Blob([inzendingenCsv(velden, subs)], { type: \"text/csv;charset=utf-8\" });\n const url = URL.createObjectURL(blob);\n const a = document.createElement(\"a\");\n a.href = url;\n a.download = `inzendingen-${formId}-${new Date().toISOString().slice(0, 10)}.csv`;\n a.click();\n URL.revokeObjectURL(url);\n }\n\n async function verwijder(s: Inzending) {\n const ok = await confirm({\n title: \"Inzending verwijderen?\",\n description: \"Bijvoorbeeld op verzoek van de inzender (AVG). Dit kan niet ongedaan worden gemaakt.\",\n confirmLabel: \"Verwijderen\",\n tone: \"danger\",\n });\n if (!ok) return;\n const app = getFirebaseApp();\n if (!app) return;\n try {\n await callable(app, \"deleteSubmission\")({ siteId, formId, submissionId: s.id });\n toast({ title: \"Inzending verwijderd\" });\n } catch (e) {\n toast({ title: \"Verwijderen mislukt\", description: (e as Error).message, tone: \"error\" });\n }\n }\n\n return (\n <Card>\n <div className=\"mb-3 flex flex-wrap items-center justify-between gap-2\">\n <h4 className=\"text-sm font-semibold\">\n Inzendingen <Badge>{subs.length}</Badge>\n {nieuw > 0 && <Badge tone=\"amber\">{nieuw} nieuw</Badge>}\n </h4>\n {subs.length > 0 && (\n <Button variant=\"secondary\" size=\"sm\" onClick={exporteer}>\n Exporteer CSV\n </Button>\n )}\n </div>\n {subs.length === 0 ? (\n <p className=\"text-sm text-slate-500\">Nog geen inzendingen.</p>\n ) : (\n <div className=\"grid gap-2\">\n {subs.map((s) => (\n <div key={s.id} className={`rounded-lg border p-3 text-sm ${isNieuw(s) ? \"border-amber-300 bg-amber-50/40\" : \"border-slate-200\"}`}>\n <div className=\"mb-1 flex items-center justify-between gap-2 text-xs text-slate-400\">\n <span>{datumVan(s)}{isNieuw(s) ? \" · nieuw\" : \"\"}</span>\n {magVerwijderen && (\n <button type=\"button\" className=\"text-red-600 hover:underline\" onClick={() => void verwijder(s)}>\n Verwijderen\n </button>\n )}\n </div>\n {Object.entries(s.values ?? {}).map(([k, v]) => (\n <div key={k}>\n <span className=\"font-medium text-slate-600\">{k}:</span> {String(v)}\n </div>\n ))}\n </div>\n ))}\n </div>\n )}\n </Card>\n );\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,3 @@
1
+ import { Timestamp } from "firebase/firestore";
2
+ export declare function formulierenGezienSinds(siteId: string, formId: string): Timestamp | null;
3
+ export declare function markeerFormulierGezien(siteId: string, formId: string): void;
@@ -0,0 +1,22 @@
1
+ import { Timestamp } from "firebase/firestore";
2
+ const sleutel = (siteId, formId) => `xicms-forms-gezien:${siteId}:${formId}`;
3
+ function formulierenGezienSinds(siteId, formId) {
4
+ try {
5
+ const raw = localStorage.getItem(sleutel(siteId, formId));
6
+ const ms = raw ? Number(raw) : 0;
7
+ return ms > 0 ? Timestamp.fromMillis(ms) : null;
8
+ } catch {
9
+ return null;
10
+ }
11
+ }
12
+ function markeerFormulierGezien(siteId, formId) {
13
+ try {
14
+ localStorage.setItem(sleutel(siteId, formId), String(Date.now()));
15
+ } catch {
16
+ }
17
+ }
18
+ export {
19
+ formulierenGezienSinds,
20
+ markeerFormulierGezien
21
+ };
22
+ //# sourceMappingURL=gezien.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../src/admin/forms/gezien.ts"],"sourcesContent":["import { Timestamp } from \"firebase/firestore\";\n\n/**\n * \"Nieuw sinds je laatste bezoek\" voor formulier-inzendingen (7.24).\n *\n * Het moment staat LOKAAL (localStorage, per site en formulier), niet in Firestore. Een gelezen-\n * vlag per inzending zou een write per beheerder per inzending vragen — en de rules houden\n * inzendingen bewust write-only vanaf de client (audit-trail). Lokaal is goed genoeg voor waar\n * het voor dient: de vraag \"is er iets bijgekomen sinds ik keek?\" op dít apparaat.\n */\nconst sleutel = (siteId: string, formId: string) => `xicms-forms-gezien:${siteId}:${formId}`;\n\nexport function formulierenGezienSinds(siteId: string, formId: string): Timestamp | null {\n try {\n const raw = localStorage.getItem(sleutel(siteId, formId));\n const ms = raw ? Number(raw) : 0;\n return ms > 0 ? Timestamp.fromMillis(ms) : null;\n } catch {\n return null;\n }\n}\n\nexport function markeerFormulierGezien(siteId: string, formId: string): void {\n try {\n localStorage.setItem(sleutel(siteId, formId), String(Date.now()));\n } catch {\n /* opslag geweigerd: dan blijft alles \"nieuw\" — nooit een fout hierom */\n }\n}\n"],"mappings":"AAAA,SAAS,iBAAiB;AAU1B,MAAM,UAAU,CAAC,QAAgB,WAAmB,sBAAsB,MAAM,IAAI,MAAM;AAEnF,SAAS,uBAAuB,QAAgB,QAAkC;AACvF,MAAI;AACF,UAAM,MAAM,aAAa,QAAQ,QAAQ,QAAQ,MAAM,CAAC;AACxD,UAAM,KAAK,MAAM,OAAO,GAAG,IAAI;AAC/B,WAAO,KAAK,IAAI,UAAU,WAAW,EAAE,IAAI;AAAA,EAC7C,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEO,SAAS,uBAAuB,QAAgB,QAAsB;AAC3E,MAAI;AACF,iBAAa,QAAQ,QAAQ,QAAQ,MAAM,GAAG,OAAO,KAAK,IAAI,CAAC,CAAC;AAAA,EAClE,QAAQ;AAAA,EAER;AACF;","names":[]}
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Kleurhulpjes voor het Huisstijl-scherm (7.24): hex ↔ getallen en het WCAG-contrast.
3
+ *
4
+ * Klanten kozen een merkkleur op gevoel en zagen pas op de site dat witte knoptekst op een
5
+ * lichtgele knop onleesbaar is. Het contrastcijfer erbij zetten voorkomt dat gesprek.
6
+ */
7
+ /** "#abc" / "#aabbcc" / "aabbcc" → [r, g, b], of null bij iets wat geen hex-kleur is. */
8
+ export declare function hexNaarRgb(hex: string | undefined): [number, number, number] | null;
9
+ /** Naar een 6-cijferige hex voor `<input type="color">` (die accepteert alleen #rrggbb). */
10
+ export declare function naarHex6(kleur: string | undefined, fallback?: string): string;
11
+ /** Relatieve luminantie (WCAG 2.x). */
12
+ export declare function luminantie(rgb: [number, number, number]): number;
13
+ /** Contrastverhouding tussen twee kleuren (1 = geen contrast, 21 = zwart op wit). */
14
+ export declare function contrast(a: string | undefined, b: string | undefined): number | null;
15
+ /** Welke tekstkleur komt er op een knop in deze kleur? (Spiegelt de on-accent-afleiding in CSS.) */
16
+ export declare function tekstOp(kleur: string | undefined): "#ffffff" | "#0f172a";
17
+ /**
18
+ * Oordeel in gewone woorden. AA voor normale tekst is 4.5; voor grote tekst en knoppen volstaat 3.
19
+ * We meten tegen 3 (knoppen zijn het hoofdgebruik van de merkkleur) en waarschuwen daaronder.
20
+ */
21
+ export declare function contrastOordeel(ratio: number | null): {
22
+ tekst: string;
23
+ toon: "goed" | "matig" | "slecht";
24
+ } | null;
25
+ /** Curated lettertype-paren voor het Huisstijl-scherm — één keuze, meteen goed. */
26
+ export declare const LETTERTYPE_PAREN: {
27
+ id: string;
28
+ label: string;
29
+ fontFamily: string;
30
+ headingFont?: string;
31
+ fontUrl: string;
32
+ }[];
@@ -0,0 +1,60 @@
1
+ function hexNaarRgb(hex) {
2
+ if (!hex) return null;
3
+ const m = hex.trim().replace(/^#/, "");
4
+ const vol = m.length === 3 ? m.split("").map((c) => c + c).join("") : m;
5
+ if (!/^[0-9a-fA-F]{6}$/.test(vol)) return null;
6
+ return [parseInt(vol.slice(0, 2), 16), parseInt(vol.slice(2, 4), 16), parseInt(vol.slice(4, 6), 16)];
7
+ }
8
+ function naarHex6(kleur, fallback = "#000000") {
9
+ const rgb = hexNaarRgb(kleur);
10
+ if (!rgb) return fallback;
11
+ return `#${rgb.map((n) => n.toString(16).padStart(2, "0")).join("")}`;
12
+ }
13
+ function lineair(c) {
14
+ const v = c / 255;
15
+ return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
16
+ }
17
+ function luminantie(rgb) {
18
+ return 0.2126 * lineair(rgb[0]) + 0.7152 * lineair(rgb[1]) + 0.0722 * lineair(rgb[2]);
19
+ }
20
+ function contrast(a, b) {
21
+ const ra = hexNaarRgb(a);
22
+ const rb = hexNaarRgb(b);
23
+ if (!ra || !rb) return null;
24
+ const la = luminantie(ra);
25
+ const lb = luminantie(rb);
26
+ const [licht, donker] = la > lb ? [la, lb] : [lb, la];
27
+ return (licht + 0.05) / (donker + 0.05);
28
+ }
29
+ function tekstOp(kleur) {
30
+ const rgb = hexNaarRgb(kleur);
31
+ if (!rgb) return "#ffffff";
32
+ return luminantie(rgb) > 0.4 ? "#0f172a" : "#ffffff";
33
+ }
34
+ function contrastOordeel(ratio) {
35
+ if (ratio === null) return null;
36
+ const r = Math.round(ratio * 10) / 10;
37
+ if (ratio >= 4.5) return { tekst: `Contrast ${r}:1 \u2014 goed leesbaar`, toon: "goed" };
38
+ if (ratio >= 3) return { tekst: `Contrast ${r}:1 \u2014 voldoende voor knoppen en koppen, krap voor kleine tekst`, toon: "matig" };
39
+ return { tekst: `Contrast ${r}:1 \u2014 te laag; kies een donkerdere of lichtere tint`, toon: "slecht" };
40
+ }
41
+ const LETTERTYPE_PAREN = [
42
+ { id: "inter", label: "Inter \u2014 helder & neutraal", fontFamily: "'Inter', system-ui, sans-serif", fontUrl: "https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" },
43
+ { id: "inter-sora", label: "Inter + Sora \u2014 modern met karakter", fontFamily: "'Inter', system-ui, sans-serif", headingFont: "'Sora', 'Inter', sans-serif", fontUrl: "https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&family=Sora:wght@600;700&display=swap" },
44
+ { id: "poppins", label: "Poppins \u2014 rond & vriendelijk", fontFamily: "'Poppins', system-ui, sans-serif", fontUrl: "https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&display=swap" },
45
+ { id: "nunito", label: "Nunito \u2014 zacht & toegankelijk", fontFamily: "'Nunito', system-ui, sans-serif", fontUrl: "https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700;800&display=swap" },
46
+ { id: "lora-source", label: "Source Sans + Lora \u2014 klassiek & warm", fontFamily: "'Source Sans 3', system-ui, sans-serif", headingFont: "'Lora', Georgia, serif", fontUrl: "https://fonts.googleapis.com/css2?family=Lora:wght@600;700&family=Source+Sans+3:wght@400;600;700&display=swap" },
47
+ { id: "playfair", label: "Inter + Playfair Display \u2014 elegant", fontFamily: "'Inter', system-ui, sans-serif", headingFont: "'Playfair Display', Georgia, serif", fontUrl: "https://fonts.googleapis.com/css2?family=Inter:wght@400;600&family=Playfair+Display:wght@600;700&display=swap" },
48
+ { id: "manrope", label: "Manrope \u2014 strak & technisch", fontFamily: "'Manrope', system-ui, sans-serif", fontUrl: "https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap" },
49
+ { id: "systeem", label: "Systeemlettertype \u2014 snelst, geen download", fontFamily: "system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif", fontUrl: "" }
50
+ ];
51
+ export {
52
+ LETTERTYPE_PAREN,
53
+ contrast,
54
+ contrastOordeel,
55
+ hexNaarRgb,
56
+ luminantie,
57
+ naarHex6,
58
+ tekstOp
59
+ };
60
+ //# sourceMappingURL=kleur.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../src/admin/lib/kleur.ts"],"sourcesContent":["/**\n * Kleurhulpjes voor het Huisstijl-scherm (7.24): hex ↔ getallen en het WCAG-contrast.\n *\n * Klanten kozen een merkkleur op gevoel en zagen pas op de site dat witte knoptekst op een\n * lichtgele knop onleesbaar is. Het contrastcijfer erbij zetten voorkomt dat gesprek.\n */\n\n/** \"#abc\" / \"#aabbcc\" / \"aabbcc\" → [r, g, b], of null bij iets wat geen hex-kleur is. */\nexport function hexNaarRgb(hex: string | undefined): [number, number, number] | null {\n if (!hex) return null;\n const m = hex.trim().replace(/^#/, \"\");\n const vol = m.length === 3 ? m.split(\"\").map((c) => c + c).join(\"\") : m;\n if (!/^[0-9a-fA-F]{6}$/.test(vol)) return null;\n return [parseInt(vol.slice(0, 2), 16), parseInt(vol.slice(2, 4), 16), parseInt(vol.slice(4, 6), 16)];\n}\n\n/** Naar een 6-cijferige hex voor `<input type=\"color\">` (die accepteert alleen #rrggbb). */\nexport function naarHex6(kleur: string | undefined, fallback = \"#000000\"): string {\n const rgb = hexNaarRgb(kleur);\n if (!rgb) return fallback;\n return `#${rgb.map((n) => n.toString(16).padStart(2, \"0\")).join(\"\")}`;\n}\n\nfunction lineair(c: number): number {\n const v = c / 255;\n return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;\n}\n\n/** Relatieve luminantie (WCAG 2.x). */\nexport function luminantie(rgb: [number, number, number]): number {\n return 0.2126 * lineair(rgb[0]) + 0.7152 * lineair(rgb[1]) + 0.0722 * lineair(rgb[2]);\n}\n\n/** Contrastverhouding tussen twee kleuren (1 = geen contrast, 21 = zwart op wit). */\nexport function contrast(a: string | undefined, b: string | undefined): number | null {\n const ra = hexNaarRgb(a);\n const rb = hexNaarRgb(b);\n if (!ra || !rb) return null;\n const la = luminantie(ra);\n const lb = luminantie(rb);\n const [licht, donker] = la > lb ? [la, lb] : [lb, la];\n return (licht + 0.05) / (donker + 0.05);\n}\n\n/** Welke tekstkleur komt er op een knop in deze kleur? (Spiegelt de on-accent-afleiding in CSS.) */\nexport function tekstOp(kleur: string | undefined): \"#ffffff\" | \"#0f172a\" {\n const rgb = hexNaarRgb(kleur);\n if (!rgb) return \"#ffffff\";\n return luminantie(rgb) > 0.4 ? \"#0f172a\" : \"#ffffff\";\n}\n\n/**\n * Oordeel in gewone woorden. AA voor normale tekst is 4.5; voor grote tekst en knoppen volstaat 3.\n * We meten tegen 3 (knoppen zijn het hoofdgebruik van de merkkleur) en waarschuwen daaronder.\n */\nexport function contrastOordeel(ratio: number | null): { tekst: string; toon: \"goed\" | \"matig\" | \"slecht\" } | null {\n if (ratio === null) return null;\n const r = Math.round(ratio * 10) / 10;\n if (ratio >= 4.5) return { tekst: `Contrast ${r}:1 — goed leesbaar`, toon: \"goed\" };\n if (ratio >= 3) return { tekst: `Contrast ${r}:1 — voldoende voor knoppen en koppen, krap voor kleine tekst`, toon: \"matig\" };\n return { tekst: `Contrast ${r}:1 — te laag; kies een donkerdere of lichtere tint`, toon: \"slecht\" };\n}\n\n/** Curated lettertype-paren voor het Huisstijl-scherm — één keuze, meteen goed. */\nexport const LETTERTYPE_PAREN: { id: string; label: string; fontFamily: string; headingFont?: string; fontUrl: string }[] = [\n { id: \"inter\", label: \"Inter — helder & neutraal\", fontFamily: \"'Inter', system-ui, sans-serif\", fontUrl: \"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap\" },\n { id: \"inter-sora\", label: \"Inter + Sora — modern met karakter\", fontFamily: \"'Inter', system-ui, sans-serif\", headingFont: \"'Sora', 'Inter', sans-serif\", fontUrl: \"https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&family=Sora:wght@600;700&display=swap\" },\n { id: \"poppins\", label: \"Poppins — rond & vriendelijk\", fontFamily: \"'Poppins', system-ui, sans-serif\", fontUrl: \"https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&display=swap\" },\n { id: \"nunito\", label: \"Nunito — zacht & toegankelijk\", fontFamily: \"'Nunito', system-ui, sans-serif\", fontUrl: \"https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700;800&display=swap\" },\n { id: \"lora-source\", label: \"Source Sans + Lora — klassiek & warm\", fontFamily: \"'Source Sans 3', system-ui, sans-serif\", headingFont: \"'Lora', Georgia, serif\", fontUrl: \"https://fonts.googleapis.com/css2?family=Lora:wght@600;700&family=Source+Sans+3:wght@400;600;700&display=swap\" },\n { id: \"playfair\", label: \"Inter + Playfair Display — elegant\", fontFamily: \"'Inter', system-ui, sans-serif\", headingFont: \"'Playfair Display', Georgia, serif\", fontUrl: \"https://fonts.googleapis.com/css2?family=Inter:wght@400;600&family=Playfair+Display:wght@600;700&display=swap\" },\n { id: \"manrope\", label: \"Manrope — strak & technisch\", fontFamily: \"'Manrope', system-ui, sans-serif\", fontUrl: \"https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap\" },\n { id: \"systeem\", label: \"Systeemlettertype — snelst, geen download\", fontFamily: \"system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif\", fontUrl: \"\" },\n];\n"],"mappings":"AAQO,SAAS,WAAW,KAA0D;AACnF,MAAI,CAAC,IAAK,QAAO;AACjB,QAAM,IAAI,IAAI,KAAK,EAAE,QAAQ,MAAM,EAAE;AACrC,QAAM,MAAM,EAAE,WAAW,IAAI,EAAE,MAAM,EAAE,EAAE,IAAI,CAAC,MAAM,IAAI,CAAC,EAAE,KAAK,EAAE,IAAI;AACtE,MAAI,CAAC,mBAAmB,KAAK,GAAG,EAAG,QAAO;AAC1C,SAAO,CAAC,SAAS,IAAI,MAAM,GAAG,CAAC,GAAG,EAAE,GAAG,SAAS,IAAI,MAAM,GAAG,CAAC,GAAG,EAAE,GAAG,SAAS,IAAI,MAAM,GAAG,CAAC,GAAG,EAAE,CAAC;AACrG;AAGO,SAAS,SAAS,OAA2B,WAAW,WAAmB;AAChF,QAAM,MAAM,WAAW,KAAK;AAC5B,MAAI,CAAC,IAAK,QAAO;AACjB,SAAO,IAAI,IAAI,IAAI,CAAC,MAAM,EAAE,SAAS,EAAE,EAAE,SAAS,GAAG,GAAG,CAAC,EAAE,KAAK,EAAE,CAAC;AACrE;AAEA,SAAS,QAAQ,GAAmB;AAClC,QAAM,IAAI,IAAI;AACd,SAAO,KAAK,UAAU,IAAI,UAAU,IAAI,SAAS,UAAU;AAC7D;AAGO,SAAS,WAAW,KAAuC;AAChE,SAAO,SAAS,QAAQ,IAAI,CAAC,CAAC,IAAI,SAAS,QAAQ,IAAI,CAAC,CAAC,IAAI,SAAS,QAAQ,IAAI,CAAC,CAAC;AACtF;AAGO,SAAS,SAAS,GAAuB,GAAsC;AACpF,QAAM,KAAK,WAAW,CAAC;AACvB,QAAM,KAAK,WAAW,CAAC;AACvB,MAAI,CAAC,MAAM,CAAC,GAAI,QAAO;AACvB,QAAM,KAAK,WAAW,EAAE;AACxB,QAAM,KAAK,WAAW,EAAE;AACxB,QAAM,CAAC,OAAO,MAAM,IAAI,KAAK,KAAK,CAAC,IAAI,EAAE,IAAI,CAAC,IAAI,EAAE;AACpD,UAAQ,QAAQ,SAAS,SAAS;AACpC;AAGO,SAAS,QAAQ,OAAkD;AACxE,QAAM,MAAM,WAAW,KAAK;AAC5B,MAAI,CAAC,IAAK,QAAO;AACjB,SAAO,WAAW,GAAG,IAAI,MAAM,YAAY;AAC7C;AAMO,SAAS,gBAAgB,OAAmF;AACjH,MAAI,UAAU,KAAM,QAAO;AAC3B,QAAM,IAAI,KAAK,MAAM,QAAQ,EAAE,IAAI;AACnC,MAAI,SAAS,IAAK,QAAO,EAAE,OAAO,YAAY,CAAC,2BAAsB,MAAM,OAAO;AAClF,MAAI,SAAS,EAAG,QAAO,EAAE,OAAO,YAAY,CAAC,sEAAiE,MAAM,QAAQ;AAC5H,SAAO,EAAE,OAAO,YAAY,CAAC,2DAAsD,MAAM,SAAS;AACpG;AAGO,MAAM,mBAA+G;AAAA,EAC1H,EAAE,IAAI,SAAS,OAAO,kCAA6B,YAAY,kCAAkC,SAAS,mFAAmF;AAAA,EAC7L,EAAE,IAAI,cAAc,OAAO,2CAAsC,YAAY,kCAAkC,aAAa,+BAA+B,SAAS,wGAAwG;AAAA,EAC5Q,EAAE,IAAI,WAAW,OAAO,qCAAgC,YAAY,oCAAoC,SAAS,qFAAqF;AAAA,EACtM,EAAE,IAAI,UAAU,OAAO,sCAAiC,YAAY,mCAAmC,SAAS,oFAAoF;AAAA,EACpM,EAAE,IAAI,eAAe,OAAO,6CAAwC,YAAY,0CAA0C,aAAa,0BAA0B,SAAS,gHAAgH;AAAA,EAC1R,EAAE,IAAI,YAAY,OAAO,2CAAsC,YAAY,kCAAkC,aAAa,sCAAsC,SAAS,gHAAgH;AAAA,EACzR,EAAE,IAAI,WAAW,OAAO,oCAA+B,YAAY,oCAAoC,SAAS,yFAAyF;AAAA,EACzM,EAAE,IAAI,WAAW,OAAO,kDAA6C,YAAY,4DAA4D,SAAS,GAAG;AAC3J;","names":[]}
@@ -2,7 +2,24 @@ import type { Page } from "../../schema/pages.js";
2
2
  export type DraftPage = Page & {
3
3
  id: string;
4
4
  published?: boolean;
5
+ /** Firestore-Timestamp (editor) of ISO-string (import-scripts). */
6
+ updatedAt?: unknown;
7
+ lastPublishedAt?: unknown;
8
+ /** 7.24: in de prullenbak; wordt na 30 dagen definitief verwijderd (purgeTrashedPages). */
9
+ trashed?: boolean;
10
+ trashedAt?: unknown;
5
11
  };
12
+ /**
13
+ * Staat er op deze pagina iets dat nog niet live is? (7.24)
14
+ *
15
+ * Nooit gepubliceerd = concept (dat is een eigen toestand, geen "wijziging"). Gepubliceerd en
16
+ * daarna opnieuw opgeslagen = wijzigingen die niemand ziet. De marge van 1,5 s vangt de
17
+ * autosave die `publishPage` vlak vóór het publiceren zelf doet (die zet `updatedAt` een tel
18
+ * eerder dan `lastPublishedAt`).
19
+ */
20
+ export declare function heeftOngepubliceerdeWijzigingen(p: Pick<DraftPage, "published" | "updatedAt" | "lastPublishedAt">): boolean;
21
+ /** Leesbare toestand voor badges: live / gewijzigd / concept / prullenbak. */
22
+ export declare function paginaToestand(p: DraftPage): "prullenbak" | "gewijzigd" | "live" | "concept";
6
23
  /** Live-lijst van pagina's (werkversies in `drafts/`). */
7
24
  export declare function subscribePages(siteId: string, cb: (pages: DraftPage[]) => void): () => void;
8
25
  export declare function subscribeVersions(siteId: string, pageId: string, cb: (versions: {
@@ -40,6 +57,21 @@ export declare function createPage(siteId: string, input: Partial<Page> & {
40
57
  path: string;
41
58
  }): Promise<string>;
42
59
  export declare function saveDraft(siteId: string, pageId: string, partial: Partial<Page>): Promise<void>;
60
+ /**
61
+ * Pagina dupliceren (7.24): nieuwe draft met dezelfde blokken (verse `_key`s), als concept, met
62
+ * "-kopie" achter pad en titel zodat hij nergens mee botst.
63
+ */
64
+ export declare function duplicatePage(siteId: string, bron: DraftPage): Promise<string>;
65
+ /** Offline halen (7.24): de live versie verdwijnt, de werkversie blijft. Via de callable. */
66
+ export declare function unpublishPage(siteId: string, pageId: string): Promise<void>;
67
+ /**
68
+ * Naar de prullenbak (7.24): offline halen + markeren. Geen delete — een klant die per ongeluk
69
+ * op "verwijderen" drukte kon tot 7.24 alleen via een herstelpunt terug. Na 30 dagen ruimt de
70
+ * backend definitief op (purgeTrashedPages).
71
+ */
72
+ export declare function trashPage(siteId: string, page: DraftPage): Promise<void>;
73
+ /** Terug uit de prullenbak: als concept (publiceren is een bewuste tweede stap). */
74
+ export declare function restorePage(siteId: string, pageId: string): Promise<void>;
43
75
  export declare function deletePage(siteId: string, pageId: string): Promise<void>;
44
76
  /**
45
77
  * Herstelt een eerdere versie naar de WERKVERSIE (draft). De gebruiker moet daarna zelf
@@ -18,6 +18,26 @@ import { callable } from "../../firebase/functionsClient.js";
18
18
  import { getDb, getFirebaseApp } from "../../firebase/client.js";
19
19
  import { isDemoActive, demoIdForPath, resolveDemoPage, saveDemoDraft } from "../../demo/demoStore.js";
20
20
  import { sanitizeDraftBlocks } from "./sanitizeDraft.js";
21
+ function ms(v) {
22
+ if (v && typeof v.toDate === "function") return v.toDate().getTime();
23
+ if (typeof v === "string") {
24
+ const t = Date.parse(v);
25
+ return Number.isNaN(t) ? 0 : t;
26
+ }
27
+ return 0;
28
+ }
29
+ function heeftOngepubliceerdeWijzigingen(p) {
30
+ if (!p.published) return false;
31
+ const u = ms(p.updatedAt);
32
+ const l = ms(p.lastPublishedAt);
33
+ if (!u || !l) return false;
34
+ return u - l > 1500;
35
+ }
36
+ function paginaToestand(p) {
37
+ if (p.trashed) return "prullenbak";
38
+ if (heeftOngepubliceerdeWijzigingen(p)) return "gewijzigd";
39
+ return p.published ? "live" : "concept";
40
+ }
21
41
  function subscribePages(siteId, cb) {
22
42
  const db = getDb();
23
43
  if (!db) {
@@ -112,6 +132,39 @@ async function saveDraft(siteId, pageId, partial) {
112
132
  { merge: true }
113
133
  );
114
134
  }
135
+ async function duplicatePage(siteId, bron) {
136
+ const nieuweKey = () => (globalThis.crypto?.randomUUID?.() ?? `b${Date.now()}${Math.random()}`).slice(0, 12);
137
+ const blocks = (bron.blocks ?? []).map((b) => ({ ...b, _key: nieuweKey() }));
138
+ const slug = `${bron.slug || "pagina"}-kopie`;
139
+ const path = bron.path === "/" ? "/home-kopie" : `${bron.path}-kopie`;
140
+ return createPage(siteId, {
141
+ title: `${bron.title} (kopie)`,
142
+ slug,
143
+ path,
144
+ blocks,
145
+ ...bron.seo ? { seo: bron.seo } : {},
146
+ ...bron.translations ? { translations: bron.translations } : {}
147
+ });
148
+ }
149
+ async function unpublishPage(siteId, pageId) {
150
+ if (isDemoActive()) return;
151
+ const app = getFirebaseApp();
152
+ if (!app) throw new Error("Firebase niet geconfigureerd.");
153
+ await callable(app, "unpublishPage")({ siteId, pageId });
154
+ }
155
+ async function trashPage(siteId, page) {
156
+ const db = getDb();
157
+ if (!db) return;
158
+ if (page.published) {
159
+ await unpublishPage(siteId, page.id).catch(() => void 0);
160
+ }
161
+ await setDoc(doc(db, "sites", siteId, "drafts", page.id), { trashed: true, trashedAt: serverTimestamp(), updatedAt: serverTimestamp() }, { merge: true });
162
+ }
163
+ async function restorePage(siteId, pageId) {
164
+ const db = getDb();
165
+ if (!db) return;
166
+ await setDoc(doc(db, "sites", siteId, "drafts", pageId), { trashed: false, trashedAt: null, updatedAt: serverTimestamp() }, { merge: true });
167
+ }
115
168
  async function deletePage(siteId, pageId) {
116
169
  const db = getDb();
117
170
  if (!db) return;
@@ -154,13 +207,19 @@ async function publishPage(siteId, pageId) {
154
207
  export {
155
208
  createPage,
156
209
  deletePage,
210
+ duplicatePage,
157
211
  getDraftByPath,
158
212
  getPublishedPage,
213
+ heeftOngepubliceerdeWijzigingen,
214
+ paginaToestand,
159
215
  publishPage,
216
+ restorePage,
160
217
  restoreVersion,
161
218
  saveDraft,
162
219
  subscribeDraft,
163
220
  subscribePages,
164
- subscribeVersions
221
+ subscribeVersions,
222
+ trashPage,
223
+ unpublishPage
165
224
  };
166
225
  //# sourceMappingURL=pagesClient.js.map