@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.
- package/README.md +315 -253
- package/package.json +13 -1
- package/src/admin/AdminShell.tsx +2 -1
- package/src/admin/AdminUsersPanel.tsx +9 -3
- package/src/admin/CompanySettingsForm.tsx +123 -0
- package/src/admin/LegalPagesEditor.tsx +132 -0
- package/src/admin/LocaleTabs.tsx +36 -0
- package/src/admin/StatHistoryChart.tsx +100 -0
- package/src/admin/adminUsersPanelStrings.ts +3 -3
- package/src/admin/companySettingsFormStrings.ts +50 -0
- package/src/admin/index.ts +14 -0
- package/src/admin/legalPagesEditorStrings.ts +32 -0
- package/src/admin/statHistoryChartStrings.ts +17 -0
- package/src/api/apiFieldErrors.ts +4 -4
- package/src/api/company.ts +47 -0
- package/src/api/createApiClient.ts +3 -2
- package/src/{errors.ts → api/formatApiError.ts} +12 -12
- package/src/api/index.ts +6 -1
- package/src/api/request.ts +2 -2
- package/src/api/revalidateTargets.ts +42 -0
- package/src/api/services.ts +14 -13
- package/src/auth/createAuthOptions.ts +7 -5
- package/src/branding/BrandingManager.tsx +10 -5
- package/src/branding/BrandingProvider.tsx +5 -3
- package/src/branding/index.ts +1 -2
- package/src/branding/server.ts +10 -9
- package/src/branding/types.ts +3 -3
- package/src/config.ts +10 -10
- package/src/forms/useFormDraft.ts +9 -7
- package/src/index.ts +1 -3
- package/src/legal/LegalPageView.tsx +62 -0
- package/src/legal/index.ts +1 -0
- package/src/markdown/Markdown.tsx +12 -10
- package/src/markdown/index.ts +1 -1
- package/src/next-auth.d.ts +11 -9
- package/src/next-config/headers.ts +96 -0
- package/src/next-config/index.ts +18 -107
- package/src/payments/PaymentReturn.tsx +9 -7
- package/src/payments/formatNok.ts +1 -1
- package/src/payments/usePollUntilFinal.ts +7 -5
- package/src/seo/JsonLd.tsx +1 -1
- package/src/seo/environment.ts +3 -3
- package/src/seo/index.ts +2 -1
- package/src/seo/og/index.tsx +5 -4
- package/src/seo/schema.ts +6 -2
- package/src/seo/site.ts +90 -30
- package/src/seo/sitemap.ts +2 -2
- package/src/server/publicApi.ts +13 -9
- package/src/server/revalidateRoute.ts +10 -8
- package/src/session/expiry.ts +8 -5
- package/src/session/react/AppSessionProvider.tsx +3 -2
- package/src/{ui → session/react}/CredentialsForm.tsx +10 -9
- package/src/{ui → session/react}/LoginPage.tsx +7 -6
- package/src/session/react/ProtectedGate.tsx +3 -2
- package/src/session/react/RoleGate.tsx +4 -3
- package/src/session/react/SessionExpiryGuard.tsx +5 -4
- package/src/{ui → session/react}/credentialsFormStrings.ts +1 -1
- package/src/session/react/index.ts +8 -2
- package/src/{ui → session/react}/loginPageStrings.ts +1 -1
- package/src/session/react/sessionExpiryGuardStrings.ts +1 -1
- package/src/session/react/useSessionGate.ts +3 -3
- package/src/styles.css +2 -0
- package/src/testing/index.ts +113 -0
- package/src/ui/Alert.tsx +6 -4
- package/src/ui/ContentImage.tsx +12 -11
- package/src/ui/ErrorState.tsx +5 -3
- package/src/ui/NotFoundState.tsx +9 -7
- package/src/ui/PasswordInput.tsx +6 -5
- package/src/ui/ResetPasswordFlow.tsx +10 -8
- package/src/ui/TextArea.tsx +6 -4
- package/src/ui/TextInput.tsx +4 -2
- package/src/ui/Toggle.tsx +3 -3
- package/src/ui/field.tsx +5 -3
- package/src/ui/index.ts +2 -10
- package/src/ui/resetPasswordFlowStrings.ts +7 -7
- package/src/validation/fieldErrors.ts +4 -3
- package/src/validation/password.ts +5 -5
- 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.
|
|
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
|
},
|
package/src/admin/AdminShell.tsx
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
//
|
|
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(
|
|
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.
|
|
5
|
-
*
|
|
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
|
-
/**
|
|
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
|
+
};
|
package/src/admin/index.ts
CHANGED
|
@@ -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
|
-
*
|
|
5
|
-
* they match the form's own names: `{ errors: { OrgNumber: ["..."] } }` becomes
|
|
6
|
-
* `{ orgNumber: ["..."] }`. Takes the failed axios call or the response body.
|
|
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;
|