@xiwebdesign/xicms-core 7.17.1 → 7.18.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.
@@ -42,6 +42,7 @@ function MailSettings({ siteId }) {
42
42
  const [resendKey, setResendKey] = useState("");
43
43
  const [postmarkToken, setPostmarkToken] = useState("");
44
44
  const [smtp, setSmtp] = useState({ host: "", port: "465", secure: true, user: "", pass: "" });
45
+ const [sesRegion, setSesRegion] = useState("eu-west-1");
45
46
  const [fromName, setFromName] = useState("");
46
47
  const [fromEmail, setFromEmail] = useState("");
47
48
  const [notifyTo, setNotifyTo] = useState("");
@@ -51,6 +52,7 @@ function MailSettings({ siteId }) {
51
52
  setStatus(m);
52
53
  setMailProvider(m.provider ?? (m.host ? "smtp" : m.hasPostmarkToken ? "postmark" : m.hasResendKey ? "resend" : "smtp"));
53
54
  setSmtp((p) => ({ ...p, host: m.host ?? p.host, port: String(m.port ?? 465), secure: m.secure !== false, user: m.user ?? p.user }));
55
+ setSesRegion(m.sesRegion ?? "eu-west-1");
54
56
  setFromName(m.fromName ?? "");
55
57
  setFromEmail(m.fromEmail ?? "");
56
58
  setNotifyTo(m.notifyTo ?? "");
@@ -67,6 +69,13 @@ function MailSettings({ siteId }) {
67
69
  const mail = { provider: mailProvider, fromName, fromEmail, notifyTo };
68
70
  if (mailProvider === "resend") mail.resendApiKey = resendKey.trim() || void 0;
69
71
  if (mailProvider === "postmark") mail.postmarkToken = postmarkToken.trim() || void 0;
72
+ if (mailProvider === "ses") {
73
+ mail.sesRegion = sesRegion.trim();
74
+ mail.port = Number(smtp.port) || 465;
75
+ mail.secure = smtp.secure;
76
+ mail.user = smtp.user.trim();
77
+ if (smtp.pass) mail.pass = smtp.pass;
78
+ }
70
79
  if (mailProvider === "smtp") {
71
80
  mail.host = smtp.host.trim();
72
81
  mail.port = Number(smtp.port) || 465;
@@ -112,6 +121,7 @@ function MailSettings({ siteId }) {
112
121
  onChange: setMailProvider,
113
122
  options: [
114
123
  { value: "smtp", label: "Eigen SMTP" },
124
+ { value: "ses", label: "Amazon SES" },
115
125
  { value: "resend", label: "Resend" },
116
126
  { value: "postmark", label: "Postmark" }
117
127
  ]
@@ -134,6 +144,37 @@ function MailSettings({ siteId }) {
134
144
  /* @__PURE__ */ jsx(Field, { label: "Wachtwoord", hint: status?.hasSmtpPass ? "Leeg laten = ongewijzigd" : void 0, children: /* @__PURE__ */ jsx(Input, { type: "password", value: smtp.pass, onChange: (e) => setSmtp((p) => ({ ...p, pass: e.target.value })) }) })
135
145
  ] })
136
146
  ] }),
147
+ mailProvider === "ses" && /* @__PURE__ */ jsxs("div", { className: "grid gap-3", children: [
148
+ /* @__PURE__ */ jsxs("p", { className: "text-sm text-slate-600", children: [
149
+ /* @__PURE__ */ jsx("strong", { children: "Amazon SES" }),
150
+ " verstuurt vanaf je eigen domein en is goedkoop bij grotere aantallen. Let op drie dingen: de ",
151
+ /* @__PURE__ */ jsx("em", { children: "regio" }),
152
+ " moet dezelfde zijn als waar je domein geverifieerd is, de inloggegevens hieronder zijn",
153
+ " ",
154
+ /* @__PURE__ */ jsx("a", { className: "underline", href: "https://docs.aws.amazon.com/ses/latest/dg/smtp-credentials.html", target: "_blank", rel: "noreferrer", children: "SES-SMTP-inloggegevens" }),
155
+ " ",
156
+ "(niet je gewone AWS-sleutels), en een nieuw SES-account staat in de ",
157
+ /* @__PURE__ */ jsx("em", { children: "sandbox" }),
158
+ " \u2014 daar mag je alleen naar geverifieerde adressen mailen tot je productietoegang aanvraagt."
159
+ ] }),
160
+ /* @__PURE__ */ jsxs("div", { className: "grid gap-3 md:grid-cols-[1fr_110px]", children: [
161
+ /* @__PURE__ */ jsx(Field, { label: "Regio", hint: "Bijvoorbeeld eu-west-1 (Ierland) of eu-central-1 (Frankfurt)", children: /* @__PURE__ */ jsx(Input, { value: sesRegion, onChange: (e) => setSesRegion(e.target.value), placeholder: "eu-west-1" }) }),
162
+ /* @__PURE__ */ jsx(Field, { label: "Poort", children: /* @__PURE__ */ jsx(Input, { value: smtp.port, onChange: (e) => setSmtp((p) => ({ ...p, port: e.target.value })), placeholder: "465" }) })
163
+ ] }),
164
+ /* @__PURE__ */ jsxs("p", { className: "text-xs text-slate-500", children: [
165
+ "Server: ",
166
+ /* @__PURE__ */ jsxs("code", { children: [
167
+ "email-smtp.",
168
+ sesRegion.trim() || "eu-west-1",
169
+ ".amazonaws.com"
170
+ ] })
171
+ ] }),
172
+ /* @__PURE__ */ jsx(Switch, { checked: smtp.secure, onCheckedChange: (v) => setSmtp((p) => ({ ...p, secure: v })), label: "SSL (poort 465). Uit = STARTTLS (poort 587)" }),
173
+ /* @__PURE__ */ jsxs("div", { className: "grid gap-3 md:grid-cols-2", children: [
174
+ /* @__PURE__ */ jsx(Field, { label: "SMTP-gebruikersnaam", children: /* @__PURE__ */ jsx(Input, { value: smtp.user, onChange: (e) => setSmtp((p) => ({ ...p, user: e.target.value })), placeholder: "AKIA\u2026" }) }),
175
+ /* @__PURE__ */ jsx(Field, { label: "SMTP-wachtwoord", hint: status?.hasSmtpPass ? "Leeg laten = ongewijzigd" : void 0, children: /* @__PURE__ */ jsx(Input, { type: "password", value: smtp.pass, onChange: (e) => setSmtp((p) => ({ ...p, pass: e.target.value })) }) })
176
+ ] })
177
+ ] }),
137
178
  mailProvider === "resend" && /* @__PURE__ */ jsxs("div", { className: "grid gap-3", children: [
138
179
  /* @__PURE__ */ jsxs("p", { className: "text-sm text-slate-600", children: [
139
180
  /* @__PURE__ */ jsx("a", { className: "underline", href: "https://resend.com", target: "_blank", rel: "noreferrer", children: "Resend" }),
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/admin/settings/MailSettings.tsx"],"sourcesContent":["\"use client\";\n\nimport { useCallback, useEffect, useState } from \"react\";\nimport { callable } from \"../../firebase/functionsClient\";\nimport { getFirebaseApp } from \"../../firebase/client\";\nimport { Badge, Button, Card, Field, Input } from \"../ui/primitives\";\nimport { Switch } from \"../ui/switch\";\nimport { useToast } from \"../ui/toast\";\n\ntype MailProvider = \"resend\" | \"postmark\" | \"smtp\";\n\ninterface MailStatus {\n provider?: MailProvider;\n configured: boolean;\n host?: string;\n port?: number;\n secure?: boolean;\n user?: string;\n fromName?: string;\n fromEmail?: string;\n notifyTo?: string;\n hasResendKey?: boolean;\n hasPostmarkToken?: boolean;\n hasSmtpPass?: boolean;\n}\n\n/**\n * Generieke callable-helper (niet shop-gebonden): roept een functie op naam aan via de\n * client-Firebase. Bewust hier lokaal zodat de algemene instellingen niet van de opt-in\n * shop-module afhangen — mail is een site-brede instelling (zie docs/SHARED-SETTINGS.md).\n */\nfunction callFunction<TIn, TOut>(name: string) {\n return async (data: TIn): Promise<TOut> => {\n const app = getFirebaseApp();\n if (!app) throw new Error(\"Firebase niet beschikbaar.\");\n const fn = callable(app, name);\n const res = await fn(data as Record<string, unknown>);\n return res.data as TOut;\n };\n}\n\n/** Segmented control voor providerkeuze. */\nfunction Segmented<T extends string>({\n value,\n options,\n onChange,\n}: {\n value: T;\n options: { value: T; label: string }[];\n onChange: (v: T) => void;\n}) {\n return (\n <div className=\"inline-flex rounded-lg border border-slate-200 bg-slate-50 p-0.5\">\n {options.map((o) => (\n <button\n key={o.value}\n type=\"button\"\n onClick={() => onChange(o.value)}\n className={`rounded-md px-3 py-1.5 text-sm font-medium transition ${\n value === o.value ? \"bg-white text-brand-700 shadow-sm\" : \"text-slate-500 hover:text-slate-700\"\n }`}\n >\n {o.label}\n </button>\n ))}\n </div>\n );\n}\n\nfunction mailProviderLabel(p: MailProvider): string {\n return p === \"smtp\" ? \"Eigen SMTP\" : p === \"resend\" ? \"Resend\" : \"Postmark\";\n}\n\n/**\n * Site-brede mailprovider-configuratie (Resend / Postmark / eigen SMTP) + afzender-identiteit.\n * Deze instelling wordt door MEERDERE modules gebruikt (formulier-notificaties én de shop), dus\n * hoort site-breed thuis in de algemene instellingen — niet in een module-paneel. Geheimen gaan\n * uitsluitend via callables naar `sites/{siteId}/private/mail` (deny-all rules); dit paneel toont\n * alleen gemaskeerde status. Alleen een siteadmin kan opslaan.\n */\nexport function MailSettings({ siteId }: { siteId: string }) {\n const { toast } = useToast();\n const [status, setStatus] = useState<MailStatus | null>(null);\n const [busy, setBusy] = useState(false);\n\n const [mailProvider, setMailProvider] = useState<MailProvider>(\"smtp\");\n const [resendKey, setResendKey] = useState(\"\");\n const [postmarkToken, setPostmarkToken] = useState(\"\");\n const [smtp, setSmtp] = useState({ host: \"\", port: \"465\", secure: true, user: \"\", pass: \"\" });\n const [fromName, setFromName] = useState(\"\");\n const [fromEmail, setFromEmail] = useState(\"\");\n const [notifyTo, setNotifyTo] = useState(\"\");\n\n const refresh = useCallback(async () => {\n try {\n const m = await callFunction<{ siteId: string }, MailStatus>(\"getMailStatus\")({ siteId });\n setStatus(m);\n setMailProvider(m.provider ?? (m.host ? \"smtp\" : m.hasPostmarkToken ? \"postmark\" : m.hasResendKey ? \"resend\" : \"smtp\"));\n setSmtp((p) => ({ ...p, host: m.host ?? p.host, port: String(m.port ?? 465), secure: m.secure !== false, user: m.user ?? p.user }));\n setFromName(m.fromName ?? \"\");\n setFromEmail(m.fromEmail ?? \"\");\n setNotifyTo(m.notifyTo ?? \"\");\n } catch {\n setStatus(null);\n }\n }, [siteId]);\n\n useEffect(() => {\n void refresh();\n }, [refresh]);\n\n async function saveMail() {\n setBusy(true);\n try {\n const mail: Record<string, unknown> = { provider: mailProvider, fromName, fromEmail, notifyTo };\n if (mailProvider === \"resend\") mail.resendApiKey = resendKey.trim() || undefined;\n if (mailProvider === \"postmark\") mail.postmarkToken = postmarkToken.trim() || undefined;\n if (mailProvider === \"smtp\") {\n mail.host = smtp.host.trim();\n mail.port = Number(smtp.port) || 465;\n mail.secure = smtp.secure;\n mail.user = smtp.user.trim();\n if (smtp.pass) mail.pass = smtp.pass;\n }\n await callFunction<{ siteId: string; mail: Record<string, unknown> }, { ok: boolean }>(\"saveMailSettings\")({ siteId, mail });\n toast({ title: \"E-mailinstellingen opgeslagen\", tone: \"success\" });\n setResendKey(\"\");\n setPostmarkToken(\"\");\n setSmtp((p) => ({ ...p, pass: \"\" }));\n await refresh();\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 async function sendTest() {\n setBusy(true);\n try {\n // `sendShopTestMail` is een generieke testverzending (naam ongewijzigd voor compat).\n const res = await callFunction<{ siteId: string }, { to: string }>(\"sendShopTestMail\")({ siteId });\n toast({ title: \"Testmail verstuurd\", description: `Naar ${res.to} — check de inbox (en spam).`, tone: \"success\" });\n } catch (e) {\n toast({ title: \"Testmail mislukt\", description: e instanceof Error ? e.message : undefined, tone: \"error\" });\n } finally {\n setBusy(false);\n }\n }\n\n return (\n <Card>\n <div className=\"grid gap-4\">\n <div className=\"flex flex-wrap items-center justify-between gap-2\">\n <div>\n <h3 className=\"text-base font-semibold\">E-mail (formulieren &amp; shop)</h3>\n <p className=\"text-xs text-slate-500\">\n De provider waarmee deze site e-mails verstuurt — formulier-notificaties én\n bestelbevestigingen. Een provider verstuurt alleen vanaf een geverifieerd domein.\n </p>\n </div>\n {status && (\n <Badge tone={status.configured ? \"green\" : \"amber\"}>\n {status.configured ? `${mailProviderLabel(mailProvider)} actief` : \"Nog niet ingesteld\"}\n </Badge>\n )}\n </div>\n\n <Segmented\n value={mailProvider}\n onChange={setMailProvider}\n options={[\n { value: \"smtp\", label: \"Eigen SMTP\" },\n { value: \"resend\", label: \"Resend\" },\n { value: \"postmark\", label: \"Postmark\" },\n ]}\n />\n\n {mailProvider === \"smtp\" && (\n <div className=\"grid gap-3\">\n <p className=\"text-sm text-slate-600\">\n Via je eigen mailbox: bv. het adres bij je hostingpartij, of Gmail (smtp.gmail.com, poort 465, met een{\" \"}\n <a className=\"underline\" href=\"https://myaccount.google.com/apppasswords\" target=\"_blank\" rel=\"noreferrer\">app-wachtwoord</a>).\n </p>\n <div className=\"grid gap-3 md:grid-cols-[1fr_110px]\">\n <Field label=\"SMTP-server\"><Input value={smtp.host} onChange={(e) => setSmtp((p) => ({ ...p, host: e.target.value }))} placeholder=\"smtp.gmail.com\" /></Field>\n <Field label=\"Poort\"><Input value={smtp.port} onChange={(e) => setSmtp((p) => ({ ...p, port: e.target.value }))} placeholder=\"465\" /></Field>\n </div>\n <Switch checked={smtp.secure} onCheckedChange={(v) => setSmtp((p) => ({ ...p, secure: v }))} label=\"SSL (poort 465). Uit = STARTTLS (poort 587)\" />\n <div className=\"grid gap-3 md:grid-cols-2\">\n <Field label=\"Gebruikersnaam\"><Input value={smtp.user} onChange={(e) => setSmtp((p) => ({ ...p, user: e.target.value }))} placeholder=\"info@jouwdomein.nl\" /></Field>\n <Field label=\"Wachtwoord\" hint={status?.hasSmtpPass ? \"Leeg laten = ongewijzigd\" : undefined}>\n <Input type=\"password\" value={smtp.pass} onChange={(e) => setSmtp((p) => ({ ...p, pass: e.target.value }))} />\n </Field>\n </div>\n </div>\n )}\n\n {mailProvider === \"resend\" && (\n <div className=\"grid gap-3\">\n <p className=\"text-sm text-slate-600\">\n <a className=\"underline\" href=\"https://resend.com\" target=\"_blank\" rel=\"noreferrer\">Resend</a> verstuurt vanaf een geverifieerd domein.\n Maak een API-sleutel (re_…) en verifieer je afzenderdomein in het Resend-dashboard.\n </p>\n <Field label=\"Resend API-sleutel (re_…)\" hint={status?.hasResendKey ? \"Leeg laten = ongewijzigd\" : undefined}>\n <Input type=\"password\" value={resendKey} onChange={(e) => setResendKey(e.target.value)} placeholder=\"re_…\" />\n </Field>\n </div>\n )}\n\n {mailProvider === \"postmark\" && (\n <div className=\"grid gap-3\">\n <p className=\"text-sm text-slate-600\">\n <a className=\"underline\" href=\"https://postmarkapp.com\" target=\"_blank\" rel=\"noreferrer\">Postmark</a> verstuurt vanaf een geverifieerde afzender/domein (Sender Signature).\n Kopieer het Server-token.\n </p>\n <Field label=\"Postmark server-token\" hint={status?.hasPostmarkToken ? \"Leeg laten = ongewijzigd\" : undefined}>\n <Input type=\"password\" value={postmarkToken} onChange={(e) => setPostmarkToken(e.target.value)} placeholder=\"xxxxxxxx-xxxx-…\" />\n </Field>\n </div>\n )}\n\n {/* Gedeelde afzender-velden */}\n <div className=\"grid gap-3 md:grid-cols-2\">\n <Field label=\"Afzendernaam\"><Input value={fromName} onChange={(e) => setFromName(e.target.value)} placeholder=\"Mijn Site\" /></Field>\n <Field label=\"Afzenderadres\"><Input value={fromEmail} onChange={(e) => setFromEmail(e.target.value)} placeholder=\"info@jouwdomein.nl\" /></Field>\n </div>\n <Field label=\"Notificatie-adres\" hint=\"Hier ontvang je zelf een mail bij elk formulier/elke bestelling; ook het doel van de testmail\">\n <Input value={notifyTo} onChange={(e) => setNotifyTo(e.target.value)} placeholder=\"notificaties@jouwdomein.nl\" />\n </Field>\n\n <div className=\"flex items-center gap-2\">\n <Button onClick={saveMail} disabled={busy}>Opslaan</Button>\n <Button variant=\"secondary\" onClick={sendTest} disabled={busy || !status?.configured}>Testmail versturen</Button>\n </div>\n </div>\n </Card>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../../src/admin/settings/MailSettings.tsx"],"sourcesContent":["\"use client\";\n\nimport { useCallback, useEffect, useState } from \"react\";\nimport { callable } from \"../../firebase/functionsClient\";\nimport { getFirebaseApp } from \"../../firebase/client\";\nimport { Badge, Button, Card, Field, Input } from \"../ui/primitives\";\nimport { Switch } from \"../ui/switch\";\nimport { useToast } from \"../ui/toast\";\n\ntype MailProvider = \"resend\" | \"postmark\" | \"smtp\" | \"ses\";\n\ninterface MailStatus {\n provider?: MailProvider;\n configured: boolean;\n /** provider \"ses\": AWS-regio waar het domein geverifieerd is. */\n sesRegion?: string;\n host?: string;\n port?: number;\n secure?: boolean;\n user?: string;\n fromName?: string;\n fromEmail?: string;\n notifyTo?: string;\n hasResendKey?: boolean;\n hasPostmarkToken?: boolean;\n hasSmtpPass?: boolean;\n}\n\n/**\n * Generieke callable-helper (niet shop-gebonden): roept een functie op naam aan via de\n * client-Firebase. Bewust hier lokaal zodat de algemene instellingen niet van de opt-in\n * shop-module afhangen — mail is een site-brede instelling (zie docs/SHARED-SETTINGS.md).\n */\nfunction callFunction<TIn, TOut>(name: string) {\n return async (data: TIn): Promise<TOut> => {\n const app = getFirebaseApp();\n if (!app) throw new Error(\"Firebase niet beschikbaar.\");\n const fn = callable(app, name);\n const res = await fn(data as Record<string, unknown>);\n return res.data as TOut;\n };\n}\n\n/** Segmented control voor providerkeuze. */\nfunction Segmented<T extends string>({\n value,\n options,\n onChange,\n}: {\n value: T;\n options: { value: T; label: string }[];\n onChange: (v: T) => void;\n}) {\n return (\n <div className=\"inline-flex rounded-lg border border-slate-200 bg-slate-50 p-0.5\">\n {options.map((o) => (\n <button\n key={o.value}\n type=\"button\"\n onClick={() => onChange(o.value)}\n className={`rounded-md px-3 py-1.5 text-sm font-medium transition ${\n value === o.value ? \"bg-white text-brand-700 shadow-sm\" : \"text-slate-500 hover:text-slate-700\"\n }`}\n >\n {o.label}\n </button>\n ))}\n </div>\n );\n}\n\nfunction mailProviderLabel(p: MailProvider): string {\n return p === \"smtp\" ? \"Eigen SMTP\" : p === \"resend\" ? \"Resend\" : \"Postmark\";\n}\n\n/**\n * Site-brede mailprovider-configuratie (Resend / Postmark / eigen SMTP) + afzender-identiteit.\n * Deze instelling wordt door MEERDERE modules gebruikt (formulier-notificaties én de shop), dus\n * hoort site-breed thuis in de algemene instellingen — niet in een module-paneel. Geheimen gaan\n * uitsluitend via callables naar `sites/{siteId}/private/mail` (deny-all rules); dit paneel toont\n * alleen gemaskeerde status. Alleen een siteadmin kan opslaan.\n */\nexport function MailSettings({ siteId }: { siteId: string }) {\n const { toast } = useToast();\n const [status, setStatus] = useState<MailStatus | null>(null);\n const [busy, setBusy] = useState(false);\n\n const [mailProvider, setMailProvider] = useState<MailProvider>(\"smtp\");\n const [resendKey, setResendKey] = useState(\"\");\n const [postmarkToken, setPostmarkToken] = useState(\"\");\n const [smtp, setSmtp] = useState({ host: \"\", port: \"465\", secure: true, user: \"\", pass: \"\" });\n const [sesRegion, setSesRegion] = useState(\"eu-west-1\");\n const [fromName, setFromName] = useState(\"\");\n const [fromEmail, setFromEmail] = useState(\"\");\n const [notifyTo, setNotifyTo] = useState(\"\");\n\n const refresh = useCallback(async () => {\n try {\n const m = await callFunction<{ siteId: string }, MailStatus>(\"getMailStatus\")({ siteId });\n setStatus(m);\n setMailProvider(m.provider ?? (m.host ? \"smtp\" : m.hasPostmarkToken ? \"postmark\" : m.hasResendKey ? \"resend\" : \"smtp\"));\n setSmtp((p) => ({ ...p, host: m.host ?? p.host, port: String(m.port ?? 465), secure: m.secure !== false, user: m.user ?? p.user }));\n setSesRegion(m.sesRegion ?? \"eu-west-1\");\n setFromName(m.fromName ?? \"\");\n setFromEmail(m.fromEmail ?? \"\");\n setNotifyTo(m.notifyTo ?? \"\");\n } catch {\n setStatus(null);\n }\n }, [siteId]);\n\n useEffect(() => {\n void refresh();\n }, [refresh]);\n\n async function saveMail() {\n setBusy(true);\n try {\n const mail: Record<string, unknown> = { provider: mailProvider, fromName, fromEmail, notifyTo };\n if (mailProvider === \"resend\") mail.resendApiKey = resendKey.trim() || undefined;\n if (mailProvider === \"postmark\") mail.postmarkToken = postmarkToken.trim() || undefined;\n if (mailProvider === \"ses\") {\n // Host leidt de server af uit de regio; hier alleen de SES-SMTP-inloggegevens.\n mail.sesRegion = sesRegion.trim();\n mail.port = Number(smtp.port) || 465;\n mail.secure = smtp.secure;\n mail.user = smtp.user.trim();\n if (smtp.pass) mail.pass = smtp.pass;\n }\n if (mailProvider === \"smtp\") {\n mail.host = smtp.host.trim();\n mail.port = Number(smtp.port) || 465;\n mail.secure = smtp.secure;\n mail.user = smtp.user.trim();\n if (smtp.pass) mail.pass = smtp.pass;\n }\n await callFunction<{ siteId: string; mail: Record<string, unknown> }, { ok: boolean }>(\"saveMailSettings\")({ siteId, mail });\n toast({ title: \"E-mailinstellingen opgeslagen\", tone: \"success\" });\n setResendKey(\"\");\n setPostmarkToken(\"\");\n setSmtp((p) => ({ ...p, pass: \"\" }));\n await refresh();\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 async function sendTest() {\n setBusy(true);\n try {\n // `sendShopTestMail` is een generieke testverzending (naam ongewijzigd voor compat).\n const res = await callFunction<{ siteId: string }, { to: string }>(\"sendShopTestMail\")({ siteId });\n toast({ title: \"Testmail verstuurd\", description: `Naar ${res.to} — check de inbox (en spam).`, tone: \"success\" });\n } catch (e) {\n toast({ title: \"Testmail mislukt\", description: e instanceof Error ? e.message : undefined, tone: \"error\" });\n } finally {\n setBusy(false);\n }\n }\n\n return (\n <Card>\n <div className=\"grid gap-4\">\n <div className=\"flex flex-wrap items-center justify-between gap-2\">\n <div>\n <h3 className=\"text-base font-semibold\">E-mail (formulieren &amp; shop)</h3>\n <p className=\"text-xs text-slate-500\">\n De provider waarmee deze site e-mails verstuurt — formulier-notificaties én\n bestelbevestigingen. Een provider verstuurt alleen vanaf een geverifieerd domein.\n </p>\n </div>\n {status && (\n <Badge tone={status.configured ? \"green\" : \"amber\"}>\n {status.configured ? `${mailProviderLabel(mailProvider)} actief` : \"Nog niet ingesteld\"}\n </Badge>\n )}\n </div>\n\n <Segmented\n value={mailProvider}\n onChange={setMailProvider}\n options={[\n { value: \"smtp\", label: \"Eigen SMTP\" },\n { value: \"ses\", label: \"Amazon SES\" },\n { value: \"resend\", label: \"Resend\" },\n { value: \"postmark\", label: \"Postmark\" },\n ]}\n />\n\n {mailProvider === \"smtp\" && (\n <div className=\"grid gap-3\">\n <p className=\"text-sm text-slate-600\">\n Via je eigen mailbox: bv. het adres bij je hostingpartij, of Gmail (smtp.gmail.com, poort 465, met een{\" \"}\n <a className=\"underline\" href=\"https://myaccount.google.com/apppasswords\" target=\"_blank\" rel=\"noreferrer\">app-wachtwoord</a>).\n </p>\n <div className=\"grid gap-3 md:grid-cols-[1fr_110px]\">\n <Field label=\"SMTP-server\"><Input value={smtp.host} onChange={(e) => setSmtp((p) => ({ ...p, host: e.target.value }))} placeholder=\"smtp.gmail.com\" /></Field>\n <Field label=\"Poort\"><Input value={smtp.port} onChange={(e) => setSmtp((p) => ({ ...p, port: e.target.value }))} placeholder=\"465\" /></Field>\n </div>\n <Switch checked={smtp.secure} onCheckedChange={(v) => setSmtp((p) => ({ ...p, secure: v }))} label=\"SSL (poort 465). Uit = STARTTLS (poort 587)\" />\n <div className=\"grid gap-3 md:grid-cols-2\">\n <Field label=\"Gebruikersnaam\"><Input value={smtp.user} onChange={(e) => setSmtp((p) => ({ ...p, user: e.target.value }))} placeholder=\"info@jouwdomein.nl\" /></Field>\n <Field label=\"Wachtwoord\" hint={status?.hasSmtpPass ? \"Leeg laten = ongewijzigd\" : undefined}>\n <Input type=\"password\" value={smtp.pass} onChange={(e) => setSmtp((p) => ({ ...p, pass: e.target.value }))} />\n </Field>\n </div>\n </div>\n )}\n\n {mailProvider === \"ses\" && (\n <div className=\"grid gap-3\">\n <p className=\"text-sm text-slate-600\">\n <strong>Amazon SES</strong> verstuurt vanaf je eigen domein en is goedkoop bij grotere\n aantallen. Let op drie dingen: de <em>regio</em> moet dezelfde zijn als waar je domein\n geverifieerd is, de inloggegevens hieronder zijn{\" \"}\n <a className=\"underline\" href=\"https://docs.aws.amazon.com/ses/latest/dg/smtp-credentials.html\" target=\"_blank\" rel=\"noreferrer\">\n SES-SMTP-inloggegevens\n </a>{\" \"}\n (niet je gewone AWS-sleutels), en een nieuw SES-account staat in de <em>sandbox</em> —\n daar mag je alleen naar geverifieerde adressen mailen tot je productietoegang aanvraagt.\n </p>\n <div className=\"grid gap-3 md:grid-cols-[1fr_110px]\">\n <Field label=\"Regio\" hint=\"Bijvoorbeeld eu-west-1 (Ierland) of eu-central-1 (Frankfurt)\">\n <Input value={sesRegion} onChange={(e) => setSesRegion(e.target.value)} placeholder=\"eu-west-1\" />\n </Field>\n <Field label=\"Poort\"><Input value={smtp.port} onChange={(e) => setSmtp((p) => ({ ...p, port: e.target.value }))} placeholder=\"465\" /></Field>\n </div>\n <p className=\"text-xs text-slate-500\">\n Server: <code>email-smtp.{sesRegion.trim() || \"eu-west-1\"}.amazonaws.com</code>\n </p>\n <Switch checked={smtp.secure} onCheckedChange={(v) => setSmtp((p) => ({ ...p, secure: v }))} label=\"SSL (poort 465). Uit = STARTTLS (poort 587)\" />\n <div className=\"grid gap-3 md:grid-cols-2\">\n <Field label=\"SMTP-gebruikersnaam\"><Input value={smtp.user} onChange={(e) => setSmtp((p) => ({ ...p, user: e.target.value }))} placeholder=\"AKIA…\" /></Field>\n <Field label=\"SMTP-wachtwoord\" hint={status?.hasSmtpPass ? \"Leeg laten = ongewijzigd\" : undefined}>\n <Input type=\"password\" value={smtp.pass} onChange={(e) => setSmtp((p) => ({ ...p, pass: e.target.value }))} />\n </Field>\n </div>\n </div>\n )}\n\n {mailProvider === \"resend\" && (\n <div className=\"grid gap-3\">\n <p className=\"text-sm text-slate-600\">\n <a className=\"underline\" href=\"https://resend.com\" target=\"_blank\" rel=\"noreferrer\">Resend</a> verstuurt vanaf een geverifieerd domein.\n Maak een API-sleutel (re_…) en verifieer je afzenderdomein in het Resend-dashboard.\n </p>\n <Field label=\"Resend API-sleutel (re_…)\" hint={status?.hasResendKey ? \"Leeg laten = ongewijzigd\" : undefined}>\n <Input type=\"password\" value={resendKey} onChange={(e) => setResendKey(e.target.value)} placeholder=\"re_…\" />\n </Field>\n </div>\n )}\n\n {mailProvider === \"postmark\" && (\n <div className=\"grid gap-3\">\n <p className=\"text-sm text-slate-600\">\n <a className=\"underline\" href=\"https://postmarkapp.com\" target=\"_blank\" rel=\"noreferrer\">Postmark</a> verstuurt vanaf een geverifieerde afzender/domein (Sender Signature).\n Kopieer het Server-token.\n </p>\n <Field label=\"Postmark server-token\" hint={status?.hasPostmarkToken ? \"Leeg laten = ongewijzigd\" : undefined}>\n <Input type=\"password\" value={postmarkToken} onChange={(e) => setPostmarkToken(e.target.value)} placeholder=\"xxxxxxxx-xxxx-…\" />\n </Field>\n </div>\n )}\n\n {/* Gedeelde afzender-velden */}\n <div className=\"grid gap-3 md:grid-cols-2\">\n <Field label=\"Afzendernaam\"><Input value={fromName} onChange={(e) => setFromName(e.target.value)} placeholder=\"Mijn Site\" /></Field>\n <Field label=\"Afzenderadres\"><Input value={fromEmail} onChange={(e) => setFromEmail(e.target.value)} placeholder=\"info@jouwdomein.nl\" /></Field>\n </div>\n <Field label=\"Notificatie-adres\" hint=\"Hier ontvang je zelf een mail bij elk formulier/elke bestelling; ook het doel van de testmail\">\n <Input value={notifyTo} onChange={(e) => setNotifyTo(e.target.value)} placeholder=\"notificaties@jouwdomein.nl\" />\n </Field>\n\n <div className=\"flex items-center gap-2\">\n <Button onClick={saveMail} disabled={busy}>Opslaan</Button>\n <Button variant=\"secondary\" onClick={sendTest} disabled={busy || !status?.configured}>Testmail versturen</Button>\n </div>\n </div>\n </Card>\n );\n}\n"],"mappings":"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","names":[]}
@@ -8,6 +8,7 @@ import { Button, Card, Field, Input, Textarea } from "../ui/primitives.js";
8
8
  import { Switch } from "../ui/switch.js";
9
9
  import { useToast } from "../ui/toast.js";
10
10
  import { useConfirm } from "../ui/ConfirmDialog.js";
11
+ import { shopCallable } from "./lib.js";
11
12
  import { MediaPickerButton } from "../media/MediaPicker.js";
12
13
  import { slugify } from "../lib/slugify.js";
13
14
  function CategoriesTab({ siteId }) {
@@ -28,13 +29,20 @@ function CategoriesTab({ siteId }) {
28
29
  async function createCategory() {
29
30
  const db = getDb();
30
31
  if (!db) return;
31
- const ref = await addDoc(collection(db, "sites", siteId, "shopCategories"), {
32
+ const nieuw = {
32
33
  name: "Nieuwe categorie",
33
34
  slug: `categorie-${cats.length + 1}`,
34
35
  order: cats.length,
35
36
  visible: true
36
- });
37
- setSelected(ref.id);
37
+ };
38
+ try {
39
+ const res = await shopCallable("saveShopCategory")({ siteId, category: nieuw });
40
+ setSelected(res.id);
41
+ } catch (err) {
42
+ if (err?.code !== "functions/not-found") throw err;
43
+ const ref = await addDoc(collection(db, "sites", siteId, "shopCategories"), nieuw);
44
+ setSelected(ref.id);
45
+ }
38
46
  }
39
47
  const current = cats.find((c) => c.id === selected) ?? null;
40
48
  return /* @__PURE__ */ jsxs("div", { className: "grid gap-4 md:grid-cols-[260px_1fr]", children: [
@@ -87,7 +95,16 @@ function CategoryDetail({
87
95
  onToast({ title: "Ongeldige categorie", description: parsed.error.issues[0]?.message, tone: "error" });
88
96
  return;
89
97
  }
90
- await setDoc(doc(db, "sites", siteId, "shopCategories", category.id), parsed.data);
98
+ try {
99
+ await shopCallable("saveShopCategory")({
100
+ siteId,
101
+ categoryId: category.id,
102
+ category: parsed.data
103
+ });
104
+ } catch (err) {
105
+ if (err?.code !== "functions/not-found") throw err;
106
+ await setDoc(doc(db, "sites", siteId, "shopCategories", category.id), parsed.data);
107
+ }
91
108
  onToast({ title: "Categorie opgeslagen", tone: "success" });
92
109
  }
93
110
  async function remove() {
@@ -98,7 +115,12 @@ function CategoryDetail({
98
115
  description: "Producten in deze categorie blijven bestaan (alleen de koppeling vervalt)."
99
116
  });
100
117
  if (!ok) return;
101
- await deleteDoc(doc(db, "sites", siteId, "shopCategories", category.id));
118
+ try {
119
+ await shopCallable("deleteShopCategory")({ siteId, categoryId: category.id });
120
+ } catch (err) {
121
+ if (err?.code !== "functions/not-found") throw err;
122
+ await deleteDoc(doc(db, "sites", siteId, "shopCategories", category.id));
123
+ }
102
124
  onDeleted();
103
125
  }
104
126
  return /* @__PURE__ */ jsx(Card, { children: /* @__PURE__ */ jsxs("div", { className: "grid gap-4", children: [
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/admin/shop/CategoriesTab.tsx"],"sourcesContent":["\"use client\";\n\nimport { useEffect, useState } from \"react\";\nimport { addDoc, collection, deleteDoc, doc, onSnapshot, setDoc } from \"firebase/firestore\";\nimport { getDb } from \"../../firebase/client\";\nimport { ShopCategorySchema, type ShopCategory } from \"../../schema/shop\";\nimport { Button, Card, Field, Input, Textarea } from \"../ui/primitives\";\nimport { Switch } from \"../ui/switch\";\nimport { useToast } from \"../ui/toast\";\nimport { useConfirm } from \"../ui/ConfirmDialog\";\nimport { MediaPickerButton } from \"../media/MediaPicker\";\nimport { slugify } from \"../lib/slugify\";\n\ntype StoredCategory = ShopCategory & { id: string };\n\nexport function CategoriesTab({ siteId }: { siteId: string }) {\n const { toast } = useToast();\n const confirm = useConfirm();\n const [cats, setCats] = useState<StoredCategory[]>([]);\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, \"shopCategories\"), (snap) =>\n setCats(\n snap.docs\n .map((d) => ({ id: d.id, ...(d.data() as ShopCategory) }))\n .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)),\n ),\n );\n }, [siteId]);\n\n async function createCategory() {\n const db = getDb();\n if (!db) return;\n const ref = await addDoc(collection(db, \"sites\", siteId, \"shopCategories\"), {\n name: \"Nieuwe categorie\",\n slug: `categorie-${cats.length + 1}`,\n order: cats.length,\n visible: true,\n } satisfies ShopCategory);\n setSelected(ref.id);\n }\n\n const current = cats.find((c) => c.id === selected) ?? null;\n\n return (\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\">Categorieën</h3>\n <Button size=\"sm\" onClick={createCategory}>+ Nieuw</Button>\n </div>\n <ul className=\"grid gap-1\">\n {cats.map((c) => (\n <li key={c.id}>\n <button\n onClick={() => setSelected(c.id)}\n className={`w-full truncate rounded-lg px-3 py-2 text-left text-sm ${\n c.id === selected ? \"bg-brand-50 text-brand-700\" : \"hover:bg-slate-100\"\n }`}\n >\n {c.name}\n {c.visible === false && <span className=\"ml-1 text-xs text-slate-400\">(verborgen)</span>}\n </button>\n </li>\n ))}\n {cats.length === 0 && <li className=\"px-3 py-2 text-sm text-slate-500\">Nog geen categorieën.</li>}\n </ul>\n </Card>\n {current ? (\n <CategoryDetail\n key={current.id}\n siteId={siteId}\n category={current}\n onDeleted={() => setSelected(null)}\n onToast={toast}\n confirm={confirm}\n />\n ) : (\n <Card>\n <p className=\"text-sm text-slate-500\">Selecteer of maak een categorie.</p>\n </Card>\n )}\n </div>\n );\n}\n\nfunction CategoryDetail({\n siteId,\n category,\n onDeleted,\n onToast,\n confirm,\n}: {\n siteId: string;\n category: StoredCategory;\n onDeleted: () => void;\n onToast: (t: { title: string; description?: string; tone?: \"default\" | \"success\" | \"error\" }) => void;\n confirm: (opts: { title: string; description?: string }) => Promise<boolean>;\n}) {\n const [def, setDef] = useState<ShopCategory>(category);\n\n async function save() {\n const db = getDb();\n if (!db) return;\n const parsed = ShopCategorySchema.safeParse(def);\n if (!parsed.success) {\n onToast({ title: \"Ongeldige categorie\", description: parsed.error.issues[0]?.message, tone: \"error\" });\n return;\n }\n await setDoc(doc(db, \"sites\", siteId, \"shopCategories\", category.id), parsed.data);\n onToast({ title: \"Categorie opgeslagen\", tone: \"success\" });\n }\n\n async function remove() {\n const db = getDb();\n if (!db) return;\n const ok = await confirm({\n title: `\"${category.name}\" verwijderen?`,\n description: \"Producten in deze categorie blijven bestaan (alleen de koppeling vervalt).\",\n });\n if (!ok) return;\n await deleteDoc(doc(db, \"sites\", siteId, \"shopCategories\", category.id));\n onDeleted();\n }\n\n return (\n <Card>\n <div className=\"grid gap-4\">\n <Field label=\"Naam\">\n <Input\n value={def.name}\n onChange={(e) => setDef((p) => ({ ...p, name: e.target.value, slug: slugify(e.target.value) || p.slug }))}\n />\n </Field>\n <Field label=\"Slug\" hint=\"Wordt gebruikt in de winkel-URL (?cat=…)\">\n <Input value={def.slug} onChange={(e) => setDef((p) => ({ ...p, slug: slugify(e.target.value) }))} />\n </Field>\n <Field label=\"Omschrijving (optioneel)\">\n <Textarea rows={2} value={def.description ?? \"\"} onChange={(e) => setDef((p) => ({ ...p, description: e.target.value }))} />\n </Field>\n <Field label=\"Afbeelding\">\n <div className=\"flex items-center gap-3\">\n {def.imageUrl && <img src={def.imageUrl} alt=\"\" className=\"h-14 w-14 rounded-lg object-cover\" />}\n <MediaPickerButton siteId={siteId} onSelect={(m) => setDef((p) => ({ ...p, imageUrl: m.url }))} />\n {def.imageUrl && (\n <Button size=\"sm\" variant=\"ghost\" onClick={() => setDef((p) => ({ ...p, imageUrl: undefined }))}>\n Verwijder\n </Button>\n )}\n </div>\n </Field>\n <div className=\"flex items-center gap-6\">\n <Field label=\"Volgorde\">\n <Input\n type=\"number\"\n className=\"w-24\"\n value={def.order ?? 0}\n onChange={(e) => setDef((p) => ({ ...p, order: Math.max(0, Number(e.target.value) || 0) }))}\n />\n </Field>\n <Switch checked={def.visible !== false} onCheckedChange={(v) => setDef((p) => ({ ...p, visible: v }))} label=\"Zichtbaar in de winkel\" />\n </div>\n <div className=\"flex items-center justify-between\">\n <Button onClick={save}>Opslaan</Button>\n <Button variant=\"ghost\" onClick={remove}>Verwijderen</Button>\n </div>\n </div>\n </Card>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../../src/admin/shop/CategoriesTab.tsx"],"sourcesContent":["\"use client\";\n\nimport { useEffect, useState } from \"react\";\nimport { addDoc, collection, deleteDoc, doc, onSnapshot, setDoc } from \"firebase/firestore\";\nimport { getDb } from \"../../firebase/client\";\nimport { ShopCategorySchema, type ShopCategory } from \"../../schema/shop\";\nimport { Button, Card, Field, Input, Textarea } from \"../ui/primitives\";\nimport { Switch } from \"../ui/switch\";\nimport { useToast } from \"../ui/toast\";\nimport { useConfirm } from \"../ui/ConfirmDialog\";\nimport { shopCallable } from \"./lib\";\nimport { MediaPickerButton } from \"../media/MediaPicker\";\nimport { slugify } from \"../lib/slugify\";\n\ntype StoredCategory = ShopCategory & { id: string };\n\nexport function CategoriesTab({ siteId }: { siteId: string }) {\n const { toast } = useToast();\n const confirm = useConfirm();\n const [cats, setCats] = useState<StoredCategory[]>([]);\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, \"shopCategories\"), (snap) =>\n setCats(\n snap.docs\n .map((d) => ({ id: d.id, ...(d.data() as ShopCategory) }))\n .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)),\n ),\n );\n }, [siteId]);\n\n async function createCategory() {\n const db = getDb();\n if (!db) return;\n const nieuw = {\n name: \"Nieuwe categorie\",\n slug: `categorie-${cats.length + 1}`,\n order: cats.length,\n visible: true,\n } satisfies ShopCategory;\n try {\n const res = await shopCallable<{ siteId: string; category: unknown }, { id: string }>(\"saveShopCategory\")({ siteId, category: nieuw });\n setSelected(res.id);\n } catch (err) {\n if ((err as { code?: string } | null)?.code !== \"functions/not-found\") throw err;\n const ref = await addDoc(collection(db, \"sites\", siteId, \"shopCategories\"), nieuw);\n setSelected(ref.id);\n }\n }\n\n const current = cats.find((c) => c.id === selected) ?? null;\n\n return (\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\">Categorieën</h3>\n <Button size=\"sm\" onClick={createCategory}>+ Nieuw</Button>\n </div>\n <ul className=\"grid gap-1\">\n {cats.map((c) => (\n <li key={c.id}>\n <button\n onClick={() => setSelected(c.id)}\n className={`w-full truncate rounded-lg px-3 py-2 text-left text-sm ${\n c.id === selected ? \"bg-brand-50 text-brand-700\" : \"hover:bg-slate-100\"\n }`}\n >\n {c.name}\n {c.visible === false && <span className=\"ml-1 text-xs text-slate-400\">(verborgen)</span>}\n </button>\n </li>\n ))}\n {cats.length === 0 && <li className=\"px-3 py-2 text-sm text-slate-500\">Nog geen categorieën.</li>}\n </ul>\n </Card>\n {current ? (\n <CategoryDetail\n key={current.id}\n siteId={siteId}\n category={current}\n onDeleted={() => setSelected(null)}\n onToast={toast}\n confirm={confirm}\n />\n ) : (\n <Card>\n <p className=\"text-sm text-slate-500\">Selecteer of maak een categorie.</p>\n </Card>\n )}\n </div>\n );\n}\n\nfunction CategoryDetail({\n siteId,\n category,\n onDeleted,\n onToast,\n confirm,\n}: {\n siteId: string;\n category: StoredCategory;\n onDeleted: () => void;\n onToast: (t: { title: string; description?: string; tone?: \"default\" | \"success\" | \"error\" }) => void;\n confirm: (opts: { title: string; description?: string }) => Promise<boolean>;\n}) {\n const [def, setDef] = useState<ShopCategory>(category);\n\n async function save() {\n const db = getDb();\n if (!db) return;\n const parsed = ShopCategorySchema.safeParse(def);\n if (!parsed.success) {\n onToast({ title: \"Ongeldige categorie\", description: parsed.error.issues[0]?.message, tone: \"error\" });\n return;\n }\n /**\n * Via de callable (7.18): die maakt de server-cache van de categorieënlijst ongeldig. Een\n * rechtstreekse write stond meteen in Firestore maar pas na het ISR-vangnet (tot een uur) op\n * de winkel — zelfde valkuil als bij producten. Terugval op de directe write houdt een site\n * met een oudere functions-deploy werkend (zie productClient.ts).\n */\n try {\n await shopCallable<{ siteId: string; categoryId: string; category: unknown }, { id: string }>(\"saveShopCategory\")({\n siteId,\n categoryId: category.id,\n category: parsed.data,\n });\n } catch (err) {\n if ((err as { code?: string } | null)?.code !== \"functions/not-found\") throw err;\n await setDoc(doc(db, \"sites\", siteId, \"shopCategories\", category.id), parsed.data);\n }\n onToast({ title: \"Categorie opgeslagen\", tone: \"success\" });\n }\n\n async function remove() {\n const db = getDb();\n if (!db) return;\n const ok = await confirm({\n title: `\"${category.name}\" verwijderen?`,\n description: \"Producten in deze categorie blijven bestaan (alleen de koppeling vervalt).\",\n });\n if (!ok) return;\n try {\n await shopCallable<{ siteId: string; categoryId: string }, unknown>(\"deleteShopCategory\")({ siteId, categoryId: category.id });\n } catch (err) {\n if ((err as { code?: string } | null)?.code !== \"functions/not-found\") throw err;\n await deleteDoc(doc(db, \"sites\", siteId, \"shopCategories\", category.id));\n }\n onDeleted();\n }\n\n return (\n <Card>\n <div className=\"grid gap-4\">\n <Field label=\"Naam\">\n <Input\n value={def.name}\n onChange={(e) => setDef((p) => ({ ...p, name: e.target.value, slug: slugify(e.target.value) || p.slug }))}\n />\n </Field>\n <Field label=\"Slug\" hint=\"Wordt gebruikt in de winkel-URL (?cat=…)\">\n <Input value={def.slug} onChange={(e) => setDef((p) => ({ ...p, slug: slugify(e.target.value) }))} />\n </Field>\n <Field label=\"Omschrijving (optioneel)\">\n <Textarea rows={2} value={def.description ?? \"\"} onChange={(e) => setDef((p) => ({ ...p, description: e.target.value }))} />\n </Field>\n <Field label=\"Afbeelding\">\n <div className=\"flex items-center gap-3\">\n {def.imageUrl && <img src={def.imageUrl} alt=\"\" className=\"h-14 w-14 rounded-lg object-cover\" />}\n <MediaPickerButton siteId={siteId} onSelect={(m) => setDef((p) => ({ ...p, imageUrl: m.url }))} />\n {def.imageUrl && (\n <Button size=\"sm\" variant=\"ghost\" onClick={() => setDef((p) => ({ ...p, imageUrl: undefined }))}>\n Verwijder\n </Button>\n )}\n </div>\n </Field>\n <div className=\"flex items-center gap-6\">\n <Field label=\"Volgorde\">\n <Input\n type=\"number\"\n className=\"w-24\"\n value={def.order ?? 0}\n onChange={(e) => setDef((p) => ({ ...p, order: Math.max(0, Number(e.target.value) || 0) }))}\n />\n </Field>\n <Switch checked={def.visible !== false} onCheckedChange={(v) => setDef((p) => ({ ...p, visible: v }))} label=\"Zichtbaar in de winkel\" />\n </div>\n <div className=\"flex items-center justify-between\">\n <Button onClick={save}>Opslaan</Button>\n <Button variant=\"ghost\" onClick={remove}>Verwijderen</Button>\n </div>\n </div>\n </Card>\n );\n}\n"],"mappings":"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","names":[]}
@@ -69,7 +69,7 @@ function DiscountDetail({
69
69
  confirm
70
70
  }) {
71
71
  const [def, setDef] = useState(
72
- existing ?? { code: "", type: "percent", value: 10, minOrderCents: 0, freeShipping: false, active: true, maxUses: 0, usedCount: 0 }
72
+ existing ?? { code: "", type: "percent", value: 10, minOrderCents: 0, freeShipping: false, active: true, maxUses: 0, usedCount: 0, oncePerCustomer: false }
73
73
  );
74
74
  const [fixedInput, setFixedInput] = useState(existing?.type === "fixed" ? centsToEuroInput(existing.value) : "");
75
75
  const [minInput, setMinInput] = useState(existing?.minOrderCents ? centsToEuroInput(existing.minOrderCents) : "");
@@ -150,6 +150,14 @@ function DiscountDetail({
150
150
  }
151
151
  ) }),
152
152
  /* @__PURE__ */ jsx(Switch, { checked: def.freeShipping === true, onCheckedChange: (v) => setDef((p) => ({ ...p, freeShipping: v })), label: "Gratis verzending" }),
153
+ /* @__PURE__ */ jsx(
154
+ Switch,
155
+ {
156
+ checked: def.oncePerCustomer === true,
157
+ onCheckedChange: (v) => setDef((p) => ({ ...p, oncePerCustomer: v })),
158
+ label: "E\xE9n keer per klant (op e-mailadres)"
159
+ }
160
+ ),
153
161
  /* @__PURE__ */ jsx(Switch, { checked: def.active !== false, onCheckedChange: (v) => setDef((p) => ({ ...p, active: v })), label: "Actief" }),
154
162
  existing && /* @__PURE__ */ jsxs("span", { className: "text-sm text-slate-500", children: [
155
163
  existing.usedCount ?? 0,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/admin/shop/DiscountsTab.tsx"],"sourcesContent":["\"use client\";\n\nimport { useEffect, useState } from \"react\";\nimport { collection, deleteDoc, doc, onSnapshot, setDoc } from \"firebase/firestore\";\nimport { getDb } from \"../../firebase/client\";\nimport { DiscountCodeSchema, formatCents, type DiscountCode } from \"../../schema/shop\";\nimport { Badge, Button, Card, Field, Input } from \"../ui/primitives\";\nimport { Select } from \"../ui/select\";\nimport { Switch } from \"../ui/switch\";\nimport { useToast } from \"../ui/toast\";\nimport { useConfirm } from \"../ui/ConfirmDialog\";\nimport { centsToEuroInput, eurosToCents } from \"./lib\";\n\ntype Stored = DiscountCode & { id: string };\n\n/** Kortingscodes — doc-id = code; de webhook telt het gebruik op. */\nexport function DiscountsTab({ siteId }: { siteId: string }) {\n const { toast } = useToast();\n const confirm = useConfirm();\n const [codes, setCodes] = useState<Stored[]>([]);\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, \"discountCodes\"), (snap) =>\n setCodes(snap.docs.map((d) => ({ id: d.id, ...(d.data() as DiscountCode) })).sort((a, b) => a.code.localeCompare(b.code))),\n );\n }, [siteId]);\n\n const current = codes.find((c) => c.id === selected) ?? null;\n\n return (\n <div className=\"grid gap-4 md:grid-cols-[280px_1fr]\">\n <Card className=\"h-fit\">\n <div className=\"mb-3 flex items-center justify-between\">\n <h3 className=\"text-sm font-semibold\">Kortingscodes</h3>\n <Button size=\"sm\" onClick={() => setSelected(\"__new__\")}>+ Nieuw</Button>\n </div>\n <ul className=\"grid gap-1\">\n {codes.map((c) => (\n <li key={c.id}>\n <button\n onClick={() => setSelected(c.id)}\n className={`flex w-full items-center justify-between gap-2 rounded-lg px-3 py-2 text-left text-sm ${\n c.id === selected ? \"bg-brand-50 text-brand-700\" : \"hover:bg-slate-100\"\n }`}\n >\n <span className=\"font-mono font-semibold\">{c.code}</span>\n <span className=\"flex items-center gap-1\">\n <span className=\"text-xs text-slate-500\">\n {c.type === \"percent\" ? `${c.value}%` : formatCents(c.value)}\n </span>\n <Badge tone={c.active !== false ? \"green\" : \"slate\"}>{c.active !== false ? \"actief\" : \"uit\"}</Badge>\n </span>\n </button>\n </li>\n ))}\n {codes.length === 0 && <li className=\"px-3 py-2 text-sm text-slate-500\">Nog geen kortingscodes.</li>}\n </ul>\n </Card>\n {current || selected === \"__new__\" ? (\n <DiscountDetail\n key={selected}\n siteId={siteId}\n existing={current}\n onDone={() => setSelected(null)}\n onToast={toast}\n confirm={confirm}\n />\n ) : (\n <Card><p className=\"text-sm text-slate-500\">Selecteer of maak een kortingscode.</p></Card>\n )}\n </div>\n );\n}\n\nfunction DiscountDetail({\n siteId,\n existing,\n onDone,\n onToast,\n confirm,\n}: {\n siteId: string;\n existing: Stored | null;\n onDone: () => void;\n onToast: (t: { title: string; description?: string; tone?: \"default\" | \"success\" | \"error\" }) => void;\n confirm: (opts: { title: string; description?: string; tone?: \"default\" | \"danger\" }) => Promise<boolean>;\n}) {\n const [def, setDef] = useState<DiscountCode>(\n existing ?? { code: \"\", type: \"percent\", value: 10, minOrderCents: 0, freeShipping: false, active: true, maxUses: 0, usedCount: 0 },\n );\n const [fixedInput, setFixedInput] = useState(existing?.type === \"fixed\" ? centsToEuroInput(existing.value) : \"\");\n const [minInput, setMinInput] = useState(existing?.minOrderCents ? centsToEuroInput(existing.minOrderCents) : \"\");\n\n async function save() {\n const db = getDb();\n if (!db) return;\n const value = def.type === \"fixed\" ? (eurosToCents(fixedInput) ?? 0) : def.value;\n const parsed = DiscountCodeSchema.safeParse({\n ...def,\n code: def.code.toUpperCase(),\n value,\n minOrderCents: minInput.trim() === \"\" ? 0 : (eurosToCents(minInput) ?? 0),\n });\n if (!parsed.success) {\n onToast({ title: \"Ongeldige code\", description: parsed.error.issues[0]?.message, tone: \"error\" });\n return;\n }\n await setDoc(doc(db, \"sites\", siteId, \"discountCodes\", parsed.data.code), parsed.data);\n onToast({ title: \"Kortingscode opgeslagen\", tone: \"success\" });\n if (!existing) onDone();\n }\n\n async function remove() {\n const db = getDb();\n if (!db || !existing) return;\n const ok = await confirm({ title: `Code ${existing.code} verwijderen?`, tone: \"danger\" });\n if (!ok) return;\n await deleteDoc(doc(db, \"sites\", siteId, \"discountCodes\", existing.id));\n onDone();\n }\n\n return (\n <Card>\n <div className=\"grid gap-4\">\n <div className=\"grid gap-4 md:grid-cols-3\">\n <Field label=\"Code\" hint=\"Hoofdletters, cijfers, streepjes\">\n <Input\n value={def.code}\n disabled={Boolean(existing)}\n onChange={(e) => setDef((p) => ({ ...p, code: e.target.value.toUpperCase().replace(/[^A-Z0-9-]/g, \"\") }))}\n placeholder=\"WELKOM10\"\n />\n </Field>\n <Field label=\"Soort\">\n <Select\n value={def.type}\n onValueChange={(v) => setDef((p) => ({ ...p, type: v as \"percent\" | \"fixed\" }))}\n options={[\n { value: \"percent\", label: \"Percentage\" },\n { value: \"fixed\", label: \"Vast bedrag\" },\n ]}\n />\n </Field>\n {def.type === \"percent\" ? (\n <Field label=\"Percentage\">\n <Input\n type=\"number\"\n min={1}\n max={100}\n value={def.value}\n onChange={(e) => setDef((p) => ({ ...p, value: Math.max(1, Math.min(100, Number(e.target.value) || 0)) }))}\n className=\"w-24\"\n />\n </Field>\n ) : (\n <Field label=\"Bedrag (€)\">\n <Input value={fixedInput} onChange={(e) => setFixedInput(e.target.value)} placeholder=\"5,00\" className=\"w-24\" />\n </Field>\n )}\n </div>\n\n <div className=\"grid gap-4 md:grid-cols-3\">\n <Field label=\"Minimaal orderbedrag (€)\" hint=\"Leeg = geen minimum\">\n <Input value={minInput} onChange={(e) => setMinInput(e.target.value)} placeholder=\"25,00\" className=\"w-28\" />\n </Field>\n <Field label=\"Geldig van\" hint=\"Leeg = direct\">\n <Input type=\"date\" value={def.startsAt ?? \"\"} onChange={(e) => setDef((p) => ({ ...p, startsAt: e.target.value || undefined }))} />\n </Field>\n <Field label=\"Geldig t/m\" hint=\"Leeg = onbeperkt\">\n <Input type=\"date\" value={def.endsAt ?? \"\"} onChange={(e) => setDef((p) => ({ ...p, endsAt: e.target.value || undefined }))} />\n </Field>\n </div>\n\n <div className=\"flex flex-wrap items-center gap-6\">\n <Field label=\"Max. aantal keer te gebruiken\" hint=\"0 = onbeperkt\">\n <Input\n type=\"number\"\n min={0}\n value={def.maxUses ?? 0}\n onChange={(e) => setDef((p) => ({ ...p, maxUses: Math.max(0, Number(e.target.value) || 0) }))}\n className=\"w-24\"\n />\n </Field>\n <Switch checked={def.freeShipping === true} onCheckedChange={(v) => setDef((p) => ({ ...p, freeShipping: v }))} label=\"Gratis verzending\" />\n <Switch checked={def.active !== false} onCheckedChange={(v) => setDef((p) => ({ ...p, active: v }))} label=\"Actief\" />\n {existing && (\n <span className=\"text-sm text-slate-500\">{existing.usedCount ?? 0}× gebruikt</span>\n )}\n </div>\n\n <div className=\"flex items-center justify-between border-t border-slate-100 pt-4\">\n <Button onClick={save} disabled={!def.code}>Opslaan</Button>\n {existing && <Button variant=\"danger\" onClick={remove}>Verwijderen</Button>}\n </div>\n </div>\n </Card>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../../src/admin/shop/DiscountsTab.tsx"],"sourcesContent":["\"use client\";\n\nimport { useEffect, useState } from \"react\";\nimport { collection, deleteDoc, doc, onSnapshot, setDoc } from \"firebase/firestore\";\nimport { getDb } from \"../../firebase/client\";\nimport { DiscountCodeSchema, formatCents, type DiscountCode } from \"../../schema/shop\";\nimport { Badge, Button, Card, Field, Input } from \"../ui/primitives\";\nimport { Select } from \"../ui/select\";\nimport { Switch } from \"../ui/switch\";\nimport { useToast } from \"../ui/toast\";\nimport { useConfirm } from \"../ui/ConfirmDialog\";\nimport { centsToEuroInput, eurosToCents } from \"./lib\";\n\ntype Stored = DiscountCode & { id: string };\n\n/** Kortingscodes — doc-id = code; de webhook telt het gebruik op. */\nexport function DiscountsTab({ siteId }: { siteId: string }) {\n const { toast } = useToast();\n const confirm = useConfirm();\n const [codes, setCodes] = useState<Stored[]>([]);\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, \"discountCodes\"), (snap) =>\n setCodes(snap.docs.map((d) => ({ id: d.id, ...(d.data() as DiscountCode) })).sort((a, b) => a.code.localeCompare(b.code))),\n );\n }, [siteId]);\n\n const current = codes.find((c) => c.id === selected) ?? null;\n\n return (\n <div className=\"grid gap-4 md:grid-cols-[280px_1fr]\">\n <Card className=\"h-fit\">\n <div className=\"mb-3 flex items-center justify-between\">\n <h3 className=\"text-sm font-semibold\">Kortingscodes</h3>\n <Button size=\"sm\" onClick={() => setSelected(\"__new__\")}>+ Nieuw</Button>\n </div>\n <ul className=\"grid gap-1\">\n {codes.map((c) => (\n <li key={c.id}>\n <button\n onClick={() => setSelected(c.id)}\n className={`flex w-full items-center justify-between gap-2 rounded-lg px-3 py-2 text-left text-sm ${\n c.id === selected ? \"bg-brand-50 text-brand-700\" : \"hover:bg-slate-100\"\n }`}\n >\n <span className=\"font-mono font-semibold\">{c.code}</span>\n <span className=\"flex items-center gap-1\">\n <span className=\"text-xs text-slate-500\">\n {c.type === \"percent\" ? `${c.value}%` : formatCents(c.value)}\n </span>\n <Badge tone={c.active !== false ? \"green\" : \"slate\"}>{c.active !== false ? \"actief\" : \"uit\"}</Badge>\n </span>\n </button>\n </li>\n ))}\n {codes.length === 0 && <li className=\"px-3 py-2 text-sm text-slate-500\">Nog geen kortingscodes.</li>}\n </ul>\n </Card>\n {current || selected === \"__new__\" ? (\n <DiscountDetail\n key={selected}\n siteId={siteId}\n existing={current}\n onDone={() => setSelected(null)}\n onToast={toast}\n confirm={confirm}\n />\n ) : (\n <Card><p className=\"text-sm text-slate-500\">Selecteer of maak een kortingscode.</p></Card>\n )}\n </div>\n );\n}\n\nfunction DiscountDetail({\n siteId,\n existing,\n onDone,\n onToast,\n confirm,\n}: {\n siteId: string;\n existing: Stored | null;\n onDone: () => void;\n onToast: (t: { title: string; description?: string; tone?: \"default\" | \"success\" | \"error\" }) => void;\n confirm: (opts: { title: string; description?: string; tone?: \"default\" | \"danger\" }) => Promise<boolean>;\n}) {\n const [def, setDef] = useState<DiscountCode>(\n existing ?? { code: \"\", type: \"percent\", value: 10, minOrderCents: 0, freeShipping: false, active: true, maxUses: 0, usedCount: 0, oncePerCustomer: false },\n );\n const [fixedInput, setFixedInput] = useState(existing?.type === \"fixed\" ? centsToEuroInput(existing.value) : \"\");\n const [minInput, setMinInput] = useState(existing?.minOrderCents ? centsToEuroInput(existing.minOrderCents) : \"\");\n\n async function save() {\n const db = getDb();\n if (!db) return;\n const value = def.type === \"fixed\" ? (eurosToCents(fixedInput) ?? 0) : def.value;\n const parsed = DiscountCodeSchema.safeParse({\n ...def,\n code: def.code.toUpperCase(),\n value,\n minOrderCents: minInput.trim() === \"\" ? 0 : (eurosToCents(minInput) ?? 0),\n });\n if (!parsed.success) {\n onToast({ title: \"Ongeldige code\", description: parsed.error.issues[0]?.message, tone: \"error\" });\n return;\n }\n await setDoc(doc(db, \"sites\", siteId, \"discountCodes\", parsed.data.code), parsed.data);\n onToast({ title: \"Kortingscode opgeslagen\", tone: \"success\" });\n if (!existing) onDone();\n }\n\n async function remove() {\n const db = getDb();\n if (!db || !existing) return;\n const ok = await confirm({ title: `Code ${existing.code} verwijderen?`, tone: \"danger\" });\n if (!ok) return;\n await deleteDoc(doc(db, \"sites\", siteId, \"discountCodes\", existing.id));\n onDone();\n }\n\n return (\n <Card>\n <div className=\"grid gap-4\">\n <div className=\"grid gap-4 md:grid-cols-3\">\n <Field label=\"Code\" hint=\"Hoofdletters, cijfers, streepjes\">\n <Input\n value={def.code}\n disabled={Boolean(existing)}\n onChange={(e) => setDef((p) => ({ ...p, code: e.target.value.toUpperCase().replace(/[^A-Z0-9-]/g, \"\") }))}\n placeholder=\"WELKOM10\"\n />\n </Field>\n <Field label=\"Soort\">\n <Select\n value={def.type}\n onValueChange={(v) => setDef((p) => ({ ...p, type: v as \"percent\" | \"fixed\" }))}\n options={[\n { value: \"percent\", label: \"Percentage\" },\n { value: \"fixed\", label: \"Vast bedrag\" },\n ]}\n />\n </Field>\n {def.type === \"percent\" ? (\n <Field label=\"Percentage\">\n <Input\n type=\"number\"\n min={1}\n max={100}\n value={def.value}\n onChange={(e) => setDef((p) => ({ ...p, value: Math.max(1, Math.min(100, Number(e.target.value) || 0)) }))}\n className=\"w-24\"\n />\n </Field>\n ) : (\n <Field label=\"Bedrag (€)\">\n <Input value={fixedInput} onChange={(e) => setFixedInput(e.target.value)} placeholder=\"5,00\" className=\"w-24\" />\n </Field>\n )}\n </div>\n\n <div className=\"grid gap-4 md:grid-cols-3\">\n <Field label=\"Minimaal orderbedrag (€)\" hint=\"Leeg = geen minimum\">\n <Input value={minInput} onChange={(e) => setMinInput(e.target.value)} placeholder=\"25,00\" className=\"w-28\" />\n </Field>\n <Field label=\"Geldig van\" hint=\"Leeg = direct\">\n <Input type=\"date\" value={def.startsAt ?? \"\"} onChange={(e) => setDef((p) => ({ ...p, startsAt: e.target.value || undefined }))} />\n </Field>\n <Field label=\"Geldig t/m\" hint=\"Leeg = onbeperkt\">\n <Input type=\"date\" value={def.endsAt ?? \"\"} onChange={(e) => setDef((p) => ({ ...p, endsAt: e.target.value || undefined }))} />\n </Field>\n </div>\n\n <div className=\"flex flex-wrap items-center gap-6\">\n <Field label=\"Max. aantal keer te gebruiken\" hint=\"0 = onbeperkt\">\n <Input\n type=\"number\"\n min={0}\n value={def.maxUses ?? 0}\n onChange={(e) => setDef((p) => ({ ...p, maxUses: Math.max(0, Number(e.target.value) || 0) }))}\n className=\"w-24\"\n />\n </Field>\n <Switch checked={def.freeShipping === true} onCheckedChange={(v) => setDef((p) => ({ ...p, freeShipping: v }))} label=\"Gratis verzending\" />\n <Switch\n checked={def.oncePerCustomer === true}\n onCheckedChange={(v) => setDef((p) => ({ ...p, oncePerCustomer: v }))}\n label=\"Eén keer per klant (op e-mailadres)\"\n />\n <Switch checked={def.active !== false} onCheckedChange={(v) => setDef((p) => ({ ...p, active: v }))} label=\"Actief\" />\n {existing && (\n <span className=\"text-sm text-slate-500\">{existing.usedCount ?? 0}× gebruikt</span>\n )}\n </div>\n\n <div className=\"flex items-center justify-between border-t border-slate-100 pt-4\">\n <Button onClick={save} disabled={!def.code}>Opslaan</Button>\n {existing && <Button variant=\"danger\" onClick={remove}>Verwijderen</Button>}\n </div>\n </div>\n </Card>\n );\n}\n"],"mappings":"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","names":[]}