@sjolystinnovation/app-kit 0.21.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 (99) hide show
  1. package/README.md +1287 -860
  2. package/package.json +21 -4
  3. package/src/admin/AdminShell.tsx +2 -1
  4. package/src/admin/AdminUsersPanel.tsx +93 -37
  5. package/src/admin/CompanySettingsForm.tsx +35 -14
  6. package/src/admin/EmailStatsPanel.tsx +76 -0
  7. package/src/admin/LegalPagesEditor.tsx +45 -17
  8. package/src/admin/LocaleTabs.tsx +4 -3
  9. package/src/admin/StatHistoryChart.tsx +4 -3
  10. package/src/admin/adminUsersPanelStrings.ts +70 -3
  11. package/src/admin/companySettingsFormStrings.ts +26 -0
  12. package/src/admin/emailStatsPanelStrings.ts +29 -0
  13. package/src/admin/index.ts +14 -5
  14. package/src/admin/legalPagesEditorStrings.ts +26 -1
  15. package/src/admin/statHistoryChartStrings.ts +3 -2
  16. package/src/api/apiErrorCode.ts +28 -0
  17. package/src/api/apiFieldErrors.ts +84 -13
  18. package/src/api/company.ts +4 -3
  19. package/src/api/createApiClient.ts +3 -2
  20. package/src/api/failureBody.ts +10 -0
  21. package/src/api/fieldErrorText.ts +32 -0
  22. package/src/{errors.ts → api/formatApiError.ts} +12 -12
  23. package/src/api/index.ts +7 -2
  24. package/src/api/legalPage.ts +10 -0
  25. package/src/api/request.ts +14 -4
  26. package/src/api/revalidateTargets.ts +7 -5
  27. package/src/api/services.ts +53 -22
  28. package/src/auth/createAuthOptions.ts +16 -8
  29. package/src/branding/BrandingManager.tsx +37 -37
  30. package/src/branding/BrandingProvider.tsx +5 -3
  31. package/src/branding/brandingManagerStrings.ts +49 -0
  32. package/src/branding/index.ts +4 -5
  33. package/src/branding/server.ts +10 -9
  34. package/src/branding/types.ts +3 -3
  35. package/src/config.ts +10 -10
  36. package/src/forms/useFormDraft.ts +9 -7
  37. package/src/index.ts +1 -3
  38. package/src/legal/LegalPageView.tsx +5 -19
  39. package/src/legal/index.ts +4 -1
  40. package/src/legal/keys.ts +10 -0
  41. package/src/legal/legalPageViewStrings.ts +17 -0
  42. package/src/locales/nb.ts +330 -0
  43. package/src/markdown/Markdown.tsx +12 -10
  44. package/src/markdown/index.ts +1 -1
  45. package/src/next-auth.d.ts +11 -9
  46. package/src/next-config/headers.ts +96 -0
  47. package/src/next-config/index.ts +18 -107
  48. package/src/payments/PaymentReturn.tsx +9 -7
  49. package/src/payments/formatNok.ts +1 -1
  50. package/src/payments/usePollUntilFinal.ts +7 -5
  51. package/src/sections/SectionRenderer.tsx +275 -0
  52. package/src/sections/SectionsEditor.tsx +151 -0
  53. package/src/sections/SmartLink.tsx +42 -0
  54. package/src/sections/editors.tsx +311 -0
  55. package/src/sections/factory.ts +26 -0
  56. package/src/sections/index.ts +69 -0
  57. package/src/sections/sectionsEditorStrings.ts +108 -0
  58. package/src/sections/types.ts +107 -0
  59. package/src/seo/JsonLd.tsx +1 -1
  60. package/src/seo/environment.ts +3 -3
  61. package/src/seo/index.ts +1 -1
  62. package/src/seo/og/index.tsx +5 -4
  63. package/src/seo/schema.ts +6 -2
  64. package/src/seo/site.ts +49 -37
  65. package/src/seo/sitemap.ts +2 -2
  66. package/src/server/publicApi.ts +13 -9
  67. package/src/server/revalidateRoute.ts +10 -8
  68. package/src/session/expiry.ts +11 -6
  69. package/src/session/index.ts +0 -1
  70. package/src/session/react/AppSessionProvider.tsx +3 -2
  71. package/src/{ui → session/react}/CredentialsForm.tsx +17 -10
  72. package/src/{ui → session/react}/LoginPage.tsx +7 -6
  73. package/src/session/react/ProtectedGate.tsx +3 -2
  74. package/src/session/react/RoleGate.tsx +4 -3
  75. package/src/session/react/SessionExpiryGuard.tsx +5 -4
  76. package/src/{ui → session/react}/credentialsFormStrings.ts +4 -1
  77. package/src/session/react/index.ts +8 -2
  78. package/src/{ui → session/react}/loginPageStrings.ts +1 -1
  79. package/src/session/react/sessionExpiryGuardStrings.ts +1 -1
  80. package/src/session/react/useSessionGate.ts +3 -3
  81. package/src/styles.css +2 -0
  82. package/src/testing/index.ts +5 -13
  83. package/src/ui/Alert.tsx +6 -4
  84. package/src/ui/ContentImage.tsx +12 -11
  85. package/src/ui/ErrorState.tsx +5 -17
  86. package/src/ui/NotFoundState.tsx +7 -17
  87. package/src/ui/PasswordInput.tsx +6 -5
  88. package/src/ui/ResetPasswordFlow.tsx +64 -19
  89. package/src/ui/TextArea.tsx +6 -4
  90. package/src/ui/TextInput.tsx +4 -2
  91. package/src/ui/Toggle.tsx +3 -3
  92. package/src/ui/errorStateStrings.ts +16 -0
  93. package/src/ui/field.tsx +5 -3
  94. package/src/ui/index.ts +10 -14
  95. package/src/ui/notFoundStateStrings.ts +14 -0
  96. package/src/ui/resetPasswordFlowStrings.ts +51 -7
  97. package/src/validation/fieldErrors.ts +4 -3
  98. package/src/validation/password.ts +5 -5
  99. package/src/validation/passwordRules.ts +2 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjolystinnovation/app-kit",
3
- "version": "0.21.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",
@@ -13,6 +13,7 @@ export interface AdminTab {
13
13
 
14
14
  type GateProps = RoleGateProps extends infer P ? (P extends unknown ? Omit<P, "children"> : never) : never;
15
15
 
16
+ /** Props for `AdminShell`. The gate props are `RoleGate`'s, without `children`. */
16
17
  export type AdminShellProps = GateProps & {
17
18
  title: ReactNode;
18
19
  tabs: readonly AdminTab[];
@@ -48,6 +49,6 @@ export function AdminShell({ title, tabs, children, ...gate }: AdminShellProps)
48
49
  {children}
49
50
  </div>
50
51
  );
51
- // The gate props came in as one of RoleGate's two shapes, so they go back as that shape.
52
+ // The gate props arrive as one of RoleGate's two shapes, so they go back as that shape.
52
53
  return <RoleGate {...({ ...gate, children: page } as RoleGateProps)} />;
53
54
  }
@@ -1,23 +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
- 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. */
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 = {
27
+ /** The role a new invite starts with. Defaults to `Admin`. */
18
28
  defaultInviteRole?: string | undefined;
19
- strings?: TextOverrides<AdminUsersPanelStrings> | undefined;
20
- }
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"];
21
52
 
22
53
  const fill = (text: string, values: Record<string, string>) =>
23
54
  text.replace(/\{(\w+)\}/g, (match, key: string) => values[key] ?? match);
@@ -26,10 +57,16 @@ const reason = (err: unknown, fallback: string) => (err instanceof Error && err.
26
57
 
27
58
  /**
28
59
  * 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.
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`.
30
62
  */
31
63
  export function AdminUsersPanel({ service, getRoles, defaultInviteRole = "Admin", strings }: AdminUsersPanelProps) {
32
- 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`;
33
70
  const [users, setUsers] = useState<AdminUser[]>([]);
34
71
  const [allRoles, setAllRoles] = useState<string[]>([]);
35
72
  const [includeDeleted, setIncludeDeleted] = useState(false);
@@ -39,7 +76,9 @@ export function AdminUsersPanel({ service, getRoles, defaultInviteRole = "Admin"
39
76
  const [roleLoading, setRoleLoading] = useState(false);
40
77
  const [invite, setInvite] = useState({ email: "", firstName: "", lastName: "", role: defaultInviteRole });
41
78
  const [inviting, setInviting] = useState(false);
42
- // Bumped to fetch the list again after a change, through the same effect as every other load.
79
+ // The per-field texts from the last failed invite, keyed by field name.
80
+ const [inviteErrors, setInviteErrors] = useState<Record<string, string[]>>({});
81
+ // Bumping it fetches the list again after a change, through the same effect as every other load.
43
82
  const [reloads, setReloads] = useState(0);
44
83
  // The latest service, so an inline one does not fetch the list again on every render. The roles
45
84
  // load once, so the first getRoles is enough.
@@ -71,8 +110,9 @@ export function AdminUsersPanel({ service, getRoles, defaultInviteRole = "Admin"
71
110
  }, [includeDeleted, reloads, text.usersLoadFailed]);
72
111
 
73
112
  useEffect(() => {
74
- getRolesRef
75
- .current()
113
+ // Without getRoles the props carry a service with roles.
114
+ const roles = getRolesRef.current ?? (() => (serviceRef.current as AdminUsersService).roles());
115
+ roles()
76
116
  .then(setAllRoles)
77
117
  .catch(() => toast.error(text.rolesLoadFailed));
78
118
  }, [text.rolesLoadFailed]);
@@ -99,7 +139,7 @@ export function AdminUsersPanel({ service, getRoles, defaultInviteRole = "Admin"
99
139
  setAddRoleFor(null);
100
140
  setSelectedRole("");
101
141
  } catch (err) {
102
- toast.error(reason(err, text.roleAssignFailed));
142
+ toast.error(errorText(err, errors, text.roleAssignFailed));
103
143
  } finally {
104
144
  setRoleLoading(false);
105
145
  }
@@ -111,7 +151,7 @@ export function AdminUsersPanel({ service, getRoles, defaultInviteRole = "Admin"
111
151
  setRoles(user.id, user.roles.filter((r) => r !== role));
112
152
  toast.success(fill(text.roleRemoved, { name: user.firstName, role }));
113
153
  } catch (err) {
114
- toast.error(reason(err, text.roleRemoveFailed));
154
+ toast.error(errorText(err, errors, text.roleRemoveFailed));
115
155
  }
116
156
  };
117
157
 
@@ -123,7 +163,7 @@ export function AdminUsersPanel({ service, getRoles, defaultInviteRole = "Admin"
123
163
  toast.success(text.userDeleted);
124
164
  reload();
125
165
  } catch (err) {
126
- toast.error(reason(err, text.userDeleteFailed));
166
+ toast.error(errorText(err, deleteErrors, text.userDeleteFailed));
127
167
  }
128
168
  };
129
169
 
@@ -134,12 +174,23 @@ export function AdminUsersPanel({ service, getRoles, defaultInviteRole = "Admin"
134
174
  }
135
175
  setInviting(true);
136
176
  try {
137
- await serviceRef.current.invite(invite.email.trim(), invite.firstName.trim(), invite.lastName.trim(), invite.role);
177
+ await serviceRef.current.invite({
178
+ email: invite.email.trim(),
179
+ firstName: invite.firstName.trim(),
180
+ lastName: invite.lastName.trim(),
181
+ role: invite.role,
182
+ });
183
+ setInviteErrors({});
138
184
  toast.success(text.inviteSent);
139
185
  setInvite({ email: "", firstName: "", lastName: "", role: invite.role });
140
186
  reload();
141
187
  } catch (err) {
142
- 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));
143
194
  } finally {
144
195
  setInviting(false);
145
196
  }
@@ -156,28 +207,33 @@ export function AdminUsersPanel({ service, getRoles, defaultInviteRole = "Admin"
156
207
  <h3 className="text-sm font-semibold text-gray-900">{text.inviteUser}</h3>
157
208
  <div className="flex flex-wrap items-end gap-2">
158
209
  <div className="w-40">
159
- <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 })} />
160
211
  </div>
161
212
  <div className="w-40">
162
- <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 })} />
163
214
  </div>
164
215
  <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 })} />
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(" ")} />
166
236
  </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
237
  <button
182
238
  onClick={sendInvite}
183
239
  disabled={inviting}
@@ -1,18 +1,29 @@
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
 
16
+ /** Props for `CompanySettingsForm`. */
11
17
  export interface CompanySettingsFormProps {
12
18
  /** The settings calls, such as `createSettingsService(api)`. */
13
19
  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`. */
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;
26
+ /** The content shown below the form once the settings have loaded, such as the `BrandingManager`. */
16
27
  children?: ReactNode;
17
28
  }
18
29
 
@@ -35,11 +46,15 @@ const EMPTY: SiteSettings = {
35
46
  * number and VAT, the address in parts, and the public email and phone.
36
47
  */
37
48
  export function CompanySettingsForm({ service, strings, children }: CompanySettingsFormProps) {
38
- const text = withDefaults(defaultCompanySettingsFormStrings, strings);
49
+ const { validation: validationOverrides, ...textOverrides } = strings ?? {};
50
+ const text = withDefaults(defaultCompanySettingsFormStrings, textOverrides);
51
+ const validation = withDefaults(defaultCompanySettingsFormStrings.validation, validationOverrides);
39
52
  const [settings, setSettings] = useState<SiteSettings>(EMPTY);
40
53
  const [loading, setLoading] = useState(true);
41
54
  const [saving, setSaving] = useState(false);
42
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[]>>({});
43
58
  // The latest service, so an inline one does not load the settings again on every render.
44
59
  const serviceRef = useRef(service);
45
60
  useEffect(() => {
@@ -59,15 +74,20 @@ export function CompanySettingsForm({ service, strings, children }: CompanySetti
59
74
  }, [text.loadFailed]);
60
75
 
61
76
  const patch = (changes: Partial<SiteSettings>) => setSettings(prev => ({ ...prev, ...changes }));
77
+ const errorFor = (field: keyof SiteSettings) => fieldErrors[field]?.join(" ");
62
78
 
63
79
  const handleSubmit = async (e: FormEvent) => {
64
80
  e.preventDefault();
65
81
  setSaving(true);
66
82
  try {
67
83
  setSettings(await serviceRef.current.update(settings));
84
+ setFieldErrors({});
68
85
  toast.success(text.saved);
69
86
  } catch (err) {
70
- 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));
71
91
  } finally {
72
92
  setSaving(false);
73
93
  }
@@ -82,6 +102,7 @@ export function CompanySettingsForm({ service, strings, children }: CompanySetti
82
102
  <TextInput
83
103
  label={text.contactRecipient}
84
104
  name="contactRecipientEmail"
105
+ error={errorFor("contactRecipientEmail")}
85
106
  type="email"
86
107
  value={settings.contactRecipientEmail}
87
108
  onChange={e => patch({ contactRecipientEmail: e.target.value })}
@@ -89,23 +110,23 @@ export function CompanySettingsForm({ service, strings, children }: CompanySetti
89
110
  />
90
111
  <p className="text-xs text-gray-500">{text.contactRecipientHint}</p>
91
112
 
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 })} />
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 })} />
94
115
  <p className="text-xs text-gray-500">{text.legalNameHint}</p>
95
116
 
96
- <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 })} />
97
118
  <Toggle label={text.vatRegistered} checked={settings.vatRegistered} onChange={v => patch({ vatRegistered: v })} />
98
119
  <p className="text-xs text-gray-500">{text.vatHint}</p>
99
120
 
100
- <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 />
101
122
  <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 })} />
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 })} />
104
125
  </div>
105
- <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 })} />
106
127
  <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 />
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 })} />
109
130
 
110
131
  <button
111
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,28 +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
 
14
+ /** Props for `LegalPagesEditor`. */
11
15
  export interface LegalPagesEditorProps<K extends string, L extends string> {
12
16
  /** The legal page calls, such as `createLegalService(api, publicApi)`. */
13
17
  service: {
18
+ /** The app's pages, as `createLegalService` gives them. */
19
+ keys?: readonly K[] | undefined;
14
20
  listAll: () => Promise<LegalPage<K>[]>;
15
- save: (key: K, locale: L, title: string, bodyMarkdown: string) => Promise<unknown>;
21
+ save: (page: { key: K; locale: L; title: string; bodyMarkdown: string }) => Promise<unknown>;
16
22
  };
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. */
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;
27
+ /** The locales each page is written in. The editor opens on the first. */
22
28
  locales: readonly L[];
23
29
  /** Each locale's tab label, such as `Norsk`. */
24
30
  localeLabels: Record<L, string>;
25
- 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;
26
37
  }
27
38
 
28
39
  type Draft = { title: string; bodyMarkdown: string };
@@ -30,14 +41,20 @@ type Draft = { title: string; bodyMarkdown: string };
30
41
  const emptyDraft: Draft = { title: "", bodyMarkdown: "" };
31
42
 
32
43
  /** 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);
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);
35
50
  const [drafts, setDrafts] = useState<Record<string, Draft>>({});
36
51
  const [activeKey, setActiveKey] = useState<K | undefined>(keys[0]);
37
52
  const [activeLocale, setActiveLocale] = useState<L | undefined>(locales[0]);
38
53
  const [loading, setLoading] = useState(true);
39
54
  const [saving, setSaving] = useState(false);
40
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);
41
58
  // The latest service, so an inline one does not load the pages again on every render.
42
59
  const serviceRef = useRef(service);
43
60
  useEffect(() => {
@@ -60,20 +77,28 @@ export function LegalPagesEditor<K extends string, L extends string>({ service,
60
77
 
61
78
  const slot = `${activeKey}:${activeLocale}`;
62
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");
63
82
 
64
83
  const patch = (changes: Partial<Draft>) => setDrafts(prev => ({ ...prev, [slot]: { ...(prev[slot] ?? emptyDraft), ...changes } }));
65
84
 
66
85
  const save = async () => {
67
- 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]])) });
68
89
  toast.error(text.required);
69
90
  return;
70
91
  }
71
92
  setSaving(true);
72
93
  try {
73
- await serviceRef.current.save(activeKey, activeLocale, draft.title.trim(), draft.bodyMarkdown);
94
+ await serviceRef.current.save({ key: activeKey, locale: activeLocale, title: draft.title.trim(), bodyMarkdown: draft.bodyMarkdown });
95
+ setFieldErrors(null);
74
96
  toast.success(text.saved);
75
97
  } catch (err) {
76
- 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));
77
102
  } finally {
78
103
  setSaving(false);
79
104
  }
@@ -105,14 +130,14 @@ export function LegalPagesEditor<K extends string, L extends string>({ service,
105
130
  aria-pressed={key === activeKey}
106
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"}`}
107
132
  >
108
- {keyLabels[key]}
133
+ {labelOf(key)}
109
134
  </button>
110
135
  ))}
111
136
  </div>
112
137
 
113
138
  <LocaleTabs locales={locales} labels={localeLabels} active={activeLocale} onChange={setActiveLocale} filled={filled} notFilled={text.notFilled} />
114
139
 
115
- <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 })} />
116
141
 
117
142
  <div>
118
143
  <label htmlFor="legal-body" className="block text-sm font-medium text-gray-700 mb-1">
@@ -123,8 +148,11 @@ export function LegalPagesEditor<K extends string, L extends string>({ service,
123
148
  rows={24}
124
149
  value={draft.bodyMarkdown}
125
150
  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"
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`}
127
154
  />
155
+ <FieldError id="legal-body-error" error={bodyError} />
128
156
  </div>
129
157
  </div>
130
158
  );
@@ -1,18 +1,19 @@
1
1
  "use client";
2
2
 
3
+ /** Props for `LocaleTabs`. */
3
4
  export interface LocaleTabsProps<L extends string> {
4
5
  locales: readonly L[];
5
6
  /** Each locale's tab label, such as `Norsk`. */
6
7
  labels: Record<L, string>;
7
8
  active: L;
8
9
  onChange: (locale: L) => void;
9
- /** Which locales have content. A locale marked false gets a dot titled `notFilled`. */
10
+ /** The locales that have content. A locale set to `false` gets a dot titled `notFilled`. */
10
11
  filled?: Partial<Record<L, boolean>> | undefined;
11
- /** The dot's title, such as "Not filled in". */
12
+ /** The dot's title. Defaults to "Not filled in". */
12
13
  notFilled?: string | undefined;
13
14
  }
14
15
 
15
- /** Tabs for editing one translation at a time. */
16
+ /** Renders tabs for editing one translation at a time. */
16
17
  export function LocaleTabs<L extends string>({ locales, labels, active, onChange, filled, notFilled = "Not filled in" }: LocaleTabsProps<L>) {
17
18
  return (
18
19
  <div className="flex items-center gap-1 border-b border-gray-200">