@sjolystinnovation/app-kit 0.20.0 → 0.22.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 (78) hide show
  1. package/README.md +315 -253
  2. package/package.json +13 -1
  3. package/src/admin/AdminShell.tsx +2 -1
  4. package/src/admin/AdminUsersPanel.tsx +9 -3
  5. package/src/admin/CompanySettingsForm.tsx +123 -0
  6. package/src/admin/LegalPagesEditor.tsx +132 -0
  7. package/src/admin/LocaleTabs.tsx +36 -0
  8. package/src/admin/StatHistoryChart.tsx +100 -0
  9. package/src/admin/adminUsersPanelStrings.ts +3 -3
  10. package/src/admin/companySettingsFormStrings.ts +50 -0
  11. package/src/admin/index.ts +14 -0
  12. package/src/admin/legalPagesEditorStrings.ts +32 -0
  13. package/src/admin/statHistoryChartStrings.ts +17 -0
  14. package/src/api/apiFieldErrors.ts +4 -4
  15. package/src/api/company.ts +47 -0
  16. package/src/api/createApiClient.ts +3 -2
  17. package/src/{errors.ts → api/formatApiError.ts} +12 -12
  18. package/src/api/index.ts +6 -1
  19. package/src/api/request.ts +2 -2
  20. package/src/api/revalidateTargets.ts +42 -0
  21. package/src/api/services.ts +14 -13
  22. package/src/auth/createAuthOptions.ts +7 -5
  23. package/src/branding/BrandingManager.tsx +10 -5
  24. package/src/branding/BrandingProvider.tsx +5 -3
  25. package/src/branding/index.ts +1 -2
  26. package/src/branding/server.ts +10 -9
  27. package/src/branding/types.ts +3 -3
  28. package/src/config.ts +10 -10
  29. package/src/forms/useFormDraft.ts +9 -7
  30. package/src/index.ts +1 -3
  31. package/src/legal/LegalPageView.tsx +62 -0
  32. package/src/legal/index.ts +1 -0
  33. package/src/markdown/Markdown.tsx +12 -10
  34. package/src/markdown/index.ts +1 -1
  35. package/src/next-auth.d.ts +11 -9
  36. package/src/next-config/headers.ts +96 -0
  37. package/src/next-config/index.ts +18 -107
  38. package/src/payments/PaymentReturn.tsx +9 -7
  39. package/src/payments/formatNok.ts +1 -1
  40. package/src/payments/usePollUntilFinal.ts +7 -5
  41. package/src/seo/JsonLd.tsx +1 -1
  42. package/src/seo/environment.ts +3 -3
  43. package/src/seo/index.ts +2 -1
  44. package/src/seo/og/index.tsx +5 -4
  45. package/src/seo/schema.ts +6 -2
  46. package/src/seo/site.ts +90 -30
  47. package/src/seo/sitemap.ts +2 -2
  48. package/src/server/publicApi.ts +13 -9
  49. package/src/server/revalidateRoute.ts +10 -8
  50. package/src/session/expiry.ts +8 -5
  51. package/src/session/react/AppSessionProvider.tsx +3 -2
  52. package/src/{ui → session/react}/CredentialsForm.tsx +10 -9
  53. package/src/{ui → session/react}/LoginPage.tsx +7 -6
  54. package/src/session/react/ProtectedGate.tsx +3 -2
  55. package/src/session/react/RoleGate.tsx +4 -3
  56. package/src/session/react/SessionExpiryGuard.tsx +5 -4
  57. package/src/{ui → session/react}/credentialsFormStrings.ts +1 -1
  58. package/src/session/react/index.ts +8 -2
  59. package/src/{ui → session/react}/loginPageStrings.ts +1 -1
  60. package/src/session/react/sessionExpiryGuardStrings.ts +1 -1
  61. package/src/session/react/useSessionGate.ts +3 -3
  62. package/src/styles.css +2 -0
  63. package/src/testing/index.ts +113 -0
  64. package/src/ui/Alert.tsx +6 -4
  65. package/src/ui/ContentImage.tsx +12 -11
  66. package/src/ui/ErrorState.tsx +5 -3
  67. package/src/ui/NotFoundState.tsx +9 -7
  68. package/src/ui/PasswordInput.tsx +6 -5
  69. package/src/ui/ResetPasswordFlow.tsx +10 -8
  70. package/src/ui/TextArea.tsx +6 -4
  71. package/src/ui/TextInput.tsx +4 -2
  72. package/src/ui/Toggle.tsx +3 -3
  73. package/src/ui/field.tsx +5 -3
  74. package/src/ui/index.ts +2 -10
  75. package/src/ui/resetPasswordFlowStrings.ts +7 -7
  76. package/src/validation/fieldErrors.ts +4 -3
  77. package/src/validation/password.ts +5 -5
  78. 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.20.0",
3
+ "version": "0.22.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",
@@ -31,11 +31,13 @@
31
31
  "./validation": "./src/validation/index.ts",
32
32
  "./next-config": "./src/next-config/index.ts",
33
33
  "./markdown": "./src/markdown/index.ts",
34
+ "./legal": "./src/legal/index.ts",
34
35
  "./seo": "./src/seo/index.ts",
35
36
  "./seo/og": "./src/seo/og/index.tsx",
36
37
  "./branding": "./src/branding/index.ts",
37
38
  "./branding/server": "./src/branding/server.ts",
38
39
  "./payments": "./src/payments/index.ts",
40
+ "./testing": "./src/testing/index.ts",
39
41
  "./styles.css": "./src/styles.css"
40
42
  },
41
43
  "files": [
@@ -60,12 +62,22 @@
60
62
  "rehype-sanitize": "^6",
61
63
  "remark-gfm": "^4",
62
64
  "sonner": "^2",
65
+ "vitest": "^5",
63
66
  "zod": "^4"
64
67
  },
65
68
  "peerDependenciesMeta": {
69
+ "axios": {
70
+ "optional": true
71
+ },
72
+ "next-auth": {
73
+ "optional": true
74
+ },
66
75
  "sonner": {
67
76
  "optional": true
68
77
  },
78
+ "vitest": {
79
+ "optional": true
80
+ },
69
81
  "zod": {
70
82
  "optional": true
71
83
  },
@@ -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
  }
@@ -9,12 +9,13 @@ import { Toggle } from "../ui/Toggle";
9
9
  import { withDefaults, type TextOverrides } from "../ui/withDefaults";
10
10
  import { defaultAdminUsersPanelStrings, type AdminUsersPanelStrings } from "./adminUsersPanelStrings";
11
11
 
12
+ /** Props for `AdminUsersPanel`. */
12
13
  export interface AdminUsersPanelProps {
13
14
  /** The user admin calls, such as `createAdminUsersService(api)`. */
14
15
  service: ReturnType<typeof createAdminUsersService>;
15
16
  /** The roles an admin may give, from the app's own endpoint. */
16
17
  getRoles: () => Promise<string[]>;
17
- /** The role a new invite starts with. `Admin` by default. */
18
+ /** The role a new invite starts with. Defaults to `Admin`. */
18
19
  defaultInviteRole?: string | undefined;
19
20
  strings?: TextOverrides<AdminUsersPanelStrings> | undefined;
20
21
  }
@@ -39,7 +40,7 @@ export function AdminUsersPanel({ service, getRoles, defaultInviteRole = "Admin"
39
40
  const [roleLoading, setRoleLoading] = useState(false);
40
41
  const [invite, setInvite] = useState({ email: "", firstName: "", lastName: "", role: defaultInviteRole });
41
42
  const [inviting, setInviting] = useState(false);
42
- // Bumped to fetch the list again after a change, through the same effect as every other load.
43
+ // Bumping it fetches the list again after a change, through the same effect as every other load.
43
44
  const [reloads, setReloads] = useState(0);
44
45
  // The latest service, so an inline one does not fetch the list again on every render. The roles
45
46
  // load once, so the first getRoles is enough.
@@ -134,7 +135,12 @@ export function AdminUsersPanel({ service, getRoles, defaultInviteRole = "Admin"
134
135
  }
135
136
  setInviting(true);
136
137
  try {
137
- await serviceRef.current.invite(invite.email.trim(), invite.firstName.trim(), invite.lastName.trim(), invite.role);
138
+ await serviceRef.current.invite({
139
+ email: invite.email.trim(),
140
+ firstName: invite.firstName.trim(),
141
+ lastName: invite.lastName.trim(),
142
+ role: invite.role,
143
+ });
138
144
  toast.success(text.inviteSent);
139
145
  setInvite({ email: "", firstName: "", lastName: "", role: invite.role });
140
146
  reload();
@@ -0,0 +1,123 @@
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
+ /** Props for `CompanySettingsForm`. */
12
+ export interface CompanySettingsFormProps {
13
+ /** The settings calls, such as `createSettingsService(api)`. */
14
+ service: { get: () => Promise<SiteSettings>; update: (settings: SiteSettings) => Promise<SiteSettings> };
15
+ strings?: TextOverrides<CompanySettingsFormStrings> | undefined;
16
+ /** The content shown below the form once the settings have loaded, such as the `BrandingManager`. */
17
+ children?: ReactNode;
18
+ }
19
+
20
+ const EMPTY: SiteSettings = {
21
+ contactRecipientEmail: "",
22
+ companyName: "",
23
+ companyLegalName: "",
24
+ orgNumber: "",
25
+ vatRegistered: false,
26
+ streetAddress: "",
27
+ postalCode: "",
28
+ addressLocality: "",
29
+ addressRegion: "",
30
+ publicEmail: "",
31
+ publicPhone: "",
32
+ };
33
+
34
+ /**
35
+ * The admin form for the business details the public pages show: the contact recipient, names, org
36
+ * number and VAT, the address in parts, and the public email and phone.
37
+ */
38
+ export function CompanySettingsForm({ service, strings, children }: CompanySettingsFormProps) {
39
+ const text = withDefaults(defaultCompanySettingsFormStrings, strings);
40
+ const [settings, setSettings] = useState<SiteSettings>(EMPTY);
41
+ const [loading, setLoading] = useState(true);
42
+ const [saving, setSaving] = useState(false);
43
+ const [failed, setFailed] = useState(false);
44
+ // The latest service, so an inline one does not load the settings again on every render.
45
+ const serviceRef = useRef(service);
46
+ useEffect(() => {
47
+ serviceRef.current = service;
48
+ }, [service]);
49
+
50
+ useEffect(() => {
51
+ serviceRef.current
52
+ .get()
53
+ .then(setSettings)
54
+ .catch(() => {
55
+ // No form without the saved settings, since saving a blank one would erase them.
56
+ setFailed(true);
57
+ toast.error(text.loadFailed);
58
+ })
59
+ .finally(() => setLoading(false));
60
+ }, [text.loadFailed]);
61
+
62
+ const patch = (changes: Partial<SiteSettings>) => setSettings(prev => ({ ...prev, ...changes }));
63
+
64
+ const handleSubmit = async (e: FormEvent) => {
65
+ e.preventDefault();
66
+ setSaving(true);
67
+ try {
68
+ setSettings(await serviceRef.current.update(settings));
69
+ toast.success(text.saved);
70
+ } catch (err) {
71
+ toast.error(err instanceof Error && err.message ? err.message : text.saveFailed);
72
+ } finally {
73
+ setSaving(false);
74
+ }
75
+ };
76
+
77
+ if (loading) return <p className="text-gray-500">{text.loading}</p>;
78
+ if (failed) return <p className="text-gray-500">{text.loadFailed}</p>;
79
+
80
+ return (
81
+ <div className="space-y-8">
82
+ <form onSubmit={handleSubmit} className="max-w-md space-y-4">
83
+ <TextInput
84
+ label={text.contactRecipient}
85
+ name="contactRecipientEmail"
86
+ type="email"
87
+ value={settings.contactRecipientEmail}
88
+ onChange={e => patch({ contactRecipientEmail: e.target.value })}
89
+ required
90
+ />
91
+ <p className="text-xs text-gray-500">{text.contactRecipientHint}</p>
92
+
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 })} />
95
+ <p className="text-xs text-gray-500">{text.legalNameHint}</p>
96
+
97
+ <TextInput label={text.orgNumber} name="orgNumber" value={settings.orgNumber} onChange={e => patch({ orgNumber: e.target.value })} />
98
+ <Toggle label={text.vatRegistered} checked={settings.vatRegistered} onChange={v => patch({ vatRegistered: v })} />
99
+ <p className="text-xs text-gray-500">{text.vatHint}</p>
100
+
101
+ <TextInput label={text.streetAddress} name="streetAddress" value={settings.streetAddress} onChange={e => patch({ streetAddress: e.target.value })} required />
102
+ <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 })} />
105
+ </div>
106
+ <TextInput label={text.addressRegion} name="addressRegion" value={settings.addressRegion} onChange={e => patch({ addressRegion: e.target.value })} />
107
+ <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 />
110
+
111
+ <button
112
+ type="submit"
113
+ disabled={saving}
114
+ 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"
115
+ >
116
+ {saving ? text.saving : text.save}
117
+ </button>
118
+ </form>
119
+
120
+ {children}
121
+ </div>
122
+ );
123
+ }
@@ -0,0 +1,132 @@
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
+ /** Props for `LegalPagesEditor`. */
12
+ export interface LegalPagesEditorProps<K extends string, L extends string> {
13
+ /** The legal page calls, such as `createLegalService(api, publicApi)`. */
14
+ service: {
15
+ listAll: () => Promise<LegalPage<K>[]>;
16
+ save: (page: { key: K; locale: L; title: string; bodyMarkdown: string }) => Promise<unknown>;
17
+ };
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>;
22
+ /** The locales each page is written in. The editor opens on the first. */
23
+ locales: readonly L[];
24
+ /** Each locale's tab label, such as `Norsk`. */
25
+ localeLabels: Record<L, string>;
26
+ strings?: TextOverrides<LegalPagesEditorStrings> | undefined;
27
+ }
28
+
29
+ type Draft = { title: string; bodyMarkdown: string };
30
+
31
+ const emptyDraft: Draft = { title: "", bodyMarkdown: "" };
32
+
33
+ /** 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);
36
+ const [drafts, setDrafts] = useState<Record<string, Draft>>({});
37
+ const [activeKey, setActiveKey] = useState<K | undefined>(keys[0]);
38
+ const [activeLocale, setActiveLocale] = useState<L | undefined>(locales[0]);
39
+ const [loading, setLoading] = useState(true);
40
+ const [saving, setSaving] = useState(false);
41
+ const [failed, setFailed] = useState(false);
42
+ // The latest service, so an inline one does not load the pages again on every render.
43
+ const serviceRef = useRef(service);
44
+ useEffect(() => {
45
+ serviceRef.current = service;
46
+ }, [service]);
47
+
48
+ useEffect(() => {
49
+ serviceRef.current
50
+ .listAll()
51
+ .then(pages => setDrafts(Object.fromEntries(pages.map(p => [`${p.key}:${p.locale}`, { title: p.title, bodyMarkdown: p.bodyMarkdown }]))))
52
+ .catch((err: unknown) => {
53
+ // No editor without the saved pages, since saving a blank one would erase it.
54
+ setFailed(true);
55
+ toast.error(err instanceof Error && err.message ? err.message : text.loadFailed);
56
+ })
57
+ .finally(() => setLoading(false));
58
+ }, [text.loadFailed]);
59
+
60
+ if (activeKey === undefined || activeLocale === undefined) return null;
61
+
62
+ const slot = `${activeKey}:${activeLocale}`;
63
+ const draft = drafts[slot] ?? emptyDraft;
64
+
65
+ const patch = (changes: Partial<Draft>) => setDrafts(prev => ({ ...prev, [slot]: { ...(prev[slot] ?? emptyDraft), ...changes } }));
66
+
67
+ const save = async () => {
68
+ if (draft.title.trim() === "" || draft.bodyMarkdown.trim() === "") {
69
+ toast.error(text.required);
70
+ return;
71
+ }
72
+ setSaving(true);
73
+ try {
74
+ await serviceRef.current.save({ key: activeKey, locale: activeLocale, title: draft.title.trim(), bodyMarkdown: draft.bodyMarkdown });
75
+ toast.success(text.saved);
76
+ } catch (err) {
77
+ toast.error(err instanceof Error && err.message ? err.message : text.saveFailed);
78
+ } finally {
79
+ setSaving(false);
80
+ }
81
+ };
82
+
83
+ if (loading) return <p className="text-gray-500">{text.loading}</p>;
84
+ if (failed) return <p className="text-gray-500">{text.loadFailed}</p>;
85
+
86
+ const filled = Object.fromEntries(locales.map(l => [l, (drafts[`${activeKey}:${l}`]?.bodyMarkdown ?? "") !== ""])) as Record<L, boolean>;
87
+
88
+ return (
89
+ <div className="space-y-4">
90
+ <div className="flex items-center justify-between">
91
+ <h2 className="font-bold text-gray-900">{text.heading}</h2>
92
+ <button
93
+ onClick={save}
94
+ disabled={saving}
95
+ 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"
96
+ >
97
+ {saving ? text.saving : text.save}
98
+ </button>
99
+ </div>
100
+
101
+ <div className="flex gap-1">
102
+ {keys.map(key => (
103
+ <button
104
+ key={key}
105
+ onClick={() => setActiveKey(key)}
106
+ aria-pressed={key === activeKey}
107
+ 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
+ >
109
+ {keyLabels[key]}
110
+ </button>
111
+ ))}
112
+ </div>
113
+
114
+ <LocaleTabs locales={locales} labels={localeLabels} active={activeLocale} onChange={setActiveLocale} filled={filled} notFilled={text.notFilled} />
115
+
116
+ <TextInput label={text.title} value={draft.title} onChange={e => patch({ title: e.target.value })} />
117
+
118
+ <div>
119
+ <label htmlFor="legal-body" className="block text-sm font-medium text-gray-700 mb-1">
120
+ {text.bodyMarkdown}
121
+ </label>
122
+ <textarea
123
+ id="legal-body"
124
+ rows={24}
125
+ value={draft.bodyMarkdown}
126
+ 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"
128
+ />
129
+ </div>
130
+ </div>
131
+ );
132
+ }
@@ -0,0 +1,36 @@
1
+ "use client";
2
+
3
+ /** Props for `LocaleTabs`. */
4
+ export interface LocaleTabsProps<L extends string> {
5
+ locales: readonly L[];
6
+ /** Each locale's tab label, such as `Norsk`. */
7
+ labels: Record<L, string>;
8
+ active: L;
9
+ onChange: (locale: L) => void;
10
+ /** The locales that have content. A locale set to `false` gets a dot titled `notFilled`. */
11
+ filled?: Partial<Record<L, boolean>> | undefined;
12
+ /** The dot's title. Defaults to "Not filled in". */
13
+ notFilled?: string | undefined;
14
+ }
15
+
16
+ /** Renders tabs for editing one translation at a time. */
17
+ export function LocaleTabs<L extends string>({ locales, labels, active, onChange, filled, notFilled = "Not filled in" }: LocaleTabsProps<L>) {
18
+ return (
19
+ <div className="flex items-center gap-1 border-b border-gray-200">
20
+ {locales.map(locale => (
21
+ <button
22
+ key={locale}
23
+ type="button"
24
+ onClick={() => onChange(locale)}
25
+ aria-pressed={locale === active}
26
+ className={`px-4 py-2 text-sm font-semibold border-b-2 -mb-px transition-colors ${
27
+ locale === active ? "border-gray-900 text-gray-900" : "border-transparent text-gray-500 hover:text-gray-900"
28
+ }`}
29
+ >
30
+ {labels[locale]}
31
+ {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} />}
32
+ </button>
33
+ ))}
34
+ </div>
35
+ );
36
+ }
@@ -0,0 +1,100 @@
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 color. Defaults to the next color in `STAT_SERIES_COLORS`. */
13
+ color?: string | undefined;
14
+ }
15
+
16
+ /** Props for `StatHistoryChart`. */
17
+ export interface StatHistoryChartProps<T extends { date: string }> {
18
+ /** One row per day, oldest first, with `date` as `YYYY-MM-DD`. */
19
+ data: readonly T[];
20
+ series: readonly StatSeries<T>[];
21
+ strings?: TextOverrides<StatHistoryChartStrings> | undefined;
22
+ }
23
+
24
+ const W = 720;
25
+ const H = 240;
26
+ const PAD = { top: 16, right: 16, bottom: 28, left: 32 };
27
+
28
+ /**
29
+ * Renders a line per series over the days, with the scale from zero to the highest shown value. The
30
+ * legend buttons hide and show a line, and the scale follows the lines that are shown.
31
+ */
32
+ export function StatHistoryChart<T extends { date: string }>({ data, series, strings }: StatHistoryChartProps<T>) {
33
+ const text = withDefaults(defaultStatHistoryChartStrings, strings);
34
+ const [hidden, setHidden] = useState<string[]>([]);
35
+
36
+ const toggle = (key: string) => setHidden(prev => (prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key]));
37
+
38
+ if (data.length === 0) {
39
+ return <p className="text-sm text-gray-500">{text.empty}</p>;
40
+ }
41
+
42
+ const lines = series.map((s, i) => ({ ...s, color: s.color ?? STAT_SERIES_COLORS[i % STAT_SERIES_COLORS.length] }));
43
+ const shown = lines.filter(s => !hidden.includes(s.key));
44
+ const innerW = W - PAD.left - PAD.right;
45
+ const innerH = H - PAD.top - PAD.bottom;
46
+ // An API without a given series sends no value. It counts as zero rather than poisoning the scale with NaN.
47
+ const value = (d: T, key: keyof T) => Number(d[key]) || 0;
48
+ const maxVal = Math.max(1, ...data.flatMap(d => shown.map(s => value(d, s.key))));
49
+ const n = data.length;
50
+
51
+ const x = (i: number) => PAD.left + (n === 1 ? innerW / 2 : (i / (n - 1)) * innerW);
52
+ const y = (v: number) => PAD.top + innerH - (v / maxVal) * innerH;
53
+
54
+ const path = (key: keyof T) => data.map((d, i) => `${i === 0 ? "M" : "L"} ${x(i).toFixed(1)} ${y(value(d, key)).toFixed(1)}`).join(" ");
55
+
56
+ const ticks = [...new Set([0, Math.round(maxVal / 2), maxVal])];
57
+ const labelIdx = [...new Set([0, Math.floor((n - 1) / 2), n - 1])];
58
+
59
+ return (
60
+ <div>
61
+ <svg viewBox={`0 0 ${W} ${H}`} className="w-full h-auto" role="img" aria-label={text.chart}>
62
+ {ticks.map(t => (
63
+ <g key={t}>
64
+ <line x1={PAD.left} y1={y(t)} x2={W - PAD.right} y2={y(t)} className="stroke-chart-grid" strokeWidth={1} />
65
+ <text x={PAD.left - 6} y={y(t) + 3} textAnchor="end" fontSize={10} className="fill-chart-label">
66
+ {t}
67
+ </text>
68
+ </g>
69
+ ))}
70
+ {labelIdx.map(i => (
71
+ <text key={i} x={x(i)} y={H - 8} textAnchor="middle" fontSize={10} className="fill-chart-label">
72
+ {data[i]?.date.slice(5)}
73
+ </text>
74
+ ))}
75
+ {shown.map(s => (
76
+ <path key={s.key} d={path(s.key)} fill="none" stroke={s.color} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />
77
+ ))}
78
+ </svg>
79
+ <div className="mt-3 flex flex-wrap gap-2">
80
+ {lines.map(s => {
81
+ const off = hidden.includes(s.key);
82
+ return (
83
+ <button
84
+ key={s.key}
85
+ type="button"
86
+ onClick={() => toggle(s.key)}
87
+ aria-pressed={!off}
88
+ className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs transition ${
89
+ off ? "border-gray-200 bg-white text-gray-400" : "border-gray-300 bg-gray-50 text-gray-700"
90
+ }`}
91
+ >
92
+ <span className="inline-block h-2.5 w-2.5 rounded-full" style={{ backgroundColor: off ? "#d1d5db" : s.color }} />
93
+ {s.label}
94
+ </button>
95
+ );
96
+ })}
97
+ </div>
98
+ </div>
99
+ );
100
+ }
@@ -1,8 +1,8 @@
1
1
  // Plain values, with no "use client", so a server component can import them.
2
2
 
3
3
  /**
4
- * The users panel's text. Pass the app's own language. The defaults are English. `{name}` and `{role}`
5
- * are filled in where a text names them.
4
+ * The users panel's text. Pass the app's own language. The defaults are English. The panel fills in
5
+ * `{name}` and `{role}` where a text names them.
6
6
  */
7
7
  export interface AdminUsersPanelStrings {
8
8
  users: string;
@@ -19,7 +19,7 @@ export interface AdminUsersPanelStrings {
19
19
  /** The tag after a deleted user's name. */
20
20
  deleted: string;
21
21
  delete: string;
22
- /** Before the role name on the button that takes a role away. */
22
+ /** The text before the role name on the button that takes a role away. */
23
23
  remove: string;
24
24
  noRoles: string;
25
25
  addRole: string;
@@ -0,0 +1,50 @@
1
+ // Plain values, with no "use client", so a server component can import them.
2
+
3
+ /** The company settings form's text. Pass the app's own language. The defaults are English. */
4
+ export interface CompanySettingsFormStrings {
5
+ contactRecipient: string;
6
+ contactRecipientHint: string;
7
+ companyName: string;
8
+ legalName: string;
9
+ legalNameHint: string;
10
+ orgNumber: string;
11
+ vatRegistered: string;
12
+ vatHint: string;
13
+ streetAddress: string;
14
+ postalCode: string;
15
+ addressLocality: string;
16
+ addressRegion: string;
17
+ addressHint: string;
18
+ publicEmail: string;
19
+ publicPhone: string;
20
+ loading: string;
21
+ save: string;
22
+ saving: string;
23
+ loadFailed: string;
24
+ saved: string;
25
+ saveFailed: string;
26
+ }
27
+
28
+ export const defaultCompanySettingsFormStrings: CompanySettingsFormStrings = {
29
+ contactRecipient: "Contact enquiry recipient",
30
+ contactRecipientHint: "Contact form enquiries are emailed to this address.",
31
+ companyName: "Company name",
32
+ legalName: "Legal name",
33
+ legalNameHint: "The registered name. Leave blank to use the company name.",
34
+ orgNumber: "Organisation number",
35
+ vatRegistered: "VAT (MVA) registered",
36
+ vatHint: "When on, the org number is shown with the “MVA” suffix.",
37
+ streetAddress: "Street address",
38
+ postalCode: "Postal code",
39
+ addressLocality: "Place",
40
+ addressRegion: "County",
41
+ addressHint: "The county is optional.",
42
+ publicEmail: "Public email",
43
+ publicPhone: "Public phone",
44
+ loading: "Loading…",
45
+ save: "Save",
46
+ saving: "Saving…",
47
+ loadFailed: "Failed to load settings",
48
+ saved: "Settings saved",
49
+ saveFailed: "Failed to save",
50
+ };
@@ -1,6 +1,20 @@
1
+ export { CompanySettingsForm } from "./CompanySettingsForm";
2
+ export type { CompanySettingsFormProps } from "./CompanySettingsForm";
3
+ export { defaultCompanySettingsFormStrings } from "./companySettingsFormStrings";
4
+ export type { CompanySettingsFormStrings } from "./companySettingsFormStrings";
1
5
  export { AdminShell } from "./AdminShell";
2
6
  export type { AdminShellProps, AdminTab } from "./AdminShell";
7
+ export { StatHistoryChart } from "./StatHistoryChart";
8
+ export { STAT_SERIES_COLORS, defaultStatHistoryChartStrings } from "./statHistoryChartStrings";
9
+ export type { StatHistoryChartProps, StatSeries } from "./StatHistoryChart";
10
+ export type { StatHistoryChartStrings } from "./statHistoryChartStrings";
3
11
  export { AdminUsersPanel } from "./AdminUsersPanel";
4
12
  export type { AdminUsersPanelProps } from "./AdminUsersPanel";
5
13
  export { defaultAdminUsersPanelStrings } from "./adminUsersPanelStrings";
6
14
  export type { AdminUsersPanelStrings } from "./adminUsersPanelStrings";
15
+ export { LegalPagesEditor } from "./LegalPagesEditor";
16
+ export type { LegalPagesEditorProps } from "./LegalPagesEditor";
17
+ export { defaultLegalPagesEditorStrings } from "./legalPagesEditorStrings";
18
+ export type { LegalPagesEditorStrings } from "./legalPagesEditorStrings";
19
+ export { LocaleTabs } from "./LocaleTabs";
20
+ export type { LocaleTabsProps } from "./LocaleTabs";
@@ -0,0 +1,32 @@
1
+ // Plain values, with no "use client", so a server component can import them.
2
+
3
+ /** The legal pages editor's text. Pass the app's own language. The defaults are English. */
4
+ export interface LegalPagesEditorStrings {
5
+ heading: string;
6
+ title: string;
7
+ bodyMarkdown: string;
8
+ /** The title of the dot on a locale with no content yet. */
9
+ notFilled: string;
10
+ loading: string;
11
+ save: string;
12
+ saving: string;
13
+ loadFailed: string;
14
+ /** The message shown when the title or the content is blank. */
15
+ required: string;
16
+ saved: string;
17
+ saveFailed: string;
18
+ }
19
+
20
+ export const defaultLegalPagesEditorStrings: LegalPagesEditorStrings = {
21
+ heading: "Legal pages",
22
+ title: "Title",
23
+ bodyMarkdown: "Content (Markdown)",
24
+ notFilled: "Not filled in",
25
+ loading: "Loading…",
26
+ save: "Save",
27
+ saving: "Saving…",
28
+ loadFailed: "Failed to load the legal pages",
29
+ required: "Fill in the title and the content",
30
+ saved: "Page saved",
31
+ saveFailed: "Failed to save the page",
32
+ };
@@ -0,0 +1,17 @@
1
+ // Plain values, with no "use client", so a server component can import them.
2
+
3
+ /** The chart's text. Pass the app's own language. The defaults are English. */
4
+ export interface StatHistoryChartStrings {
5
+ /** The text shown instead of the chart when there are no days yet. */
6
+ empty: string;
7
+ /** The chart's accessible name. */
8
+ chart: string;
9
+ }
10
+
11
+ export const defaultStatHistoryChartStrings: StatHistoryChartStrings = {
12
+ empty: "No history yet. Check back tomorrow.",
13
+ chart: "Stats over time",
14
+ };
15
+
16
+ /** The default line colors, in order: blue, green, amber, teal, and purple. */
17
+ export const STAT_SERIES_COLORS = ["#2563eb", "#16a34a", "#d97706", "#0d9488", "#9333ea"] as const;
@@ -1,10 +1,10 @@
1
1
  import axios from "axios";
2
2
 
3
3
  /**
4
- * The per-field messages of an ASP.NET validation problem, keyed by the field's name in camelCase so
5
- * they match the form's own names: `{ errors: { OrgNumber: ["..."] } }` becomes
6
- * `{ orgNumber: ["..."] }`. Takes the failed axios call or the response body. Answers null when there
7
- * are no field errors, so the caller falls back to one message for the whole form.
4
+ * Gets the per-field messages of an ASP.NET validation problem, keyed by the field's name in
5
+ * camelCase so they match the form's own names: `{ errors: { OrgNumber: ["..."] } }` becomes
6
+ * `{ orgNumber: ["..."] }`. Takes the failed axios call or the response body. Returns null when
7
+ * there are no field errors, so the caller falls back to one message for the whole form.
8
8
  */
9
9
  export function apiFieldErrors(error: unknown): Record<string, string[]> | null {
10
10
  const data = axios.isAxiosError(error) ? error.response?.data : error;