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