@sjolystinnovation/app-kit 0.22.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +1281 -773
  2. package/package.json +21 -4
  3. package/src/admin/AdminUsersPanel.tsx +85 -35
  4. package/src/admin/CompanySettingsForm.tsx +33 -13
  5. package/src/admin/EmailStatsPanel.tsx +76 -0
  6. package/src/admin/LegalPagesEditor.tsx +41 -14
  7. package/src/admin/adminUsersPanelStrings.ts +67 -0
  8. package/src/admin/companySettingsFormStrings.ts +26 -0
  9. package/src/admin/emailStatsPanelStrings.ts +29 -0
  10. package/src/admin/index.ts +14 -5
  11. package/src/admin/legalPagesEditorStrings.ts +25 -0
  12. package/src/api/apiErrorCode.ts +28 -0
  13. package/src/api/apiFieldErrors.ts +82 -11
  14. package/src/api/failureBody.ts +10 -0
  15. package/src/api/fieldErrorText.ts +32 -0
  16. package/src/api/index.ts +5 -1
  17. package/src/api/legalPage.ts +10 -0
  18. package/src/api/request.ts +13 -3
  19. package/src/api/services.ts +42 -12
  20. package/src/auth/createAuthOptions.ts +9 -3
  21. package/src/branding/BrandingManager.tsx +31 -36
  22. package/src/branding/brandingManagerStrings.ts +49 -0
  23. package/src/branding/index.ts +3 -3
  24. package/src/legal/LegalPageView.tsx +2 -17
  25. package/src/legal/index.ts +4 -1
  26. package/src/legal/keys.ts +10 -0
  27. package/src/legal/legalPageViewStrings.ts +17 -0
  28. package/src/locales/nb.ts +330 -0
  29. package/src/sections/SectionRenderer.tsx +275 -0
  30. package/src/sections/SectionsEditor.tsx +151 -0
  31. package/src/sections/SmartLink.tsx +42 -0
  32. package/src/sections/editors.tsx +311 -0
  33. package/src/sections/factory.ts +26 -0
  34. package/src/sections/index.ts +69 -0
  35. package/src/sections/sectionsEditorStrings.ts +108 -0
  36. package/src/sections/types.ts +107 -0
  37. package/src/seo/index.ts +1 -0
  38. package/src/seo/site.ts +5 -5
  39. package/src/session/expiry.ts +3 -1
  40. package/src/session/index.ts +0 -1
  41. package/src/session/react/CredentialsForm.tsx +8 -2
  42. package/src/session/react/credentialsFormStrings.ts +4 -1
  43. package/src/session/react/index.ts +1 -1
  44. package/src/styles.css +2 -0
  45. package/src/testing/index.ts +1 -9
  46. package/src/ui/ErrorState.tsx +1 -15
  47. package/src/ui/NotFoundState.tsx +1 -13
  48. package/src/ui/ResetPasswordFlow.tsx +55 -12
  49. package/src/ui/errorStateStrings.ts +16 -0
  50. package/src/ui/index.ts +10 -6
  51. package/src/ui/notFoundStateStrings.ts +14 -0
  52. package/src/ui/resetPasswordFlowStrings.ts +44 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjolystinnovation/app-kit",
3
- "version": "0.22.0",
3
+ "version": "1.0.0",
4
4
  "description": "Building blocks for Next.js apps that sign in against a JWT API through next-auth",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Sjølyst Innovation AS",
@@ -9,7 +9,10 @@
9
9
  "url": "git+https://github.com/sondresjolyst/si-web-packages.git",
10
10
  "directory": "packages/app-kit"
11
11
  },
12
- "homepage": "https://github.com/sondresjolyst/si-web-packages#readme",
12
+ "homepage": "https://github.com/sondresjolyst/si-web-packages/tree/main/packages/app-kit#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/sondresjolyst/si-web-packages/issues"
15
+ },
13
16
  "type": "module",
14
17
  "sideEffects": [
15
18
  "*.css"
@@ -32,17 +35,21 @@
32
35
  "./next-config": "./src/next-config/index.ts",
33
36
  "./markdown": "./src/markdown/index.ts",
34
37
  "./legal": "./src/legal/index.ts",
38
+ "./sections": "./src/sections/index.ts",
35
39
  "./seo": "./src/seo/index.ts",
36
40
  "./seo/og": "./src/seo/og/index.tsx",
37
41
  "./branding": "./src/branding/index.ts",
38
42
  "./branding/server": "./src/branding/server.ts",
39
43
  "./payments": "./src/payments/index.ts",
44
+ "./locales/nb": "./src/locales/nb.ts",
40
45
  "./testing": "./src/testing/index.ts",
41
46
  "./styles.css": "./src/styles.css"
42
47
  },
43
48
  "files": [
44
49
  "src",
45
- "!src/tests"
50
+ "!src/tests",
51
+ "README.md",
52
+ "LICENSE"
46
53
  ],
47
54
  "publishConfig": {
48
55
  "access": "public"
@@ -66,6 +73,9 @@
66
73
  "zod": "^4"
67
74
  },
68
75
  "peerDependenciesMeta": {
76
+ "@heroicons/react": {
77
+ "optional": true
78
+ },
69
79
  "axios": {
70
80
  "optional": true
71
81
  },
@@ -115,11 +125,18 @@
115
125
  },
116
126
  "keywords": [
117
127
  "next.js",
128
+ "nextjs",
129
+ "app-router",
118
130
  "next-auth",
119
131
  "session",
120
132
  "jwt",
133
+ "authentication",
134
+ "axios",
121
135
  "react",
122
- "tailwindcss"
136
+ "tailwindcss",
137
+ "admin",
138
+ "seo",
139
+ "typescript"
123
140
  ],
124
141
  "dependencies": {
125
142
  "@types/jsonwebtoken": "^9.0.10",
@@ -1,24 +1,54 @@
1
1
  "use client";
2
2
 
3
3
  import { PlusIcon, TrashIcon, XMarkIcon } from "@heroicons/react/24/outline";
4
- import { useEffect, useRef, useState } from "react";
4
+ import { useEffect, useId, useRef, useState } from "react";
5
+ import { errorText, fieldErrorTexts } from "../api/fieldErrorText";
5
6
  import type { AdminUser, createAdminUsersService } from "../api/services";
6
7
  import { toast } from "../toast";
8
+ import { FieldError } from "../ui/field";
7
9
  import { TextInput } from "../ui/TextInput";
8
10
  import { Toggle } from "../ui/Toggle";
9
11
  import { withDefaults, type TextOverrides } from "../ui/withDefaults";
10
- import { defaultAdminUsersPanelStrings, type AdminUsersPanelStrings } from "./adminUsersPanelStrings";
12
+ import {
13
+ defaultAdminUsersPanelStrings,
14
+ type AdminUsersDeleteErrorStrings,
15
+ type AdminUsersErrorStrings,
16
+ type AdminUsersPanelStrings,
17
+ type AdminUsersValidationStrings,
18
+ } from "./adminUsersPanelStrings";
11
19
 
12
- /** Props for `AdminUsersPanel`. */
13
- export interface AdminUsersPanelProps {
14
- /** The user admin calls, such as `createAdminUsersService(api)`. */
15
- service: ReturnType<typeof createAdminUsersService>;
16
- /** The roles an admin may give, from the app's own endpoint. */
17
- getRoles: () => Promise<string[]>;
20
+ type AdminUsersService = ReturnType<typeof createAdminUsersService>;
21
+
22
+ /**
23
+ * Props for `AdminUsersPanel`. The roles come from the service's `roles`, or from `getRoles` when the
24
+ * app passes it, so a service without `roles` needs `getRoles`.
25
+ */
26
+ export type AdminUsersPanelProps = {
18
27
  /** The role a new invite starts with. Defaults to `Admin`. */
19
28
  defaultInviteRole?: string | undefined;
20
- strings?: TextOverrides<AdminUsersPanelStrings> | undefined;
21
- }
29
+ /** The panel's text. `validation`, `errors` and `deleteErrors` may name only some codes, the rest keep their defaults. */
30
+ strings?:
31
+ | (TextOverrides<Omit<AdminUsersPanelStrings, "validation" | "errors" | "deleteErrors">> & {
32
+ validation?: TextOverrides<AdminUsersValidationStrings> | undefined;
33
+ errors?: TextOverrides<AdminUsersErrorStrings> | undefined;
34
+ deleteErrors?: TextOverrides<AdminUsersDeleteErrorStrings> | undefined;
35
+ })
36
+ | undefined;
37
+ } & (
38
+ | {
39
+ /** The user admin calls, such as `createAdminUsersService(api)`. */
40
+ service: AdminUsersService;
41
+ /** The roles an admin may give. Defaults to the service's `roles`. */
42
+ getRoles?: (() => Promise<string[]>) | undefined;
43
+ }
44
+ | {
45
+ service: Omit<AdminUsersService, "roles">;
46
+ getRoles: () => Promise<string[]>;
47
+ }
48
+ );
49
+
50
+ // The invite fields the form shows, so a field error for any other field goes in the toast.
51
+ const INVITE_FIELDS = ["firstName", "lastName", "email", "role"];
22
52
 
23
53
  const fill = (text: string, values: Record<string, string>) =>
24
54
  text.replace(/\{(\w+)\}/g, (match, key: string) => values[key] ?? match);
@@ -27,10 +57,16 @@ const reason = (err: unknown, fallback: string) => (err instanceof Error && err.
27
57
 
28
58
  /**
29
59
  * The admin users page: every user with their roles, a toggle for deleted users, giving and taking
30
- * roles, deleting an account after a confirm, and inviting a user by email.
60
+ * roles, deleting an account after a confirm, and inviting a user by email. The API's refusals show
61
+ * in the app's language through `validation`, `errors` and `deleteErrors`.
31
62
  */
32
63
  export function AdminUsersPanel({ service, getRoles, defaultInviteRole = "Admin", strings }: AdminUsersPanelProps) {
33
- const text = withDefaults(defaultAdminUsersPanelStrings, strings);
64
+ const { validation: validationOverrides, errors: errorOverrides, deleteErrors: deleteOverrides, ...textOverrides } = strings ?? {};
65
+ const text = withDefaults(defaultAdminUsersPanelStrings, textOverrides);
66
+ const validation = withDefaults(defaultAdminUsersPanelStrings.validation, validationOverrides);
67
+ const errors = withDefaults(defaultAdminUsersPanelStrings.errors, errorOverrides);
68
+ const deleteErrors = withDefaults(defaultAdminUsersPanelStrings.deleteErrors, deleteOverrides);
69
+ const roleErrorId = `${useId()}-role-error`;
34
70
  const [users, setUsers] = useState<AdminUser[]>([]);
35
71
  const [allRoles, setAllRoles] = useState<string[]>([]);
36
72
  const [includeDeleted, setIncludeDeleted] = useState(false);
@@ -40,6 +76,8 @@ export function AdminUsersPanel({ service, getRoles, defaultInviteRole = "Admin"
40
76
  const [roleLoading, setRoleLoading] = useState(false);
41
77
  const [invite, setInvite] = useState({ email: "", firstName: "", lastName: "", role: defaultInviteRole });
42
78
  const [inviting, setInviting] = useState(false);
79
+ // The per-field texts from the last failed invite, keyed by field name.
80
+ const [inviteErrors, setInviteErrors] = useState<Record<string, string[]>>({});
43
81
  // Bumping it fetches the list again after a change, through the same effect as every other load.
44
82
  const [reloads, setReloads] = useState(0);
45
83
  // The latest service, so an inline one does not fetch the list again on every render. The roles
@@ -72,8 +110,9 @@ export function AdminUsersPanel({ service, getRoles, defaultInviteRole = "Admin"
72
110
  }, [includeDeleted, reloads, text.usersLoadFailed]);
73
111
 
74
112
  useEffect(() => {
75
- getRolesRef
76
- .current()
113
+ // Without getRoles the props carry a service with roles.
114
+ const roles = getRolesRef.current ?? (() => (serviceRef.current as AdminUsersService).roles());
115
+ roles()
77
116
  .then(setAllRoles)
78
117
  .catch(() => toast.error(text.rolesLoadFailed));
79
118
  }, [text.rolesLoadFailed]);
@@ -100,7 +139,7 @@ export function AdminUsersPanel({ service, getRoles, defaultInviteRole = "Admin"
100
139
  setAddRoleFor(null);
101
140
  setSelectedRole("");
102
141
  } catch (err) {
103
- toast.error(reason(err, text.roleAssignFailed));
142
+ toast.error(errorText(err, errors, text.roleAssignFailed));
104
143
  } finally {
105
144
  setRoleLoading(false);
106
145
  }
@@ -112,7 +151,7 @@ export function AdminUsersPanel({ service, getRoles, defaultInviteRole = "Admin"
112
151
  setRoles(user.id, user.roles.filter((r) => r !== role));
113
152
  toast.success(fill(text.roleRemoved, { name: user.firstName, role }));
114
153
  } catch (err) {
115
- toast.error(reason(err, text.roleRemoveFailed));
154
+ toast.error(errorText(err, errors, text.roleRemoveFailed));
116
155
  }
117
156
  };
118
157
 
@@ -124,7 +163,7 @@ export function AdminUsersPanel({ service, getRoles, defaultInviteRole = "Admin"
124
163
  toast.success(text.userDeleted);
125
164
  reload();
126
165
  } catch (err) {
127
- toast.error(reason(err, text.userDeleteFailed));
166
+ toast.error(errorText(err, deleteErrors, text.userDeleteFailed));
128
167
  }
129
168
  };
130
169
 
@@ -141,11 +180,17 @@ export function AdminUsersPanel({ service, getRoles, defaultInviteRole = "Admin"
141
180
  lastName: invite.lastName.trim(),
142
181
  role: invite.role,
143
182
  });
183
+ setInviteErrors({});
144
184
  toast.success(text.inviteSent);
145
185
  setInvite({ email: "", firstName: "", lastName: "", role: invite.role });
146
186
  reload();
147
187
  } catch (err) {
148
- toast.error(reason(err, text.inviteFailed));
188
+ const texts = Object.entries(fieldErrorTexts(err, validation));
189
+ // Only the fields the form shows render their errors.
190
+ setInviteErrors(Object.fromEntries(texts));
191
+ // The toast names a field error the form cannot show first, then the first one it shows.
192
+ const first = (texts.find(([field]) => !INVITE_FIELDS.includes(field)) ?? texts[0])?.[1][0];
193
+ toast.error(first ?? errorText(err, errors, text.inviteFailed));
149
194
  } finally {
150
195
  setInviting(false);
151
196
  }
@@ -162,28 +207,33 @@ export function AdminUsersPanel({ service, getRoles, defaultInviteRole = "Admin"
162
207
  <h3 className="text-sm font-semibold text-gray-900">{text.inviteUser}</h3>
163
208
  <div className="flex flex-wrap items-end gap-2">
164
209
  <div className="w-40">
165
- <TextInput label={text.firstName} value={invite.firstName} onChange={(e) => setInvite({ ...invite, firstName: e.target.value })} />
210
+ <TextInput label={text.firstName} error={inviteErrors.firstName?.join(" ")} value={invite.firstName} onChange={(e) => setInvite({ ...invite, firstName: e.target.value })} />
166
211
  </div>
167
212
  <div className="w-40">
168
- <TextInput label={text.lastName} value={invite.lastName} onChange={(e) => setInvite({ ...invite, lastName: e.target.value })} />
213
+ <TextInput label={text.lastName} error={inviteErrors.lastName?.join(" ")} value={invite.lastName} onChange={(e) => setInvite({ ...invite, lastName: e.target.value })} />
169
214
  </div>
170
215
  <div className="flex-1 min-w-[14rem]">
171
- <TextInput label={text.email} type="email" value={invite.email} onChange={(e) => setInvite({ ...invite, email: e.target.value })} />
216
+ <TextInput label={text.email} error={inviteErrors.email?.join(" ")} type="email" value={invite.email} onChange={(e) => setInvite({ ...invite, email: e.target.value })} />
217
+ </div>
218
+ <div>
219
+ <label className="text-xs text-gray-600">
220
+ {text.role}
221
+ <select
222
+ value={invite.role}
223
+ onChange={(e) => setInvite({ ...invite, role: e.target.value })}
224
+ aria-invalid={inviteErrors.role ? true : undefined}
225
+ aria-describedby={inviteErrors.role ? roleErrorId : undefined}
226
+ className="mt-1 block rounded-lg border border-gray-300 px-3 py-2 text-sm"
227
+ >
228
+ {allRoles.map((role) => (
229
+ <option key={role} value={role}>
230
+ {role}
231
+ </option>
232
+ ))}
233
+ </select>
234
+ </label>
235
+ <FieldError id={roleErrorId} error={inviteErrors.role?.join(" ")} />
172
236
  </div>
173
- <label className="text-xs text-gray-600">
174
- {text.role}
175
- <select
176
- value={invite.role}
177
- onChange={(e) => setInvite({ ...invite, role: e.target.value })}
178
- className="mt-1 block rounded-lg border border-gray-300 px-3 py-2 text-sm"
179
- >
180
- {allRoles.map((role) => (
181
- <option key={role} value={role}>
182
- {role}
183
- </option>
184
- ))}
185
- </select>
186
- </label>
187
237
  <button
188
238
  onClick={sendInvite}
189
239
  disabled={inviting}
@@ -1,18 +1,28 @@
1
1
  "use client";
2
2
 
3
3
  import { useEffect, useRef, useState, type FormEvent, type ReactNode } from "react";
4
+ import { fieldErrorTexts } from "../api/fieldErrorText";
4
5
  import type { SiteSettings } from "../api/services";
5
6
  import { toast } from "../toast";
6
7
  import { TextInput } from "../ui/TextInput";
7
8
  import { Toggle } from "../ui/Toggle";
8
9
  import { withDefaults, type TextOverrides } from "../ui/withDefaults";
9
- import { defaultCompanySettingsFormStrings, type CompanySettingsFormStrings } from "./companySettingsFormStrings";
10
+ import {
11
+ defaultCompanySettingsFormStrings,
12
+ type CompanySettingsFormStrings,
13
+ type CompanySettingsValidationStrings,
14
+ } from "./companySettingsFormStrings";
10
15
 
11
16
  /** Props for `CompanySettingsForm`. */
12
17
  export interface CompanySettingsFormProps {
13
18
  /** The settings calls, such as `createSettingsService(api)`. */
14
19
  service: { get: () => Promise<SiteSettings>; update: (settings: SiteSettings) => Promise<SiteSettings> };
15
- strings?: TextOverrides<CompanySettingsFormStrings> | undefined;
20
+ /** The form's text. `validation` may name only some codes, the rest keep their defaults. */
21
+ strings?:
22
+ | (TextOverrides<Omit<CompanySettingsFormStrings, "validation">> & {
23
+ validation?: TextOverrides<CompanySettingsValidationStrings> | undefined;
24
+ })
25
+ | undefined;
16
26
  /** The content shown below the form once the settings have loaded, such as the `BrandingManager`. */
17
27
  children?: ReactNode;
18
28
  }
@@ -36,11 +46,15 @@ const EMPTY: SiteSettings = {
36
46
  * number and VAT, the address in parts, and the public email and phone.
37
47
  */
38
48
  export function CompanySettingsForm({ service, strings, children }: CompanySettingsFormProps) {
39
- const text = withDefaults(defaultCompanySettingsFormStrings, strings);
49
+ const { validation: validationOverrides, ...textOverrides } = strings ?? {};
50
+ const text = withDefaults(defaultCompanySettingsFormStrings, textOverrides);
51
+ const validation = withDefaults(defaultCompanySettingsFormStrings.validation, validationOverrides);
40
52
  const [settings, setSettings] = useState<SiteSettings>(EMPTY);
41
53
  const [loading, setLoading] = useState(true);
42
54
  const [saving, setSaving] = useState(false);
43
55
  const [failed, setFailed] = useState(false);
56
+ // The per-field texts from the last failed save, keyed by field name.
57
+ const [fieldErrors, setFieldErrors] = useState<Record<string, string[]>>({});
44
58
  // The latest service, so an inline one does not load the settings again on every render.
45
59
  const serviceRef = useRef(service);
46
60
  useEffect(() => {
@@ -60,15 +74,20 @@ export function CompanySettingsForm({ service, strings, children }: CompanySetti
60
74
  }, [text.loadFailed]);
61
75
 
62
76
  const patch = (changes: Partial<SiteSettings>) => setSettings(prev => ({ ...prev, ...changes }));
77
+ const errorFor = (field: keyof SiteSettings) => fieldErrors[field]?.join(" ");
63
78
 
64
79
  const handleSubmit = async (e: FormEvent) => {
65
80
  e.preventDefault();
66
81
  setSaving(true);
67
82
  try {
68
83
  setSettings(await serviceRef.current.update(settings));
84
+ setFieldErrors({});
69
85
  toast.success(text.saved);
70
86
  } catch (err) {
71
- toast.error(err instanceof Error && err.message ? err.message : text.saveFailed);
87
+ const texts = fieldErrorTexts(err, validation);
88
+ setFieldErrors(texts);
89
+ const first = Object.values(texts)[0]?.[0];
90
+ toast.error(first ?? (err instanceof Error && err.message ? err.message : text.saveFailed));
72
91
  } finally {
73
92
  setSaving(false);
74
93
  }
@@ -83,6 +102,7 @@ export function CompanySettingsForm({ service, strings, children }: CompanySetti
83
102
  <TextInput
84
103
  label={text.contactRecipient}
85
104
  name="contactRecipientEmail"
105
+ error={errorFor("contactRecipientEmail")}
86
106
  type="email"
87
107
  value={settings.contactRecipientEmail}
88
108
  onChange={e => patch({ contactRecipientEmail: e.target.value })}
@@ -90,23 +110,23 @@ export function CompanySettingsForm({ service, strings, children }: CompanySetti
90
110
  />
91
111
  <p className="text-xs text-gray-500">{text.contactRecipientHint}</p>
92
112
 
93
- <TextInput label={text.companyName} name="companyName" value={settings.companyName} onChange={e => patch({ companyName: e.target.value })} required />
94
- <TextInput label={text.legalName} name="companyLegalName" value={settings.companyLegalName} onChange={e => patch({ companyLegalName: e.target.value })} />
113
+ <TextInput label={text.companyName} name="companyName" error={errorFor("companyName")} value={settings.companyName} onChange={e => patch({ companyName: e.target.value })} required />
114
+ <TextInput label={text.legalName} name="companyLegalName" error={errorFor("companyLegalName")} value={settings.companyLegalName} onChange={e => patch({ companyLegalName: e.target.value })} />
95
115
  <p className="text-xs text-gray-500">{text.legalNameHint}</p>
96
116
 
97
- <TextInput label={text.orgNumber} name="orgNumber" value={settings.orgNumber} onChange={e => patch({ orgNumber: e.target.value })} />
117
+ <TextInput label={text.orgNumber} name="orgNumber" error={errorFor("orgNumber")} value={settings.orgNumber} onChange={e => patch({ orgNumber: e.target.value })} />
98
118
  <Toggle label={text.vatRegistered} checked={settings.vatRegistered} onChange={v => patch({ vatRegistered: v })} />
99
119
  <p className="text-xs text-gray-500">{text.vatHint}</p>
100
120
 
101
- <TextInput label={text.streetAddress} name="streetAddress" value={settings.streetAddress} onChange={e => patch({ streetAddress: e.target.value })} required />
121
+ <TextInput label={text.streetAddress} name="streetAddress" error={errorFor("streetAddress")} value={settings.streetAddress} onChange={e => patch({ streetAddress: e.target.value })} required />
102
122
  <div className="grid gap-4 sm:grid-cols-[8rem_1fr]">
103
- <TextInput label={text.postalCode} name="postalCode" inputMode="numeric" value={settings.postalCode} onChange={e => patch({ postalCode: e.target.value })} />
104
- <TextInput label={text.addressLocality} name="addressLocality" value={settings.addressLocality} onChange={e => patch({ addressLocality: e.target.value })} />
123
+ <TextInput label={text.postalCode} name="postalCode" error={errorFor("postalCode")} inputMode="numeric" value={settings.postalCode} onChange={e => patch({ postalCode: e.target.value })} />
124
+ <TextInput label={text.addressLocality} name="addressLocality" error={errorFor("addressLocality")} value={settings.addressLocality} onChange={e => patch({ addressLocality: e.target.value })} />
105
125
  </div>
106
- <TextInput label={text.addressRegion} name="addressRegion" value={settings.addressRegion} onChange={e => patch({ addressRegion: e.target.value })} />
126
+ <TextInput label={text.addressRegion} name="addressRegion" error={errorFor("addressRegion")} value={settings.addressRegion} onChange={e => patch({ addressRegion: e.target.value })} />
107
127
  <p className="text-xs text-gray-500">{text.addressHint}</p>
108
- <TextInput label={text.publicEmail} name="publicEmail" type="email" value={settings.publicEmail} onChange={e => patch({ publicEmail: e.target.value })} required />
109
- <TextInput label={text.publicPhone} name="publicPhone" value={settings.publicPhone} onChange={e => patch({ publicPhone: e.target.value })} required />
128
+ <TextInput label={text.publicEmail} name="publicEmail" error={errorFor("publicEmail")} type="email" value={settings.publicEmail} onChange={e => patch({ publicEmail: e.target.value })} required />
129
+ <TextInput label={text.publicPhone} name="publicPhone" error={errorFor("publicPhone")} value={settings.publicPhone} onChange={e => patch({ publicPhone: e.target.value })} />
110
130
 
111
131
  <button
112
132
  type="submit"
@@ -0,0 +1,76 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef, useState } from "react";
4
+ import type { EmailStats } from "../api/services";
5
+ import { withDefaults, type TextOverrides } from "../ui/withDefaults";
6
+ import { defaultEmailStatsPanelStrings, type EmailStatsPanelStrings } from "./emailStatsPanelStrings";
7
+
8
+ /** Props for `EmailStatsPanel`. */
9
+ export interface EmailStatsPanelProps {
10
+ /** The email stats call, such as `createEmailStatsService(api)`. */
11
+ service: { get: (days?: number) => Promise<EmailStats> };
12
+ /** How many days back the stats go. Defaults to 30. */
13
+ days?: number | undefined;
14
+ strings?: TextOverrides<EmailStatsPanelStrings> | undefined;
15
+ /** The classes that replace the card's border and spacing, such as when the panel sits in the app's own card. */
16
+ className?: string | undefined;
17
+ }
18
+
19
+ type State = { status: "loading" } | { status: "failed" } | { status: "loaded"; stats: EmailStats };
20
+
21
+ /**
22
+ * The admin card for the transactional email of the last days, from SjolystInnovation.AppKit.Email's
23
+ * stats endpoint: sent, delivered, bounces, and blocked or reported as spam. It says the stats are
24
+ * unavailable when they cannot load, without a toast, so the rest of the stats page stays calm.
25
+ */
26
+ export function EmailStatsPanel({ service, days = 30, strings, className = "rounded-2xl border border-admin-border p-5" }: EmailStatsPanelProps) {
27
+ const text = withDefaults(defaultEmailStatsPanelStrings, strings);
28
+ const [state, setState] = useState<State>({ status: "loading" });
29
+ // The latest service, so an inline one does not load the stats again on every render.
30
+ const serviceRef = useRef(service);
31
+ useEffect(() => {
32
+ serviceRef.current = service;
33
+ }, [service]);
34
+
35
+ // An answer for an earlier `days` is ignored, so a slow one cannot replace the newer stats.
36
+ useEffect(() => {
37
+ let replaced = false;
38
+ serviceRef.current
39
+ .get(days)
40
+ .then((stats) => {
41
+ if (!replaced) setState({ status: "loaded", stats });
42
+ })
43
+ .catch(() => {
44
+ if (!replaced) setState({ status: "failed" });
45
+ });
46
+ return () => {
47
+ replaced = true;
48
+ };
49
+ }, [days]);
50
+
51
+ const shownDays = state.status === "loaded" ? state.stats.days : days;
52
+ const rows = (stats: EmailStats) => [
53
+ { label: text.sent, value: stats.requests },
54
+ { label: text.delivered, value: stats.delivered },
55
+ { label: text.bounces, value: stats.hardBounces + stats.softBounces },
56
+ { label: text.blocked, value: stats.blocked + stats.spamReports },
57
+ ];
58
+
59
+ return (
60
+ <section className={className}>
61
+ <h2 className="font-bold text-admin-title mb-3">{text.title.replaceAll("{days}", String(shownDays))}</h2>
62
+ {state.status === "loaded" ? (
63
+ <dl className="space-y-1.5 text-sm">
64
+ {rows(state.stats).map((row) => (
65
+ <div key={row.label} className="flex justify-between">
66
+ <dt className="text-admin-stat-label">{row.label}</dt>
67
+ <dd className="tabular-nums text-admin-stat-value">{row.value}</dd>
68
+ </div>
69
+ ))}
70
+ </dl>
71
+ ) : (
72
+ <p className="text-sm text-admin-stat-label">{state.status === "failed" ? text.unavailable : text.loading}</p>
73
+ )}
74
+ </section>
75
+ );
76
+ }
@@ -1,29 +1,39 @@
1
1
  "use client";
2
2
 
3
3
  import { useEffect, useRef, useState } from "react";
4
- import type { LegalPage } from "../api/services";
4
+ import { fieldErrorTexts } from "../api/fieldErrorText";
5
+ import type { LegalPage } from "../api/legalPage";
6
+ import { LEGAL_PAGE_KEYS } from "../legal/keys";
5
7
  import { toast } from "../toast";
8
+ import { FieldError } from "../ui/field";
6
9
  import { TextInput } from "../ui/TextInput";
7
10
  import { withDefaults, type TextOverrides } from "../ui/withDefaults";
8
- import { defaultLegalPagesEditorStrings, type LegalPagesEditorStrings } from "./legalPagesEditorStrings";
11
+ import { defaultLegalPageKeyLabels, defaultLegalPagesEditorStrings, type LegalPagesEditorStrings, type LegalPagesValidationStrings } from "./legalPagesEditorStrings";
9
12
  import { LocaleTabs } from "./LocaleTabs";
10
13
 
11
14
  /** Props for `LegalPagesEditor`. */
12
15
  export interface LegalPagesEditorProps<K extends string, L extends string> {
13
16
  /** The legal page calls, such as `createLegalService(api, publicApi)`. */
14
17
  service: {
18
+ /** The app's pages, as `createLegalService` gives them. */
19
+ keys?: readonly K[] | undefined;
15
20
  listAll: () => Promise<LegalPage<K>[]>;
16
21
  save: (page: { key: K; locale: L; title: string; bodyMarkdown: string }) => Promise<unknown>;
17
22
  };
18
- /** The pages, in tab order. The editor opens on the first. */
19
- keys: readonly K[];
20
- /** Each page's tab label, such as `Terms`. */
21
- keyLabels: Record<K, string>;
23
+ /** The pages, in tab order. The editor opens on the first. Defaults to the service's `keys`, else `LEGAL_PAGE_KEYS`. */
24
+ keys?: readonly K[] | undefined;
25
+ /** Each page's tab label, such as `Terms`. A page left out uses `defaultLegalPageKeyLabels`, else its key. */
26
+ keyLabels?: Partial<Record<K, string>> | undefined;
22
27
  /** The locales each page is written in. The editor opens on the first. */
23
28
  locales: readonly L[];
24
29
  /** Each locale's tab label, such as `Norsk`. */
25
30
  localeLabels: Record<L, string>;
26
- strings?: TextOverrides<LegalPagesEditorStrings> | undefined;
31
+ /** The editor's text. `validation` may name only some codes, the rest keep their defaults. */
32
+ strings?:
33
+ | (TextOverrides<Omit<LegalPagesEditorStrings, "validation">> & {
34
+ validation?: TextOverrides<LegalPagesValidationStrings> | undefined;
35
+ })
36
+ | undefined;
27
37
  }
28
38
 
29
39
  type Draft = { title: string; bodyMarkdown: string };
@@ -31,14 +41,20 @@ type Draft = { title: string; bodyMarkdown: string };
31
41
  const emptyDraft: Draft = { title: "", bodyMarkdown: "" };
32
42
 
33
43
  /** The admin editor for the legal pages: one page and one locale at a time, with the title and the Markdown body. */
34
- export function LegalPagesEditor<K extends string, L extends string>({ service, keys, keyLabels, locales, localeLabels, strings }: LegalPagesEditorProps<K, L>) {
35
- const text = withDefaults(defaultLegalPagesEditorStrings, strings);
44
+ export function LegalPagesEditor<K extends string, L extends string>({ service, keys: keysProp, keyLabels, locales, localeLabels, strings }: LegalPagesEditorProps<K, L>) {
45
+ const keys = keysProp ?? service.keys ?? (LEGAL_PAGE_KEYS as readonly string[] as readonly K[]);
46
+ const labelOf = (key: K) => keyLabels?.[key] ?? (defaultLegalPageKeyLabels as Record<string, string | undefined>)[key] ?? key;
47
+ const { validation: validationOverrides, ...textOverrides } = strings ?? {};
48
+ const text = withDefaults(defaultLegalPagesEditorStrings, textOverrides);
49
+ const validation = withDefaults(defaultLegalPagesEditorStrings.validation, validationOverrides);
36
50
  const [drafts, setDrafts] = useState<Record<string, Draft>>({});
37
51
  const [activeKey, setActiveKey] = useState<K | undefined>(keys[0]);
38
52
  const [activeLocale, setActiveLocale] = useState<L | undefined>(locales[0]);
39
53
  const [loading, setLoading] = useState(true);
40
54
  const [saving, setSaving] = useState(false);
41
55
  const [failed, setFailed] = useState(false);
56
+ // The per-field texts from the last failed save, with the page and locale they belong to.
57
+ const [fieldErrors, setFieldErrors] = useState<{ slot: string; fields: Record<string, string[]> } | null>(null);
42
58
  // The latest service, so an inline one does not load the pages again on every render.
43
59
  const serviceRef = useRef(service);
44
60
  useEffect(() => {
@@ -61,20 +77,28 @@ export function LegalPagesEditor<K extends string, L extends string>({ service,
61
77
 
62
78
  const slot = `${activeKey}:${activeLocale}`;
63
79
  const draft = drafts[slot] ?? emptyDraft;
80
+ const errorFor = (field: keyof Draft) => (fieldErrors?.slot === slot ? fieldErrors.fields[field]?.join(" ") : undefined);
81
+ const bodyError = errorFor("bodyMarkdown");
64
82
 
65
83
  const patch = (changes: Partial<Draft>) => setDrafts(prev => ({ ...prev, [slot]: { ...(prev[slot] ?? emptyDraft), ...changes } }));
66
84
 
67
85
  const save = async () => {
68
- if (draft.title.trim() === "" || draft.bodyMarkdown.trim() === "") {
86
+ const blank = (Object.keys(emptyDraft) as (keyof Draft)[]).filter(field => draft[field].trim() === "");
87
+ if (blank.length > 0) {
88
+ setFieldErrors({ slot, fields: Object.fromEntries(blank.map(field => [field, [validation.required]])) });
69
89
  toast.error(text.required);
70
90
  return;
71
91
  }
72
92
  setSaving(true);
73
93
  try {
74
94
  await serviceRef.current.save({ key: activeKey, locale: activeLocale, title: draft.title.trim(), bodyMarkdown: draft.bodyMarkdown });
95
+ setFieldErrors(null);
75
96
  toast.success(text.saved);
76
97
  } catch (err) {
77
- toast.error(err instanceof Error && err.message ? err.message : text.saveFailed);
98
+ const fields = fieldErrorTexts(err, validation);
99
+ setFieldErrors({ slot, fields });
100
+ const first = Object.values(fields)[0]?.[0];
101
+ toast.error(first ?? (err instanceof Error && err.message ? err.message : text.saveFailed));
78
102
  } finally {
79
103
  setSaving(false);
80
104
  }
@@ -106,14 +130,14 @@ export function LegalPagesEditor<K extends string, L extends string>({ service,
106
130
  aria-pressed={key === activeKey}
107
131
  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"}`}
108
132
  >
109
- {keyLabels[key]}
133
+ {labelOf(key)}
110
134
  </button>
111
135
  ))}
112
136
  </div>
113
137
 
114
138
  <LocaleTabs locales={locales} labels={localeLabels} active={activeLocale} onChange={setActiveLocale} filled={filled} notFilled={text.notFilled} />
115
139
 
116
- <TextInput label={text.title} value={draft.title} onChange={e => patch({ title: e.target.value })} />
140
+ <TextInput label={text.title} error={errorFor("title")} value={draft.title} onChange={e => patch({ title: e.target.value })} />
117
141
 
118
142
  <div>
119
143
  <label htmlFor="legal-body" className="block text-sm font-medium text-gray-700 mb-1">
@@ -124,8 +148,11 @@ export function LegalPagesEditor<K extends string, L extends string>({ service,
124
148
  rows={24}
125
149
  value={draft.bodyMarkdown}
126
150
  onChange={e => patch({ bodyMarkdown: e.target.value })}
127
- 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"
151
+ aria-invalid={bodyError ? true : undefined}
152
+ aria-describedby={bodyError ? "legal-body-error" : undefined}
153
+ className={`w-full rounded-lg border ${bodyError ? "border-input-border-error" : "border-gray-300"} px-3 py-2 font-mono text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-primary`}
128
154
  />
155
+ <FieldError id="legal-body-error" error={bodyError} />
129
156
  </div>
130
157
  </div>
131
158
  );