@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.
- package/README.md +228 -34
- package/package.json +17 -1
- package/src/admin/AdminShell.tsx +53 -0
- package/src/admin/AdminUsersPanel.tsx +276 -0
- package/src/admin/CompanySettingsForm.tsx +122 -0
- package/src/admin/LegalPagesEditor.tsx +131 -0
- package/src/admin/LocaleTabs.tsx +35 -0
- package/src/admin/StatHistoryChart.tsx +99 -0
- package/src/admin/adminUsersPanelStrings.ts +73 -0
- package/src/admin/companySettingsFormStrings.ts +50 -0
- package/src/admin/index.ts +20 -0
- package/src/admin/legalPagesEditorStrings.ts +32 -0
- package/src/admin/statHistoryChartStrings.ts +16 -0
- package/src/api/company.ts +46 -0
- package/src/api/index.ts +4 -0
- package/src/api/revalidateTargets.ts +40 -0
- package/src/legal/LegalPageView.tsx +61 -0
- package/src/legal/index.ts +1 -0
- package/src/next-auth.d.ts +24 -0
- package/src/seo/index.ts +2 -0
- package/src/seo/site.ts +56 -8
- package/src/styles.css +9 -0
- package/src/testing/index.ts +113 -0
- package/src/ui/LoginPage.tsx +54 -0
- package/src/ui/ResetPasswordFlow.tsx +5 -5
- package/src/ui/authClasses.ts +5 -0
- package/src/ui/index.ts +4 -0
- package/src/ui/loginPageStrings.ts +17 -0
|
@@ -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
|
+
}
|