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