@bygghammar/cms-platform 0.4.0 → 0.5.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bygghammar/cms-platform",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Shared CMS platform: blocks, block types, i18n core, UI primitives and design-token contract used by every Bygghammar/Voyara site.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -67,7 +67,12 @@
67
67
  "./i18n/json": "./src/i18n/json.ts",
68
68
  "./i18n/resolve-locale": "./src/i18n/resolve-locale.ts",
69
69
  "./i18n/translation-meta": "./src/i18n/translation-meta.ts",
70
- "./platform": "./src/platform/index.ts"
70
+ "./platform": "./src/platform/index.ts",
71
+ "./admin/format-log": "./src/admin/format-log.ts",
72
+ "./backoffice/forms/ConditionBuilder": "./src/backoffice/forms/ConditionBuilder.tsx",
73
+ "./backoffice/forms/RuleSimulator": "./src/backoffice/forms/RuleSimulator.tsx",
74
+ "./backoffice/forms/SmartTags": "./src/backoffice/forms/SmartTags.tsx",
75
+ "./backoffice/forms/ActivityLog": "./src/backoffice/forms/ActivityLog.tsx"
71
76
  },
72
77
  "dependencies": {
73
78
  "fflate": "^0.8.3"
@@ -0,0 +1,217 @@
1
+ // Turn machine action codes and JSON meta into human-readable strings for the
2
+ // backoffice Audit and Email log views.
3
+
4
+ const ACTION_LABELS: Record<string, string> = {
5
+ "user.create": "Created user",
6
+ "user.delete": "Deleted user",
7
+ "user.password_reset": "Reset password",
8
+ "user.invite": "Invited user",
9
+ "role.grant": "Granted role",
10
+ "role.revoke": "Revoked role",
11
+ "role.update": "Updated role",
12
+ "email.settings.update": "Updated email settings",
13
+ "email.settings.test": "Sent test email",
14
+ "content.section.save": "Saved section",
15
+ "content.section.delete": "Deleted section",
16
+ "page.create": "Created page",
17
+ "page.update": "Updated page",
18
+ "page.delete": "Deleted page",
19
+ "page.publish": "Published page",
20
+ "page.unpublish": "Unpublished page",
21
+ "page.version.create": "Saved page version",
22
+ "page.version.restore": "Restored page version",
23
+ "template.create": "Created block template",
24
+ "template.update": "Updated block template",
25
+ "template.delete": "Deleted block template",
26
+ "global.section.save": "Saved global block",
27
+ "global.section.delete": "Deleted global block",
28
+ "domain.create": "Added locale domain",
29
+ "domain.update": "Updated locale domain",
30
+ "domain.delete": "Removed locale domain",
31
+ "seo.update": "Updated SEO",
32
+ "media.upload": "Uploaded media",
33
+ "media.delete": "Deleted media",
34
+ "media.import": "Imported external media",
35
+ "translate.run": "Ran auto-translation",
36
+ "form.create": "Created form",
37
+ "form.update": "Updated form",
38
+ "form.duplicate": "Duplicated form",
39
+ "form.soft_delete": "Moved form to bin",
40
+ "form.restore": "Restored form",
41
+ "form.purge": "Permanently deleted form",
42
+ "form.defaults.update": "Updated shared form defaults",
43
+ "auth.login": "Signed in",
44
+ "auth.logout": "Signed out",
45
+ };
46
+
47
+ const ENTITY_LABELS: Record<string, string> = {
48
+ user: "User",
49
+ user_role: "Role",
50
+ content_section: "Section",
51
+ page: "Page",
52
+ page_version: "Page version",
53
+ page_publication: "Publication",
54
+ block_template: "Template",
55
+ global_section: "Global block",
56
+ locale_domain: "Domain",
57
+ page_seo: "SEO",
58
+ email_settings: "Email settings",
59
+ media: "Media",
60
+ redirect: "Redirect",
61
+ forms: "Form",
62
+ };
63
+
64
+ export function humanizeAction(action: string | null | undefined): string {
65
+ if (!action) return "—";
66
+ if (ACTION_LABELS[action]) return ACTION_LABELS[action];
67
+ // Fallback: turn "some.snake_case.action" into "Some snake case action".
68
+ return action
69
+ .replace(/[._-]+/g, " ")
70
+ .replace(/\s+/g, " ")
71
+ .trim()
72
+ .replace(/^./, (c) => c.toUpperCase());
73
+ }
74
+
75
+ export function humanizeEntity(entity: string | null | undefined): string {
76
+ if (!entity) return "—";
77
+ return (
78
+ ENTITY_LABELS[entity] ?? entity.replace(/[._-]+/g, " ").replace(/^./, (c) => c.toUpperCase())
79
+ );
80
+ }
81
+
82
+ function shortId(id: string): string {
83
+ if (id.length <= 12) return id;
84
+ // UUID-style: keep first + last block for context
85
+ if (/^[0-9a-f]{8}-/i.test(id)) return id.slice(0, 8) + "…";
86
+ return id.slice(0, 10) + "…";
87
+ }
88
+
89
+ export function formatEntityRef(
90
+ entity: string | null | undefined,
91
+ entityId: string | null | undefined,
92
+ ): string {
93
+ const label = humanizeEntity(entity);
94
+ if (!entityId) return label;
95
+ return `${label} · ${shortId(entityId)}`;
96
+ }
97
+
98
+ /**
99
+ * Convert an arbitrary `meta` payload into a short prose summary suitable for
100
+ * a log row. Falls back to compact key: value pairs, and finally to nothing.
101
+ */
102
+ export function summarizeMeta(meta: unknown): string {
103
+ if (!meta || typeof meta !== "object") return "";
104
+ const m = meta as Record<string, unknown>;
105
+ if (!Object.keys(m).length) return "";
106
+
107
+ const parts: string[] = [];
108
+ const push = (label: string, value: unknown) => {
109
+ if (value == null || value === "") return;
110
+ if (typeof value === "object") {
111
+ try {
112
+ parts.push(`${label}: ${JSON.stringify(value)}`);
113
+ } catch {
114
+ /* noop */
115
+ }
116
+ return;
117
+ }
118
+ parts.push(`${label}: ${String(value)}`);
119
+ };
120
+
121
+ // Common well-known fields, in a nice order
122
+ const known = [
123
+ ["summary", "Summary"],
124
+ ["email", "Email"],
125
+ ["role", "Role"],
126
+ ["name", "Name"],
127
+ ["key", "Key"],
128
+ ["slug", "Slug"],
129
+ ["locale", "Locale"],
130
+ ["from", "From"],
131
+ ["to", "To"],
132
+ ["reason", "Reason"],
133
+ ["status", "Status"],
134
+ ["version", "Version"],
135
+ ["count", "Count"],
136
+ ["path", "Path"],
137
+ ["domain", "Domain"],
138
+ ["title", "Title"],
139
+ ] as const;
140
+ const used = new Set<string>();
141
+ for (const [k, label] of known) {
142
+ if (k in m) {
143
+ push(label, m[k]);
144
+ used.add(k);
145
+ }
146
+ }
147
+ // Any remaining keys, up to 4 total extra fields
148
+ const extras = Object.keys(m)
149
+ .filter((k) => !used.has(k))
150
+ .slice(0, 4);
151
+ for (const k of extras) {
152
+ push(
153
+ k.replace(/[._-]+/g, " ").replace(/^./, (c) => c.toUpperCase()),
154
+ m[k],
155
+ );
156
+ }
157
+ return parts.join(" · ");
158
+ }
159
+
160
+ export function formatRelativeTime(iso: string): string {
161
+ const then = new Date(iso).getTime();
162
+ if (Number.isNaN(then)) return "—";
163
+ const diff = Date.now() - then;
164
+ const s = Math.round(diff / 1000);
165
+ if (s < 45) return "just now";
166
+ const m = Math.round(s / 60);
167
+ if (m < 60) return `${m} min ago`;
168
+ const h = Math.round(m / 60);
169
+ if (h < 24) return `${h} h ago`;
170
+ const d = Math.round(h / 24);
171
+ if (d < 30) return `${d} d ago`;
172
+ const mo = Math.round(d / 30);
173
+ if (mo < 12) return `${mo} mo ago`;
174
+ const y = Math.round(mo / 12);
175
+ return `${y} y ago`;
176
+ }
177
+
178
+ export function formatAbsoluteTime(iso: string): string {
179
+ const d = new Date(iso);
180
+ if (Number.isNaN(d.getTime())) return "—";
181
+ return d.toLocaleString(undefined, {
182
+ year: "numeric",
183
+ month: "short",
184
+ day: "2-digit",
185
+ hour: "2-digit",
186
+ minute: "2-digit",
187
+ });
188
+ }
189
+
190
+ export function emailStatusTone(status: string | null | undefined): {
191
+ label: string;
192
+ className: string;
193
+ } {
194
+ const s = (status ?? "").toLowerCase();
195
+ const map: Record<string, { label: string; className: string }> = {
196
+ sent: { label: "Sent", className: "border-green-600 text-green-700 bg-green-50" },
197
+ delivered: { label: "Delivered", className: "border-green-600 text-green-700 bg-green-50" },
198
+ queued: { label: "Queued", className: "border-ink/30 text-ink/70 bg-ink/5" },
199
+ pending: { label: "Pending", className: "border-ink/30 text-ink/70 bg-ink/5" },
200
+ failed: { label: "Failed", className: "border-red-600 text-red-700 bg-red-50" },
201
+ rejected: { label: "Rejected", className: "border-red-600 text-red-700 bg-red-50" },
202
+ bounced: { label: "Bounced", className: "border-amber-600 text-amber-700 bg-amber-50" },
203
+ complained: { label: "Complained", className: "border-amber-600 text-amber-700 bg-amber-50" },
204
+ suppressed: { label: "Suppressed", className: "border-amber-600 text-amber-700 bg-amber-50" },
205
+ unsubscribed: { label: "Unsubscribed", className: "border-ink/30 text-ink/70 bg-ink/5" },
206
+ rate_limited: {
207
+ label: "Rate limited",
208
+ className: "border-amber-600 text-amber-700 bg-amber-50",
209
+ },
210
+ };
211
+ return (
212
+ map[s] ?? {
213
+ label: status ?? "Unknown",
214
+ className: "border-ink/30 text-ink/60 bg-white",
215
+ }
216
+ );
217
+ }
@@ -0,0 +1,153 @@
1
+ import { useMemo, useState } from "react";
2
+ import { useQuery } from "@tanstack/react-query";
3
+ /** Shape of one audit row the host site returns for form activity. */
4
+ export type FormActivityRow = {
5
+ id: string;
6
+ action: string;
7
+ actor_email: string | null;
8
+ actor_id: string | null;
9
+ entity_id: string | null;
10
+ meta: Record<string, unknown> | null;
11
+ created_at: string;
12
+ };
13
+ import type { FormAuditChange } from "../../forms/form-audit";
14
+ import { formatAbsoluteTime, formatRelativeTime } from "../../admin/format-log";
15
+
16
+ const ACTION_LABELS: Record<string, string> = {
17
+ "form.create": "Created form",
18
+ "form.update": "Updated form",
19
+ "form.duplicate": "Duplicated form",
20
+ "form.soft_delete": "Moved form to bin",
21
+ "form.restore": "Restored form",
22
+ "form.purge": "Permanently deleted form",
23
+ "form.defaults.update": "Updated shared form defaults",
24
+ };
25
+
26
+ function label(action: string) {
27
+ return ACTION_LABELS[action] ?? action.replace(/[._]/g, " ");
28
+ }
29
+
30
+ /** Who changed what and when, for one form or for the whole Forms module. */
31
+ export function ActivityLog({
32
+ formKey,
33
+ fetchActivity,
34
+ }: {
35
+ formKey?: string;
36
+ /** Host-provided reader for form audit rows. */
37
+ fetchActivity: (input: {
38
+ data: { formKey?: string; limit: number };
39
+ }) => Promise<FormActivityRow[]>;
40
+ }) {
41
+ const q = useQuery({
42
+ queryKey: ["form-activity", formKey ?? "all"],
43
+ queryFn: () => fetchActivity({ data: { ...(formKey ? { formKey } : {}), limit: 200 } }),
44
+ });
45
+ const [search, setSearch] = useState("");
46
+ const [open, setOpen] = useState<string | null>(null);
47
+
48
+ const rows = (q.data ?? []) as FormActivityRow[];
49
+ const filtered = useMemo(() => {
50
+ const s = search.trim().toLowerCase();
51
+ if (!s) return rows;
52
+ return rows.filter((r) =>
53
+ [r.actor_email, label(r.action), JSON.stringify(r.meta ?? {})]
54
+ .filter(Boolean)
55
+ .join(" ")
56
+ .toLowerCase()
57
+ .includes(s),
58
+ );
59
+ }, [rows, search]);
60
+
61
+ return (
62
+ <div className="border border-ink/10 bg-white">
63
+ <div className="flex flex-wrap items-center justify-between gap-3 border-b border-ink/10 p-4">
64
+ <div>
65
+ <h3 className="font-serif text-base">Activity log</h3>
66
+ <p className="text-xs text-ink/60">
67
+ Every change to this form and its fields — who, what and when.
68
+ </p>
69
+ </div>
70
+ <input
71
+ value={search}
72
+ onChange={(e) => setSearch(e.target.value)}
73
+ placeholder="Search user, field, change…"
74
+ className="w-64 border border-ink/15 px-3 py-2 text-sm focus:border-sea focus:outline-none"
75
+ />
76
+ </div>
77
+
78
+ {q.isLoading ? (
79
+ <p className="p-6 text-sm text-ink/50">Loading activity…</p>
80
+ ) : filtered.length === 0 ? (
81
+ <p className="p-6 text-sm text-ink/50">
82
+ {rows.length === 0 ? "No changes recorded yet." : "No entries match your search."}
83
+ </p>
84
+ ) : (
85
+ <ul className="divide-y divide-ink/10">
86
+ {filtered.map((r) => {
87
+ const meta = (r.meta ?? {}) as {
88
+ summary?: string;
89
+ name?: string;
90
+ key?: string;
91
+ changes?: FormAuditChange[];
92
+ };
93
+ const changes = Array.isArray(meta.changes) ? meta.changes : [];
94
+ const isOpen = open === r.id;
95
+ return (
96
+ <li key={r.id}>
97
+ <button
98
+ type="button"
99
+ onClick={() => setOpen(isOpen ? null : r.id)}
100
+ className="flex w-full items-start gap-4 p-4 text-left hover:bg-ink/[0.02]"
101
+ >
102
+ <div className="w-40 shrink-0">
103
+ <div className="text-sm text-ink">{formatRelativeTime(r.created_at)}</div>
104
+ <div className="text-[0.65rem] text-ink/50">
105
+ {formatAbsoluteTime(r.created_at)}
106
+ </div>
107
+ </div>
108
+ <div className="w-56 shrink-0 text-sm text-ink/80 break-all">
109
+ {r.actor_email ?? "System"}
110
+ </div>
111
+ <div className="min-w-0 flex-1">
112
+ <div className="text-sm text-ink">{label(r.action)}</div>
113
+ <div className="mt-1 text-xs text-ink/60 line-clamp-2">
114
+ {meta.summary ??
115
+ (changes.length ? `${changes.length} change(s)` : (meta.name ?? ""))}
116
+ </div>
117
+ </div>
118
+ <span className="pt-1 text-xs text-ink/40">{isOpen ? "▾" : "▸"}</span>
119
+ </button>
120
+ {isOpen ? (
121
+ <div className="border-t border-ink/10 bg-ink/[0.02] p-4">
122
+ {changes.length === 0 ? (
123
+ <p className="text-xs text-ink/60">No field-level detail recorded.</p>
124
+ ) : (
125
+ <table className="w-full text-xs">
126
+ <thead className="text-[0.6rem] uppercase tracking-[0.2em] text-ink/50">
127
+ <tr>
128
+ <th className="p-2 text-left">What</th>
129
+ <th className="p-2 text-left">Before</th>
130
+ <th className="p-2 text-left">After</th>
131
+ </tr>
132
+ </thead>
133
+ <tbody>
134
+ {changes.map((c, i) => (
135
+ <tr key={i} className="border-t border-ink/10 align-top">
136
+ <td className="p-2 text-ink/80">{c.label}</td>
137
+ <td className="p-2 text-ink/60 break-all">{c.from ?? "—"}</td>
138
+ <td className="p-2 text-ink break-all">{c.to ?? "—"}</td>
139
+ </tr>
140
+ ))}
141
+ </tbody>
142
+ </table>
143
+ )}
144
+ </div>
145
+ ) : null}
146
+ </li>
147
+ );
148
+ })}
149
+ </ul>
150
+ )}
151
+ </div>
152
+ );
153
+ }
@@ -0,0 +1,170 @@
1
+ import { useState } from "react";
2
+ import { Plus, Trash2 } from "lucide-react";
3
+ import { CONDITION_OPERATORS, isUnaryOperator } from "../../forms/conditions";
4
+ import type { ConditionRule, FieldCondition, FormFieldDef } from "../../forms/field-sets";
5
+
6
+ /**
7
+ * Editor for one show/require rule: "all|any of these conditions match".
8
+ * Conditions can only reference other fields, never the field itself.
9
+ */
10
+ export function ConditionBuilder({
11
+ title,
12
+ description,
13
+ rule,
14
+ fields,
15
+ selfKey,
16
+ onChange,
17
+ }: {
18
+ title: string;
19
+ description: string;
20
+ rule: ConditionRule | undefined;
21
+ fields: FormFieldDef[];
22
+ selfKey?: string;
23
+ onChange: (rule: ConditionRule | undefined) => void;
24
+ }) {
25
+ const conditions = rule?.conditions ?? [];
26
+ const mode = rule?.mode ?? "all";
27
+ const candidates = fields.filter((f) => f.key !== selfKey);
28
+
29
+ const set = (next: FieldCondition[], nextMode: "all" | "any" = mode) =>
30
+ onChange(next.length ? { mode: nextMode, conditions: next } : undefined);
31
+
32
+ const patch = (i: number, p: Partial<FieldCondition>) =>
33
+ set(conditions.map((c, idx) => (idx === i ? { ...c, ...p } : c)));
34
+
35
+ const optionsFor = (key: string) => fields.find((f) => f.key === key)?.options ?? [];
36
+
37
+ return (
38
+ <div className="space-y-2 border border-ink/10 bg-white p-3">
39
+ <div className="flex items-center justify-between gap-3">
40
+ <div>
41
+ <div className="text-[0.65rem] uppercase tracking-[0.2em] text-ink/55">{title}</div>
42
+ <p className="mt-0.5 text-xs text-ink/45">{description}</p>
43
+ </div>
44
+ {conditions.length > 1 ? (
45
+ <select
46
+ value={mode}
47
+ onChange={(e) => set(conditions, e.target.value as "all" | "any")}
48
+ className="border border-ink/15 px-2 py-1 text-xs"
49
+ >
50
+ <option value="all">Match all</option>
51
+ <option value="any">Match any</option>
52
+ </select>
53
+ ) : null}
54
+ </div>
55
+
56
+ {conditions.length === 0 ? (
57
+ <p className="text-xs text-ink/40">No conditions — always applies.</p>
58
+ ) : null}
59
+
60
+ {conditions.map((c, i) => {
61
+ const opts = optionsFor(c.field);
62
+ return (
63
+ <div key={i} className="flex flex-wrap items-center gap-2">
64
+ <select
65
+ value={c.field}
66
+ onChange={(e) => patch(i, { field: e.target.value })}
67
+ className="min-w-[130px] flex-1 border border-ink/15 px-2 py-1 text-xs"
68
+ >
69
+ <option value="">Choose a field…</option>
70
+ {candidates.map((f) => (
71
+ <option key={f.key} value={f.key}>
72
+ {f.label.sv || f.label.en || f.key}
73
+ </option>
74
+ ))}
75
+ </select>
76
+ <select
77
+ value={c.op}
78
+ onChange={(e) => patch(i, { op: e.target.value as FieldCondition["op"] })}
79
+ className="min-w-[120px] border border-ink/15 px-2 py-1 text-xs"
80
+ >
81
+ {CONDITION_OPERATORS.map((o) => (
82
+ <option key={o.value} value={o.value}>
83
+ {o.label}
84
+ </option>
85
+ ))}
86
+ </select>
87
+ {isUnaryOperator(c.op) ? null : opts.length ? (
88
+ <select
89
+ value={c.value ?? ""}
90
+ onChange={(e) => patch(i, { value: e.target.value })}
91
+ className="min-w-[120px] flex-1 border border-ink/15 px-2 py-1 text-xs"
92
+ >
93
+ <option value="">—</option>
94
+ {opts.map((o) => (
95
+ <option key={o.value} value={o.value}>
96
+ {o.label?.sv || o.label?.en || o.value}
97
+ </option>
98
+ ))}
99
+ </select>
100
+ ) : (
101
+ <input
102
+ value={c.value ?? ""}
103
+ onChange={(e) => patch(i, { value: e.target.value })}
104
+ placeholder="value"
105
+ className="min-w-[120px] flex-1 border border-ink/15 px-2 py-1 text-xs"
106
+ />
107
+ )}
108
+ <button
109
+ type="button"
110
+ onClick={() => set(conditions.filter((_, idx) => idx !== i))}
111
+ className="text-ink/35 hover:text-red-500"
112
+ title="Remove condition"
113
+ >
114
+ <Trash2 className="size-3.5" />
115
+ </button>
116
+ </div>
117
+ );
118
+ })}
119
+
120
+ <button
121
+ type="button"
122
+ disabled={candidates.length === 0}
123
+ onClick={() =>
124
+ set([...conditions, { field: candidates[0]?.key ?? "", op: "equals", value: "" }])
125
+ }
126
+ className="inline-flex items-center gap-1.5 border border-ink/15 px-3 py-1.5 text-[0.65rem] uppercase tracking-[0.2em] hover:border-sea disabled:opacity-40"
127
+ >
128
+ <Plus className="size-3.5" />
129
+ Add condition
130
+ </button>
131
+ </div>
132
+ );
133
+ }
134
+
135
+ /** Compact summary chip used in collapsed field rows. */
136
+ export function RuleSummary({
137
+ rule,
138
+ fields,
139
+ prefix,
140
+ }: {
141
+ rule: ConditionRule | undefined;
142
+ fields: FormFieldDef[];
143
+ prefix: string;
144
+ }) {
145
+ const [open, setOpen] = useState(false);
146
+ const list = rule?.conditions ?? [];
147
+ if (list.length === 0) return null;
148
+ const nameOf = (key: string) => {
149
+ const f = fields.find((x) => x.key === key);
150
+ return f?.label.sv || f?.label.en || key;
151
+ };
152
+ const opLabel = (op: FieldCondition["op"]) =>
153
+ CONDITION_OPERATORS.find((o) => o.value === op)?.label ?? op;
154
+ const text = list
155
+ .map(
156
+ (c) =>
157
+ `${nameOf(c.field)} ${opLabel(c.op)}${isUnaryOperator(c.op) ? "" : ` “${c.value ?? ""}”`}`,
158
+ )
159
+ .join(rule?.mode === "any" ? " or " : " and ");
160
+ return (
161
+ <button
162
+ type="button"
163
+ onClick={() => setOpen((v) => !v)}
164
+ title={text}
165
+ className="max-w-full truncate bg-sea/15 px-2 py-0.5 text-[0.6rem] uppercase tracking-[0.14em] text-ink/70"
166
+ >
167
+ {prefix}: {open ? text : `${list.length} condition${list.length === 1 ? "" : "s"}`}
168
+ </button>
169
+ );
170
+ }
@@ -0,0 +1,163 @@
1
+ import { useMemo, useState } from "react";
2
+ import { Eye, EyeOff, RotateCcw } from "lucide-react";
3
+ import { tripFieldOptions, type TripSource, type FormFieldDef } from "../../forms/field-sets";
4
+ import {
5
+ fieldLabel,
6
+ hasRule,
7
+ isFieldRequired,
8
+ isFieldVisible,
9
+ normalizeCheckbox,
10
+ validateField,
11
+ } from "../../forms/conditions";
12
+
13
+ /**
14
+ * Rule simulation: type example answers and immediately see which fields
15
+ * show, which become required and which validation messages a visitor
16
+ * would get — all before publishing.
17
+ */
18
+ export function RuleSimulator({
19
+ fields,
20
+ locale = "sv",
21
+ trips,
22
+ }: {
23
+ fields: FormFieldDef[];
24
+ locale?: string;
25
+ /** Trip/course rows used to expand trip fields into selectable options. */
26
+ trips?: TripSource[];
27
+ }) {
28
+ const [values, setValues] = useState<Record<string, string>>({});
29
+ const tripItems = trips;
30
+ const resolved = useMemo(() => {
31
+ const trips = (tripItems ?? []) as TripSource[];
32
+ return fields.map((f) =>
33
+ f.type === "trip" || f.type === "course"
34
+ ? {
35
+ ...f,
36
+ type: "select" as const,
37
+ options: tripFieldOptions(trips, f.tripYear ?? f.courseYear),
38
+ }
39
+ : f,
40
+ );
41
+ }, [fields, tripItems]);
42
+ const enabled = useMemo(() => resolved.filter((f) => f.enabled), [resolved]);
43
+
44
+ const rows = enabled.map((f) => {
45
+ const visible = isFieldVisible(f, values);
46
+ return {
47
+ field: f,
48
+ visible,
49
+ required: isFieldRequired(f, values),
50
+ error: visible ? validateField(f, values, locale) : null,
51
+ conditional: hasRule(f.showWhen) || hasRule(f.requireWhen),
52
+ };
53
+ });
54
+
55
+ const errors = rows.filter((r) => r.error).length;
56
+ const hidden = rows.filter((r) => !r.visible).length;
57
+
58
+ const set = (key: string, v: string) => setValues((p) => ({ ...p, [key]: v }));
59
+
60
+ return (
61
+ <div className="space-y-4">
62
+ <div className="flex flex-wrap items-center justify-between gap-3">
63
+ <div>
64
+ <h3 className="text-[0.7rem] uppercase tracking-[0.22em] text-ink/55">Rule simulation</h3>
65
+ <p className="mt-1 text-xs text-ink/45">
66
+ Example answers only — nothing is saved or sent.
67
+ </p>
68
+ </div>
69
+ <button
70
+ type="button"
71
+ onClick={() => setValues({})}
72
+ className="inline-flex items-center gap-2 border border-ink/15 px-3 py-1.5 text-[0.65rem] uppercase tracking-[0.2em] hover:border-sea"
73
+ >
74
+ <RotateCcw className="size-3.5" />
75
+ Reset
76
+ </button>
77
+ </div>
78
+
79
+ <div className="flex flex-wrap gap-3 text-[0.65rem] uppercase tracking-[0.18em] text-ink/55">
80
+ <span>{rows.length - hidden} visible</span>
81
+ <span>{hidden} hidden</span>
82
+ <span>{rows.filter((r) => r.required).length} required</span>
83
+ <span className={errors ? "text-red-500" : ""}>{errors} error(s)</span>
84
+ </div>
85
+
86
+ {enabled.length === 0 ? (
87
+ <p className="text-xs text-ink/45">No active fields to simulate.</p>
88
+ ) : null}
89
+
90
+ <div className="space-y-2">
91
+ {rows.map(({ field: f, visible, required, error, conditional }) => (
92
+ <div
93
+ key={f.key}
94
+ className={`grid gap-2 border p-3 sm:grid-cols-[1fr_1fr] ${
95
+ visible ? "border-ink/10" : "border-dashed border-ink/15 bg-ink/[0.02]"
96
+ }`}
97
+ >
98
+ <div className="min-w-0">
99
+ <div className="flex items-center gap-2 text-xs text-ink">
100
+ {visible ? (
101
+ <Eye className="size-3.5 text-sea" />
102
+ ) : (
103
+ <EyeOff className="size-3.5 text-ink/35" />
104
+ )}
105
+ <span className="truncate">{fieldLabel(f, locale)}</span>
106
+ {required ? (
107
+ <span className="text-[0.6rem] uppercase tracking-[0.16em] text-sea">
108
+ required
109
+ </span>
110
+ ) : null}
111
+ {conditional ? (
112
+ <span className="text-[0.6rem] uppercase tracking-[0.16em] text-ink/35">
113
+ conditional
114
+ </span>
115
+ ) : null}
116
+ </div>
117
+ <div className="mt-0.5 text-[0.6rem] uppercase tracking-[0.16em] text-ink/35">
118
+ {f.key} · {f.type}
119
+ </div>
120
+ {error ? <p className="mt-1 text-xs text-red-500">{error}</p> : null}
121
+ </div>
122
+
123
+ <div>
124
+ {f.type === "checkbox" ? (
125
+ <label className="flex items-center gap-2 text-xs text-ink/70">
126
+ <input
127
+ type="checkbox"
128
+ checked={normalizeCheckbox(values[f.key] ?? "") === "true"}
129
+ onChange={(e) => set(f.key, e.target.checked ? "true" : "false")}
130
+ className="size-4 accent-sea"
131
+ />
132
+ Checked
133
+ </label>
134
+ ) : f.type === "select" ? (
135
+ <select
136
+ value={values[f.key] ?? ""}
137
+ onChange={(e) => set(f.key, e.target.value)}
138
+ className="w-full border border-ink/15 px-2 py-1 text-xs"
139
+ >
140
+ <option value="">—</option>
141
+ {(f.options ?? []).map((o) => (
142
+ <option key={o.value} value={o.value}>
143
+ {o.label?.sv || o.label?.en || o.value}
144
+ </option>
145
+ ))}
146
+ </select>
147
+ ) : (
148
+ <input
149
+ type={f.type === "textarea" ? "text" : f.type}
150
+ value={values[f.key] ?? ""}
151
+ onChange={(e) => set(f.key, e.target.value)}
152
+ placeholder="Example answer"
153
+ className="w-full border border-ink/15 px-2 py-1 text-xs"
154
+ disabled={!visible}
155
+ />
156
+ )}
157
+ </div>
158
+ </div>
159
+ ))}
160
+ </div>
161
+ </div>
162
+ );
163
+ }
@@ -0,0 +1,181 @@
1
+ import { useMemo, useRef, useState } from "react";
2
+ import { Tag } from "lucide-react";
3
+ import { LocalizedField, TextField } from "../inspectors/fields";
4
+ import { fieldLabel } from "../../forms/conditions";
5
+ import type { FormFieldDef } from "../../forms/field-sets";
6
+ import type { LocalizedString } from "../../i18n";
7
+ import { SUPPORTED_LOCALES, LOCALE_LABELS, type Locale } from "../../i18n";
8
+
9
+ export type SmartTag = { tag: string; label: string };
10
+
11
+ /** System tags available in every notification and autoreply template. */
12
+ const SYSTEM_TAGS: SmartTag[] = [
13
+ { tag: "all_fields", label: "All submitted answers (table)" },
14
+ { tag: "form_name", label: "Form name" },
15
+ { tag: "form_key", label: "Form key" },
16
+ { tag: "submission_id", label: "Submission ID" },
17
+ { tag: "date", label: "Date and time" },
18
+ { tag: "locale", label: "Language" },
19
+ { tag: "page", label: "Page path" },
20
+ { tag: "page_url", label: "Page URL" },
21
+ { tag: "admin_url", label: "Link to the submission in the backoffice" },
22
+ { tag: "ip", label: "Visitor IP address" },
23
+ { tag: "user_agent", label: "Browser / user agent" },
24
+ { tag: "referrer", label: "Referring URL" },
25
+ ];
26
+
27
+ /** Field answer tags for the given form, followed by the system tags. */
28
+ export function smartTagGroups(fields: FormFieldDef[]): { title: string; tags: SmartTag[] }[] {
29
+ const answers = fields
30
+ .filter((f) => f.enabled)
31
+ .map((f) => ({ tag: f.key, label: fieldLabel(f, "sv") || f.key }));
32
+ return [
33
+ ...(answers.length ? [{ title: "Field answers", tags: answers }] : []),
34
+ { title: "Submission info", tags: SYSTEM_TAGS },
35
+ ];
36
+ }
37
+
38
+ /** Dropdown that inserts a {{smart_tag}} into the field it belongs to. */
39
+ export function SmartTags({
40
+ fields,
41
+ onInsert,
42
+ right,
43
+ }: {
44
+ fields: FormFieldDef[];
45
+ onInsert: (token: string) => void;
46
+ right?: React.ReactNode;
47
+ }) {
48
+ const [open, setOpen] = useState(false);
49
+ const groups = useMemo(() => smartTagGroups(fields), [fields]);
50
+ const wrap = useRef<HTMLDivElement>(null);
51
+
52
+ return (
53
+ <div ref={wrap} className="relative flex items-center gap-2">
54
+ {right}
55
+ <button
56
+ type="button"
57
+ onClick={() => setOpen((o) => !o)}
58
+ className="inline-flex items-center gap-1.5 border border-ink/15 px-2 py-1 text-[0.6rem] uppercase tracking-[0.18em] text-ink/60 hover:border-sea hover:text-ink"
59
+ >
60
+ <Tag className="size-3" />
61
+ Smart tags
62
+ </button>
63
+ {open ? (
64
+ <>
65
+ <button
66
+ type="button"
67
+ aria-label="Close smart tags"
68
+ className="fixed inset-0 z-40 cursor-default"
69
+ onClick={() => setOpen(false)}
70
+ />
71
+ <div className="absolute right-0 top-full z-50 mt-1 max-h-72 w-72 overflow-auto border border-ink/15 bg-white p-2 shadow-lg">
72
+ {groups.map((g) => (
73
+ <div key={g.title} className="mb-2 last:mb-0">
74
+ <div className="px-1 py-1 text-[0.6rem] uppercase tracking-[0.18em] text-ink/40">
75
+ {g.title}
76
+ </div>
77
+ {g.tags.map((t) => (
78
+ <button
79
+ key={t.tag}
80
+ type="button"
81
+ onClick={() => {
82
+ onInsert(`{{${t.tag}}}`);
83
+ setOpen(false);
84
+ }}
85
+ className="flex w-full items-baseline justify-between gap-2 px-1 py-1 text-left text-xs hover:bg-ice/60"
86
+ >
87
+ <span className="text-ink/80">{t.label}</span>
88
+ <span className="shrink-0 font-mono text-[0.65rem] text-ink/45">
89
+ {`{{${t.tag}}}`}
90
+ </span>
91
+ </button>
92
+ ))}
93
+ </div>
94
+ ))}
95
+ </div>
96
+ </>
97
+ ) : null}
98
+ </div>
99
+ );
100
+ }
101
+
102
+ /** Plain text field (sender name, reply-to…) with a smart tag picker. */
103
+ export function SmartTagTextField({
104
+ label,
105
+ value,
106
+ onChange,
107
+ fields,
108
+ placeholder,
109
+ }: {
110
+ label: string;
111
+ value: string | undefined;
112
+ onChange: (v: string) => void;
113
+ fields: FormFieldDef[];
114
+ placeholder?: string;
115
+ }) {
116
+ return (
117
+ <div className="space-y-1">
118
+ <div className="flex items-end justify-between gap-2">
119
+ <div className="flex-1">
120
+ <TextField label={label} value={value} onChange={onChange} placeholder={placeholder} />
121
+ </div>
122
+ </div>
123
+ <div className="flex justify-end">
124
+ <SmartTags
125
+ fields={fields}
126
+ onInsert={(token) => onChange(`${(value ?? "").trim()} ${token}`.trim())}
127
+ />
128
+ </div>
129
+ </div>
130
+ );
131
+ }
132
+
133
+ /** Localized subject/body field with a smart tag picker per language. */
134
+ export function SmartTagLocalizedField({
135
+ label,
136
+ value,
137
+ onChange,
138
+ fields,
139
+ multiline,
140
+ }: {
141
+ label: string;
142
+ value: Partial<LocalizedString> | undefined;
143
+ onChange: (next: Partial<LocalizedString>) => void;
144
+ fields: FormFieldDef[];
145
+ multiline?: boolean;
146
+ }) {
147
+ const [target, setTarget] = useState<Locale>(SUPPORTED_LOCALES[0] as Locale);
148
+ const v = (value ?? {}) as Record<string, string>;
149
+
150
+ return (
151
+ <div className="space-y-1">
152
+ <LocalizedField label={label} value={value} onChange={onChange} multiline={multiline} />
153
+ <div className="flex justify-end">
154
+ <SmartTags
155
+ fields={fields}
156
+ right={
157
+ SUPPORTED_LOCALES.length > 1 ? (
158
+ <select
159
+ value={target}
160
+ onChange={(e) => setTarget(e.target.value as Locale)}
161
+ className="border border-ink/15 bg-white px-1 py-1 text-[0.6rem] uppercase tracking-[0.18em] text-ink/60"
162
+ >
163
+ {SUPPORTED_LOCALES.map((loc) => (
164
+ <option key={loc} value={loc}>
165
+ {LOCALE_LABELS[loc] ?? loc}
166
+ </option>
167
+ ))}
168
+ </select>
169
+ ) : null
170
+ }
171
+ onInsert={(token) =>
172
+ onChange({
173
+ ...v,
174
+ [target]: `${v[target] ?? ""}${(v[target] ?? "").endsWith(" ") || !v[target] ? "" : " "}${token}`,
175
+ } as Partial<LocalizedString>)
176
+ }
177
+ />
178
+ </div>
179
+ </div>
180
+ );
181
+ }