@sjolystinnovation/app-kit 0.19.0 → 0.21.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.
@@ -0,0 +1,276 @@
1
+ "use client";
2
+
3
+ import { PlusIcon, TrashIcon, XMarkIcon } from "@heroicons/react/24/outline";
4
+ import { useEffect, useRef, useState } from "react";
5
+ import type { AdminUser, createAdminUsersService } from "../api/services";
6
+ import { toast } from "../toast";
7
+ import { TextInput } from "../ui/TextInput";
8
+ import { Toggle } from "../ui/Toggle";
9
+ import { withDefaults, type TextOverrides } from "../ui/withDefaults";
10
+ import { defaultAdminUsersPanelStrings, type AdminUsersPanelStrings } from "./adminUsersPanelStrings";
11
+
12
+ export interface AdminUsersPanelProps {
13
+ /** The user admin calls, such as `createAdminUsersService(api)`. */
14
+ service: ReturnType<typeof createAdminUsersService>;
15
+ /** The roles an admin may give, from the app's own endpoint. */
16
+ getRoles: () => Promise<string[]>;
17
+ /** The role a new invite starts with. `Admin` by default. */
18
+ defaultInviteRole?: string | undefined;
19
+ strings?: TextOverrides<AdminUsersPanelStrings> | undefined;
20
+ }
21
+
22
+ const fill = (text: string, values: Record<string, string>) =>
23
+ text.replace(/\{(\w+)\}/g, (match, key: string) => values[key] ?? match);
24
+
25
+ const reason = (err: unknown, fallback: string) => (err instanceof Error && err.message ? err.message : fallback);
26
+
27
+ /**
28
+ * The admin users page: every user with their roles, a toggle for deleted users, giving and taking
29
+ * roles, deleting an account after a confirm, and inviting a user by email.
30
+ */
31
+ export function AdminUsersPanel({ service, getRoles, defaultInviteRole = "Admin", strings }: AdminUsersPanelProps) {
32
+ const text = withDefaults(defaultAdminUsersPanelStrings, strings);
33
+ const [users, setUsers] = useState<AdminUser[]>([]);
34
+ const [allRoles, setAllRoles] = useState<string[]>([]);
35
+ const [includeDeleted, setIncludeDeleted] = useState(false);
36
+ const [loading, setLoading] = useState(true);
37
+ const [addRoleFor, setAddRoleFor] = useState<string | null>(null);
38
+ const [selectedRole, setSelectedRole] = useState("");
39
+ const [roleLoading, setRoleLoading] = useState(false);
40
+ const [invite, setInvite] = useState({ email: "", firstName: "", lastName: "", role: defaultInviteRole });
41
+ const [inviting, setInviting] = useState(false);
42
+ // Bumped to fetch the list again after a change, through the same effect as every other load.
43
+ const [reloads, setReloads] = useState(0);
44
+ // The latest service, so an inline one does not fetch the list again on every render. The roles
45
+ // load once, so the first getRoles is enough.
46
+ const serviceRef = useRef(service);
47
+ useEffect(() => {
48
+ serviceRef.current = service;
49
+ }, [service]);
50
+ const getRolesRef = useRef(getRoles);
51
+
52
+ // Callers set `loading` before changing what to fetch, so the effect only sets state once the
53
+ // request settles. A request replaced by a newer one is ignored, so a slow answer to an earlier
54
+ // toggle cannot overwrite the list the toggle now shows.
55
+ useEffect(() => {
56
+ let replaced = false;
57
+ serviceRef.current
58
+ .getUsers(includeDeleted)
59
+ .then((list) => {
60
+ if (!replaced) setUsers(list);
61
+ })
62
+ .catch((err: unknown) => {
63
+ if (!replaced) toast.error(reason(err, text.usersLoadFailed));
64
+ })
65
+ .finally(() => {
66
+ if (!replaced) setLoading(false);
67
+ });
68
+ return () => {
69
+ replaced = true;
70
+ };
71
+ }, [includeDeleted, reloads, text.usersLoadFailed]);
72
+
73
+ useEffect(() => {
74
+ getRolesRef
75
+ .current()
76
+ .then(setAllRoles)
77
+ .catch(() => toast.error(text.rolesLoadFailed));
78
+ }, [text.rolesLoadFailed]);
79
+
80
+ const reload = () => {
81
+ setLoading(true);
82
+ setReloads((n) => n + 1);
83
+ };
84
+
85
+ const toggleDeleted = (deleted: boolean) => {
86
+ setLoading(true);
87
+ setIncludeDeleted(deleted);
88
+ };
89
+
90
+ const setRoles = (id: string, roles: string[]) => setUsers((prev) => prev.map((u) => (u.id === id ? { ...u, roles } : u)));
91
+
92
+ const assignRole = async (user: AdminUser) => {
93
+ if (!selectedRole) return;
94
+ setRoleLoading(true);
95
+ try {
96
+ await serviceRef.current.addRole(user.id, selectedRole);
97
+ setRoles(user.id, [...user.roles, selectedRole]);
98
+ toast.success(fill(text.roleAssigned, { name: user.firstName, role: selectedRole }));
99
+ setAddRoleFor(null);
100
+ setSelectedRole("");
101
+ } catch (err) {
102
+ toast.error(reason(err, text.roleAssignFailed));
103
+ } finally {
104
+ setRoleLoading(false);
105
+ }
106
+ };
107
+
108
+ const removeRole = async (user: AdminUser, role: string) => {
109
+ try {
110
+ await serviceRef.current.removeRole(user.id, role);
111
+ setRoles(user.id, user.roles.filter((r) => r !== role));
112
+ toast.success(fill(text.roleRemoved, { name: user.firstName, role }));
113
+ } catch (err) {
114
+ toast.error(reason(err, text.roleRemoveFailed));
115
+ }
116
+ };
117
+
118
+ const deleteUser = async (user: AdminUser) => {
119
+ const label = `${user.firstName} ${user.lastName}`.trim() || user.email;
120
+ if (!window.confirm(fill(text.confirmDelete, { name: label }))) return;
121
+ try {
122
+ await serviceRef.current.deleteUser(user.id);
123
+ toast.success(text.userDeleted);
124
+ reload();
125
+ } catch (err) {
126
+ toast.error(reason(err, text.userDeleteFailed));
127
+ }
128
+ };
129
+
130
+ const sendInvite = async () => {
131
+ if (!invite.email.trim() || !invite.firstName.trim() || !invite.lastName.trim()) {
132
+ toast.error(text.inviteIncomplete);
133
+ return;
134
+ }
135
+ setInviting(true);
136
+ try {
137
+ await serviceRef.current.invite(invite.email.trim(), invite.firstName.trim(), invite.lastName.trim(), invite.role);
138
+ toast.success(text.inviteSent);
139
+ setInvite({ email: "", firstName: "", lastName: "", role: invite.role });
140
+ reload();
141
+ } catch (err) {
142
+ toast.error(reason(err, text.inviteFailed));
143
+ } finally {
144
+ setInviting(false);
145
+ }
146
+ };
147
+
148
+ return (
149
+ <div className="space-y-4">
150
+ <div className="flex items-center justify-between">
151
+ <h2 className="font-bold text-gray-900">{text.users}</h2>
152
+ <Toggle label={text.showDeleted} checked={includeDeleted} onChange={toggleDeleted} />
153
+ </div>
154
+
155
+ <div className="rounded-2xl border border-gray-200 p-4 space-y-3">
156
+ <h3 className="text-sm font-semibold text-gray-900">{text.inviteUser}</h3>
157
+ <div className="flex flex-wrap items-end gap-2">
158
+ <div className="w-40">
159
+ <TextInput label={text.firstName} value={invite.firstName} onChange={(e) => setInvite({ ...invite, firstName: e.target.value })} />
160
+ </div>
161
+ <div className="w-40">
162
+ <TextInput label={text.lastName} value={invite.lastName} onChange={(e) => setInvite({ ...invite, lastName: e.target.value })} />
163
+ </div>
164
+ <div className="flex-1 min-w-[14rem]">
165
+ <TextInput label={text.email} type="email" value={invite.email} onChange={(e) => setInvite({ ...invite, email: e.target.value })} />
166
+ </div>
167
+ <label className="text-xs text-gray-600">
168
+ {text.role}
169
+ <select
170
+ value={invite.role}
171
+ onChange={(e) => setInvite({ ...invite, role: e.target.value })}
172
+ className="mt-1 block rounded-lg border border-gray-300 px-3 py-2 text-sm"
173
+ >
174
+ {allRoles.map((role) => (
175
+ <option key={role} value={role}>
176
+ {role}
177
+ </option>
178
+ ))}
179
+ </select>
180
+ </label>
181
+ <button
182
+ onClick={sendInvite}
183
+ disabled={inviting}
184
+ className="rounded-button bg-button-primary-bg text-button-primary-text font-semibold px-4 py-2 text-sm hover:brightness-95 disabled:opacity-60 transition"
185
+ >
186
+ {inviting ? text.sending : text.sendInvite}
187
+ </button>
188
+ </div>
189
+ <p className="text-xs text-gray-500">{text.inviteHint}</p>
190
+ </div>
191
+
192
+ {loading ? (
193
+ <p className="text-gray-500">{text.loading}</p>
194
+ ) : (
195
+ <ul className="divide-y divide-gray-100 rounded-2xl border border-gray-200">
196
+ {users.map((user) => {
197
+ const isAddingRole = addRoleFor === user.id;
198
+ const available = allRoles.filter((r) => !user.roles.includes(r));
199
+ return (
200
+ <li key={user.id} className="p-4 space-y-2">
201
+ <div className="flex items-start justify-between gap-3">
202
+ <div className={user.isDeleted ? "text-gray-400" : "text-gray-800"}>
203
+ <span className="font-medium">
204
+ {user.firstName} {user.lastName}
205
+ </span>
206
+ <span className="ml-2 text-xs text-gray-500">{user.email}</span>
207
+ {user.isDeleted && <span className="ml-2 text-xs text-gray-400">({text.deleted})</span>}
208
+ </div>
209
+ {!user.isDeleted && (
210
+ <button
211
+ onClick={() => deleteUser(user)}
212
+ title={text.delete}
213
+ className="shrink-0 p-2 rounded-lg text-red-500 hover:text-red-700 hover:bg-red-50 transition-colors"
214
+ >
215
+ <TrashIcon className="h-4 w-4" />
216
+ </button>
217
+ )}
218
+ </div>
219
+
220
+ <div className="flex flex-wrap items-center gap-1.5">
221
+ {user.roles.map((role) => (
222
+ <span key={role} className="flex items-center gap-1 pl-2 pr-1 py-0.5 bg-gray-100 border border-gray-200 rounded-lg text-xs text-gray-700">
223
+ {role}
224
+ <button onClick={() => removeRole(user, role)} className="hover:text-red-600 transition-colors" title={`${text.remove} ${role}`}>
225
+ <XMarkIcon className="h-3 w-3" />
226
+ </button>
227
+ </span>
228
+ ))}
229
+ {user.roles.length === 0 && <span className="text-xs text-gray-400">{text.noRoles}</span>}
230
+ {!isAddingRole && !user.isDeleted && available.length > 0 && (
231
+ <button
232
+ onClick={() => {
233
+ setAddRoleFor(user.id);
234
+ setSelectedRole(available[0] ?? "");
235
+ }}
236
+ className="flex items-center gap-1 px-2 py-0.5 bg-gray-50 hover:bg-gray-100 border border-gray-200 rounded-lg text-xs text-gray-500 hover:text-gray-800 transition"
237
+ >
238
+ <PlusIcon className="h-3 w-3" /> {text.addRole}
239
+ </button>
240
+ )}
241
+ </div>
242
+
243
+ {isAddingRole && (
244
+ <div className="flex items-center gap-2">
245
+ <select
246
+ value={selectedRole}
247
+ onChange={(e) => setSelectedRole(e.target.value)}
248
+ aria-label={text.addRole}
249
+ className="rounded-lg border border-gray-300 px-2 py-1 text-sm"
250
+ >
251
+ {available.map((r) => (
252
+ <option key={r} value={r}>
253
+ {r}
254
+ </option>
255
+ ))}
256
+ </select>
257
+ <button
258
+ onClick={() => assignRole(user)}
259
+ disabled={roleLoading}
260
+ className="rounded-lg bg-gray-900 text-white text-xs px-3 py-1.5 hover:bg-gray-800 disabled:opacity-50 transition"
261
+ >
262
+ {roleLoading ? "…" : text.add}
263
+ </button>
264
+ <button onClick={() => setAddRoleFor(null)} className="rounded-lg border border-gray-300 text-gray-700 text-xs px-3 py-1.5 hover:bg-gray-50 transition">
265
+ {text.cancel}
266
+ </button>
267
+ </div>
268
+ )}
269
+ </li>
270
+ );
271
+ })}
272
+ </ul>
273
+ )}
274
+ </div>
275
+ );
276
+ }
@@ -0,0 +1,122 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef, useState, type FormEvent, type ReactNode } from "react";
4
+ import type { SiteSettings } from "../api/services";
5
+ import { toast } from "../toast";
6
+ import { TextInput } from "../ui/TextInput";
7
+ import { Toggle } from "../ui/Toggle";
8
+ import { withDefaults, type TextOverrides } from "../ui/withDefaults";
9
+ import { defaultCompanySettingsFormStrings, type CompanySettingsFormStrings } from "./companySettingsFormStrings";
10
+
11
+ export interface CompanySettingsFormProps {
12
+ /** The settings calls, such as `createSettingsService(api)`. */
13
+ service: { get: () => Promise<SiteSettings>; update: (settings: SiteSettings) => Promise<SiteSettings> };
14
+ strings?: TextOverrides<CompanySettingsFormStrings> | undefined;
15
+ /** Shown below the form once the settings have loaded, such as the `BrandingManager`. */
16
+ children?: ReactNode;
17
+ }
18
+
19
+ const EMPTY: SiteSettings = {
20
+ contactRecipientEmail: "",
21
+ companyName: "",
22
+ companyLegalName: "",
23
+ orgNumber: "",
24
+ vatRegistered: false,
25
+ streetAddress: "",
26
+ postalCode: "",
27
+ addressLocality: "",
28
+ addressRegion: "",
29
+ publicEmail: "",
30
+ publicPhone: "",
31
+ };
32
+
33
+ /**
34
+ * The admin form for the business details the public pages show: the contact recipient, names, org
35
+ * number and VAT, the address in parts, and the public email and phone.
36
+ */
37
+ export function CompanySettingsForm({ service, strings, children }: CompanySettingsFormProps) {
38
+ const text = withDefaults(defaultCompanySettingsFormStrings, strings);
39
+ const [settings, setSettings] = useState<SiteSettings>(EMPTY);
40
+ const [loading, setLoading] = useState(true);
41
+ const [saving, setSaving] = useState(false);
42
+ const [failed, setFailed] = useState(false);
43
+ // The latest service, so an inline one does not load the settings again on every render.
44
+ const serviceRef = useRef(service);
45
+ useEffect(() => {
46
+ serviceRef.current = service;
47
+ }, [service]);
48
+
49
+ useEffect(() => {
50
+ serviceRef.current
51
+ .get()
52
+ .then(setSettings)
53
+ .catch(() => {
54
+ // No form without the saved settings, since saving a blank one would erase them.
55
+ setFailed(true);
56
+ toast.error(text.loadFailed);
57
+ })
58
+ .finally(() => setLoading(false));
59
+ }, [text.loadFailed]);
60
+
61
+ const patch = (changes: Partial<SiteSettings>) => setSettings(prev => ({ ...prev, ...changes }));
62
+
63
+ const handleSubmit = async (e: FormEvent) => {
64
+ e.preventDefault();
65
+ setSaving(true);
66
+ try {
67
+ setSettings(await serviceRef.current.update(settings));
68
+ toast.success(text.saved);
69
+ } catch (err) {
70
+ toast.error(err instanceof Error && err.message ? err.message : text.saveFailed);
71
+ } finally {
72
+ setSaving(false);
73
+ }
74
+ };
75
+
76
+ if (loading) return <p className="text-gray-500">{text.loading}</p>;
77
+ if (failed) return <p className="text-gray-500">{text.loadFailed}</p>;
78
+
79
+ return (
80
+ <div className="space-y-8">
81
+ <form onSubmit={handleSubmit} className="max-w-md space-y-4">
82
+ <TextInput
83
+ label={text.contactRecipient}
84
+ name="contactRecipientEmail"
85
+ type="email"
86
+ value={settings.contactRecipientEmail}
87
+ onChange={e => patch({ contactRecipientEmail: e.target.value })}
88
+ required
89
+ />
90
+ <p className="text-xs text-gray-500">{text.contactRecipientHint}</p>
91
+
92
+ <TextInput label={text.companyName} name="companyName" value={settings.companyName} onChange={e => patch({ companyName: e.target.value })} required />
93
+ <TextInput label={text.legalName} name="companyLegalName" value={settings.companyLegalName} onChange={e => patch({ companyLegalName: e.target.value })} />
94
+ <p className="text-xs text-gray-500">{text.legalNameHint}</p>
95
+
96
+ <TextInput label={text.orgNumber} name="orgNumber" value={settings.orgNumber} onChange={e => patch({ orgNumber: e.target.value })} />
97
+ <Toggle label={text.vatRegistered} checked={settings.vatRegistered} onChange={v => patch({ vatRegistered: v })} />
98
+ <p className="text-xs text-gray-500">{text.vatHint}</p>
99
+
100
+ <TextInput label={text.streetAddress} name="streetAddress" value={settings.streetAddress} onChange={e => patch({ streetAddress: e.target.value })} required />
101
+ <div className="grid gap-4 sm:grid-cols-[8rem_1fr]">
102
+ <TextInput label={text.postalCode} name="postalCode" inputMode="numeric" value={settings.postalCode} onChange={e => patch({ postalCode: e.target.value })} />
103
+ <TextInput label={text.addressLocality} name="addressLocality" value={settings.addressLocality} onChange={e => patch({ addressLocality: e.target.value })} />
104
+ </div>
105
+ <TextInput label={text.addressRegion} name="addressRegion" value={settings.addressRegion} onChange={e => patch({ addressRegion: e.target.value })} />
106
+ <p className="text-xs text-gray-500">{text.addressHint}</p>
107
+ <TextInput label={text.publicEmail} name="publicEmail" type="email" value={settings.publicEmail} onChange={e => patch({ publicEmail: e.target.value })} required />
108
+ <TextInput label={text.publicPhone} name="publicPhone" value={settings.publicPhone} onChange={e => patch({ publicPhone: e.target.value })} required />
109
+
110
+ <button
111
+ type="submit"
112
+ disabled={saving}
113
+ className="rounded-button bg-button-primary-bg text-button-primary-text font-semibold px-5 py-2.5 hover:brightness-95 disabled:opacity-60 transition"
114
+ >
115
+ {saving ? text.saving : text.save}
116
+ </button>
117
+ </form>
118
+
119
+ {children}
120
+ </div>
121
+ );
122
+ }
@@ -0,0 +1,131 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef, useState } from "react";
4
+ import type { LegalPage } from "../api/services";
5
+ import { toast } from "../toast";
6
+ import { TextInput } from "../ui/TextInput";
7
+ import { withDefaults, type TextOverrides } from "../ui/withDefaults";
8
+ import { defaultLegalPagesEditorStrings, type LegalPagesEditorStrings } from "./legalPagesEditorStrings";
9
+ import { LocaleTabs } from "./LocaleTabs";
10
+
11
+ export interface LegalPagesEditorProps<K extends string, L extends string> {
12
+ /** The legal page calls, such as `createLegalService(api, publicApi)`. */
13
+ service: {
14
+ listAll: () => Promise<LegalPage<K>[]>;
15
+ save: (key: K, locale: L, title: string, bodyMarkdown: string) => Promise<unknown>;
16
+ };
17
+ /** The pages, in tab order. The first opens. */
18
+ keys: readonly K[];
19
+ /** Each page's tab label, such as `Terms`. */
20
+ keyLabels: Record<K, string>;
21
+ /** The locales each page is written in. The first opens. */
22
+ locales: readonly L[];
23
+ /** Each locale's tab label, such as `Norsk`. */
24
+ localeLabels: Record<L, string>;
25
+ strings?: TextOverrides<LegalPagesEditorStrings> | undefined;
26
+ }
27
+
28
+ type Draft = { title: string; bodyMarkdown: string };
29
+
30
+ const emptyDraft: Draft = { title: "", bodyMarkdown: "" };
31
+
32
+ /** The admin editor for the legal pages: one page and one locale at a time, with the title and the Markdown body. */
33
+ export function LegalPagesEditor<K extends string, L extends string>({ service, keys, keyLabels, locales, localeLabels, strings }: LegalPagesEditorProps<K, L>) {
34
+ const text = withDefaults(defaultLegalPagesEditorStrings, strings);
35
+ const [drafts, setDrafts] = useState<Record<string, Draft>>({});
36
+ const [activeKey, setActiveKey] = useState<K | undefined>(keys[0]);
37
+ const [activeLocale, setActiveLocale] = useState<L | undefined>(locales[0]);
38
+ const [loading, setLoading] = useState(true);
39
+ const [saving, setSaving] = useState(false);
40
+ const [failed, setFailed] = useState(false);
41
+ // The latest service, so an inline one does not load the pages again on every render.
42
+ const serviceRef = useRef(service);
43
+ useEffect(() => {
44
+ serviceRef.current = service;
45
+ }, [service]);
46
+
47
+ useEffect(() => {
48
+ serviceRef.current
49
+ .listAll()
50
+ .then(pages => setDrafts(Object.fromEntries(pages.map(p => [`${p.key}:${p.locale}`, { title: p.title, bodyMarkdown: p.bodyMarkdown }]))))
51
+ .catch((err: unknown) => {
52
+ // No editor without the saved pages, since saving a blank one would erase it.
53
+ setFailed(true);
54
+ toast.error(err instanceof Error && err.message ? err.message : text.loadFailed);
55
+ })
56
+ .finally(() => setLoading(false));
57
+ }, [text.loadFailed]);
58
+
59
+ if (activeKey === undefined || activeLocale === undefined) return null;
60
+
61
+ const slot = `${activeKey}:${activeLocale}`;
62
+ const draft = drafts[slot] ?? emptyDraft;
63
+
64
+ const patch = (changes: Partial<Draft>) => setDrafts(prev => ({ ...prev, [slot]: { ...(prev[slot] ?? emptyDraft), ...changes } }));
65
+
66
+ const save = async () => {
67
+ if (draft.title.trim() === "" || draft.bodyMarkdown.trim() === "") {
68
+ toast.error(text.required);
69
+ return;
70
+ }
71
+ setSaving(true);
72
+ try {
73
+ await serviceRef.current.save(activeKey, activeLocale, draft.title.trim(), draft.bodyMarkdown);
74
+ toast.success(text.saved);
75
+ } catch (err) {
76
+ toast.error(err instanceof Error && err.message ? err.message : text.saveFailed);
77
+ } finally {
78
+ setSaving(false);
79
+ }
80
+ };
81
+
82
+ if (loading) return <p className="text-gray-500">{text.loading}</p>;
83
+ if (failed) return <p className="text-gray-500">{text.loadFailed}</p>;
84
+
85
+ const filled = Object.fromEntries(locales.map(l => [l, (drafts[`${activeKey}:${l}`]?.bodyMarkdown ?? "") !== ""])) as Record<L, boolean>;
86
+
87
+ return (
88
+ <div className="space-y-4">
89
+ <div className="flex items-center justify-between">
90
+ <h2 className="font-bold text-gray-900">{text.heading}</h2>
91
+ <button
92
+ onClick={save}
93
+ disabled={saving}
94
+ className="rounded-button bg-button-primary-bg text-button-primary-text font-semibold px-5 py-2 text-sm hover:brightness-95 disabled:opacity-60 transition"
95
+ >
96
+ {saving ? text.saving : text.save}
97
+ </button>
98
+ </div>
99
+
100
+ <div className="flex gap-1">
101
+ {keys.map(key => (
102
+ <button
103
+ key={key}
104
+ onClick={() => setActiveKey(key)}
105
+ aria-pressed={key === activeKey}
106
+ className={`rounded-lg px-3 py-1.5 text-sm font-medium transition-colors ${key === activeKey ? "bg-gray-900 text-white" : "text-gray-600 hover:bg-gray-100"}`}
107
+ >
108
+ {keyLabels[key]}
109
+ </button>
110
+ ))}
111
+ </div>
112
+
113
+ <LocaleTabs locales={locales} labels={localeLabels} active={activeLocale} onChange={setActiveLocale} filled={filled} notFilled={text.notFilled} />
114
+
115
+ <TextInput label={text.title} value={draft.title} onChange={e => patch({ title: e.target.value })} />
116
+
117
+ <div>
118
+ <label htmlFor="legal-body" className="block text-sm font-medium text-gray-700 mb-1">
119
+ {text.bodyMarkdown}
120
+ </label>
121
+ <textarea
122
+ id="legal-body"
123
+ rows={24}
124
+ value={draft.bodyMarkdown}
125
+ onChange={e => patch({ bodyMarkdown: e.target.value })}
126
+ className="w-full rounded-lg border border-gray-300 px-3 py-2 font-mono text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-primary"
127
+ />
128
+ </div>
129
+ </div>
130
+ );
131
+ }
@@ -0,0 +1,35 @@
1
+ "use client";
2
+
3
+ export interface LocaleTabsProps<L extends string> {
4
+ locales: readonly L[];
5
+ /** Each locale's tab label, such as `Norsk`. */
6
+ labels: Record<L, string>;
7
+ active: L;
8
+ onChange: (locale: L) => void;
9
+ /** Which locales have content. A locale marked false gets a dot titled `notFilled`. */
10
+ filled?: Partial<Record<L, boolean>> | undefined;
11
+ /** The dot's title, such as "Not filled in". */
12
+ notFilled?: string | undefined;
13
+ }
14
+
15
+ /** Tabs for editing one translation at a time. */
16
+ export function LocaleTabs<L extends string>({ locales, labels, active, onChange, filled, notFilled = "Not filled in" }: LocaleTabsProps<L>) {
17
+ return (
18
+ <div className="flex items-center gap-1 border-b border-gray-200">
19
+ {locales.map(locale => (
20
+ <button
21
+ key={locale}
22
+ type="button"
23
+ onClick={() => onChange(locale)}
24
+ aria-pressed={locale === active}
25
+ className={`px-4 py-2 text-sm font-semibold border-b-2 -mb-px transition-colors ${
26
+ locale === active ? "border-gray-900 text-gray-900" : "border-transparent text-gray-500 hover:text-gray-900"
27
+ }`}
28
+ >
29
+ {labels[locale]}
30
+ {filled?.[locale] === false && <span className="ml-1.5 inline-block h-1.5 w-1.5 rounded-full bg-amber-400 align-middle" title={notFilled} />}
31
+ </button>
32
+ ))}
33
+ </div>
34
+ );
35
+ }
@@ -0,0 +1,99 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { withDefaults, type TextOverrides } from "../ui/withDefaults";
5
+ import { defaultStatHistoryChartStrings, STAT_SERIES_COLORS, type StatHistoryChartStrings } from "./statHistoryChartStrings";
6
+
7
+ /** One line on the chart. */
8
+ export interface StatSeries<T> {
9
+ /** The field of each day's row the line plots. A missing or non-numeric value counts as zero. */
10
+ key: keyof T & string;
11
+ label: string;
12
+ /** The line's colour. Defaults to the next colour in `STAT_SERIES_COLORS`. */
13
+ color?: string | undefined;
14
+ }
15
+
16
+ export interface StatHistoryChartProps<T extends { date: string }> {
17
+ /** One row per day, oldest first, with `date` as `YYYY-MM-DD`. */
18
+ data: readonly T[];
19
+ series: readonly StatSeries<T>[];
20
+ strings?: TextOverrides<StatHistoryChartStrings> | undefined;
21
+ }
22
+
23
+ const W = 720;
24
+ const H = 240;
25
+ const PAD = { top: 16, right: 16, bottom: 28, left: 32 };
26
+
27
+ /**
28
+ * A line per series over the days, with the scale from zero to the highest shown value. The legend
29
+ * buttons hide and show a line, and the scale follows the lines that are shown.
30
+ */
31
+ export function StatHistoryChart<T extends { date: string }>({ data, series, strings }: StatHistoryChartProps<T>) {
32
+ const text = withDefaults(defaultStatHistoryChartStrings, strings);
33
+ const [hidden, setHidden] = useState<string[]>([]);
34
+
35
+ const toggle = (key: string) => setHidden(prev => (prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key]));
36
+
37
+ if (data.length === 0) {
38
+ return <p className="text-sm text-gray-500">{text.empty}</p>;
39
+ }
40
+
41
+ const lines = series.map((s, i) => ({ ...s, color: s.color ?? STAT_SERIES_COLORS[i % STAT_SERIES_COLORS.length] }));
42
+ const shown = lines.filter(s => !hidden.includes(s.key));
43
+ const innerW = W - PAD.left - PAD.right;
44
+ const innerH = H - PAD.top - PAD.bottom;
45
+ // An API without a given series sends no value. It counts as zero rather than poisoning the scale with NaN.
46
+ const value = (d: T, key: keyof T) => Number(d[key]) || 0;
47
+ const maxVal = Math.max(1, ...data.flatMap(d => shown.map(s => value(d, s.key))));
48
+ const n = data.length;
49
+
50
+ const x = (i: number) => PAD.left + (n === 1 ? innerW / 2 : (i / (n - 1)) * innerW);
51
+ const y = (v: number) => PAD.top + innerH - (v / maxVal) * innerH;
52
+
53
+ const path = (key: keyof T) => data.map((d, i) => `${i === 0 ? "M" : "L"} ${x(i).toFixed(1)} ${y(value(d, key)).toFixed(1)}`).join(" ");
54
+
55
+ const ticks = [...new Set([0, Math.round(maxVal / 2), maxVal])];
56
+ const labelIdx = [...new Set([0, Math.floor((n - 1) / 2), n - 1])];
57
+
58
+ return (
59
+ <div>
60
+ <svg viewBox={`0 0 ${W} ${H}`} className="w-full h-auto" role="img" aria-label={text.chart}>
61
+ {ticks.map(t => (
62
+ <g key={t}>
63
+ <line x1={PAD.left} y1={y(t)} x2={W - PAD.right} y2={y(t)} className="stroke-chart-grid" strokeWidth={1} />
64
+ <text x={PAD.left - 6} y={y(t) + 3} textAnchor="end" fontSize={10} className="fill-chart-label">
65
+ {t}
66
+ </text>
67
+ </g>
68
+ ))}
69
+ {labelIdx.map(i => (
70
+ <text key={i} x={x(i)} y={H - 8} textAnchor="middle" fontSize={10} className="fill-chart-label">
71
+ {data[i]?.date.slice(5)}
72
+ </text>
73
+ ))}
74
+ {shown.map(s => (
75
+ <path key={s.key} d={path(s.key)} fill="none" stroke={s.color} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />
76
+ ))}
77
+ </svg>
78
+ <div className="mt-3 flex flex-wrap gap-2">
79
+ {lines.map(s => {
80
+ const off = hidden.includes(s.key);
81
+ return (
82
+ <button
83
+ key={s.key}
84
+ type="button"
85
+ onClick={() => toggle(s.key)}
86
+ aria-pressed={!off}
87
+ className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs transition ${
88
+ off ? "border-gray-200 bg-white text-gray-400" : "border-gray-300 bg-gray-50 text-gray-700"
89
+ }`}
90
+ >
91
+ <span className="inline-block h-2.5 w-2.5 rounded-full" style={{ backgroundColor: off ? "#d1d5db" : s.color }} />
92
+ {s.label}
93
+ </button>
94
+ );
95
+ })}
96
+ </div>
97
+ </div>
98
+ );
99
+ }