@sjolystinnovation/app-kit 0.22.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +1281 -773
  2. package/package.json +21 -4
  3. package/src/admin/AdminUsersPanel.tsx +85 -35
  4. package/src/admin/CompanySettingsForm.tsx +33 -13
  5. package/src/admin/EmailStatsPanel.tsx +76 -0
  6. package/src/admin/LegalPagesEditor.tsx +41 -14
  7. package/src/admin/adminUsersPanelStrings.ts +67 -0
  8. package/src/admin/companySettingsFormStrings.ts +26 -0
  9. package/src/admin/emailStatsPanelStrings.ts +29 -0
  10. package/src/admin/index.ts +14 -5
  11. package/src/admin/legalPagesEditorStrings.ts +25 -0
  12. package/src/api/apiErrorCode.ts +28 -0
  13. package/src/api/apiFieldErrors.ts +82 -11
  14. package/src/api/failureBody.ts +10 -0
  15. package/src/api/fieldErrorText.ts +32 -0
  16. package/src/api/index.ts +5 -1
  17. package/src/api/legalPage.ts +10 -0
  18. package/src/api/request.ts +13 -3
  19. package/src/api/services.ts +42 -12
  20. package/src/auth/createAuthOptions.ts +9 -3
  21. package/src/branding/BrandingManager.tsx +31 -36
  22. package/src/branding/brandingManagerStrings.ts +49 -0
  23. package/src/branding/index.ts +3 -3
  24. package/src/legal/LegalPageView.tsx +2 -17
  25. package/src/legal/index.ts +4 -1
  26. package/src/legal/keys.ts +10 -0
  27. package/src/legal/legalPageViewStrings.ts +17 -0
  28. package/src/locales/nb.ts +330 -0
  29. package/src/sections/SectionRenderer.tsx +275 -0
  30. package/src/sections/SectionsEditor.tsx +151 -0
  31. package/src/sections/SmartLink.tsx +42 -0
  32. package/src/sections/editors.tsx +311 -0
  33. package/src/sections/factory.ts +26 -0
  34. package/src/sections/index.ts +69 -0
  35. package/src/sections/sectionsEditorStrings.ts +108 -0
  36. package/src/sections/types.ts +107 -0
  37. package/src/seo/index.ts +1 -0
  38. package/src/seo/site.ts +5 -5
  39. package/src/session/expiry.ts +3 -1
  40. package/src/session/index.ts +0 -1
  41. package/src/session/react/CredentialsForm.tsx +8 -2
  42. package/src/session/react/credentialsFormStrings.ts +4 -1
  43. package/src/session/react/index.ts +1 -1
  44. package/src/styles.css +2 -0
  45. package/src/testing/index.ts +1 -9
  46. package/src/ui/ErrorState.tsx +1 -15
  47. package/src/ui/NotFoundState.tsx +1 -13
  48. package/src/ui/ResetPasswordFlow.tsx +55 -12
  49. package/src/ui/errorStateStrings.ts +16 -0
  50. package/src/ui/index.ts +10 -6
  51. package/src/ui/notFoundStateStrings.ts +14 -0
  52. package/src/ui/resetPasswordFlowStrings.ts +44 -0
@@ -0,0 +1,311 @@
1
+ "use client";
2
+
3
+ import { PlusIcon, TrashIcon } from "@heroicons/react/24/outline";
4
+ import { useState, type ChangeEvent, type ReactNode } from "react";
5
+ import { toast } from "../toast";
6
+ import { contentImagePath } from "../ui/ContentImage";
7
+ import { TextInput } from "../ui/TextInput";
8
+ import { withDefaults, type TextOverrides } from "../ui/withDefaults";
9
+ import { defaultSectionsEditorStrings, type SectionsEditorStrings } from "./sectionsEditorStrings";
10
+ import type { ContactSection, CtaSection, FeatureSection, FeedSection, HeroSection, ImageSection, StatItem, StatsSection, TextSection } from "./types";
11
+
12
+ /** How the editor uploads and previews images. */
13
+ export interface SectionImages {
14
+ /** Uploads the file and resolves to the new image's id. */
15
+ upload: (file: File) => Promise<{ id: string }>;
16
+ /** The preview's URL for an image id. Defaults to `contentImagePath`. */
17
+ src?: ((id: string) => string) | undefined;
18
+ }
19
+
20
+ /** What an editor for one section type gets. */
21
+ export interface SectionEditorProps<S> {
22
+ section: S;
23
+ /** Merges the changes into the section. */
24
+ patch: (changes: Partial<S>) => void;
25
+ strings: SectionsEditorStrings;
26
+ images: SectionImages;
27
+ }
28
+
29
+ /** Props for `SectionField`. */
30
+ export interface SectionFieldProps {
31
+ label: string;
32
+ value: string;
33
+ onChange: (value: string) => void;
34
+ /** A three-row text area instead of a one-line input. */
35
+ textarea?: boolean | undefined;
36
+ }
37
+
38
+ /** A labeled text field for a section editor. */
39
+ export function SectionField({ label, value, onChange, textarea }: SectionFieldProps) {
40
+ if (textarea) {
41
+ return (
42
+ <div>
43
+ <label className="block text-sm font-medium text-gray-700 mb-1">{label}</label>
44
+ <textarea
45
+ rows={3}
46
+ value={value}
47
+ onChange={e => onChange(e.target.value)}
48
+ className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-primary"
49
+ />
50
+ </div>
51
+ );
52
+ }
53
+ return <TextInput label={label} value={value} onChange={e => onChange(e.target.value)} />;
54
+ }
55
+
56
+ /** Props for `ImagePicker`. */
57
+ export interface ImagePickerProps {
58
+ imageId: string | null;
59
+ onChange: (id: string | null) => void;
60
+ /** The label above the picker. Defaults to `strings.image`. */
61
+ label?: string | undefined;
62
+ images: SectionImages;
63
+ strings?: TextOverrides<SectionsEditorStrings> | undefined;
64
+ }
65
+
66
+ /** Uploads an image and shows a preview, with a button to clear it. A failed upload shows a toast. */
67
+ export function ImagePicker({ imageId, onChange, label, images, strings }: ImagePickerProps) {
68
+ const text = withDefaults(defaultSectionsEditorStrings, strings);
69
+ const [uploading, setUploading] = useState(false);
70
+ const src = images.src ?? contentImagePath;
71
+
72
+ const handleFile = async (e: ChangeEvent<HTMLInputElement>) => {
73
+ const file = e.target.files?.[0];
74
+ e.target.value = "";
75
+ if (!file) return;
76
+ setUploading(true);
77
+ try {
78
+ const { id } = await images.upload(file);
79
+ onChange(id);
80
+ } catch (err) {
81
+ toast.error(err instanceof Error ? err.message : text.uploadFailed);
82
+ } finally {
83
+ setUploading(false);
84
+ }
85
+ };
86
+
87
+ return (
88
+ <div>
89
+ <label className="block text-sm font-medium text-gray-700 mb-1">{label ?? text.image}</label>
90
+ {imageId != null && <img src={src(imageId)} alt="" className="mb-2 h-32 w-auto rounded-lg border border-gray-200 object-cover" />}
91
+ <div className="flex items-center gap-3">
92
+ <label className="inline-flex items-center gap-1.5 rounded-lg bg-gray-100 text-gray-700 font-medium px-4 py-2 text-sm hover:bg-gray-200 transition cursor-pointer">
93
+ {uploading ? text.uploading : imageId != null ? text.replaceImage : text.uploadImage}
94
+ <input type="file" accept="image/*" onChange={handleFile} disabled={uploading} className="hidden" />
95
+ </label>
96
+ {imageId != null && (
97
+ <button type="button" onClick={() => onChange(null)} className="text-sm text-red-500 hover:text-red-700">
98
+ {text.removeImage}
99
+ </button>
100
+ )}
101
+ </div>
102
+ </div>
103
+ );
104
+ }
105
+
106
+ function BulletEditor({ bullets, onChange, strings }: { bullets: string[]; onChange: (bullets: string[]) => void; strings: SectionsEditorStrings }) {
107
+ return (
108
+ <div>
109
+ <label className="block text-sm font-medium text-gray-700 mb-1">{strings.bullets}</label>
110
+ <div className="space-y-2">
111
+ {bullets.map((bullet, i) => (
112
+ <div key={i} className="flex items-center gap-2">
113
+ <input
114
+ value={bullet}
115
+ onChange={e => onChange(bullets.map((b, j) => (j === i ? e.target.value : b)))}
116
+ className="flex-1 rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
117
+ />
118
+ <button type="button" onClick={() => onChange(bullets.filter((_, j) => j !== i))} className="p-2 rounded-lg text-red-500 hover:bg-red-50">
119
+ <TrashIcon className="h-4 w-4" />
120
+ </button>
121
+ </div>
122
+ ))}
123
+ <button type="button" onClick={() => onChange([...bullets, ""])} className="inline-flex items-center gap-1.5 text-sm text-gray-600 hover:text-gray-900">
124
+ <PlusIcon className="h-4 w-4" /> {strings.addBullet}
125
+ </button>
126
+ </div>
127
+ </div>
128
+ );
129
+ }
130
+
131
+ /** Props for `HeroSectionEditor`. */
132
+ export interface HeroSectionEditorProps extends SectionEditorProps<HeroSection> {
133
+ /** Extra fields between the subheading and the buttons, such as a background image picker. */
134
+ media?: ReactNode;
135
+ }
136
+
137
+ /** Edits a hero's heading, subheading and two buttons. */
138
+ export function HeroSectionEditor({ section, patch, strings, media }: HeroSectionEditorProps) {
139
+ return (
140
+ <div className="space-y-4">
141
+ <SectionField label={strings.heading} value={section.heading} onChange={v => patch({ heading: v })} />
142
+ <SectionField label={strings.subheading} value={section.subheading} onChange={v => patch({ subheading: v })} textarea />
143
+ {media}
144
+ <div className="grid sm:grid-cols-2 gap-4">
145
+ <SectionField label={strings.primaryButtonLabel} value={section.primaryLabel} onChange={v => patch({ primaryLabel: v })} />
146
+ <SectionField label={strings.primaryButtonHref} value={section.primaryHref} onChange={v => patch({ primaryHref: v })} />
147
+ <SectionField label={strings.secondaryButtonLabel} value={section.secondaryLabel} onChange={v => patch({ secondaryLabel: v })} />
148
+ <SectionField label={strings.secondaryButtonHref} value={section.secondaryHref} onChange={v => patch({ secondaryHref: v })} />
149
+ </div>
150
+ </div>
151
+ );
152
+ }
153
+
154
+ /** Edits a feature's heading, text and bullets. */
155
+ export function FeatureSectionEditor({ section, patch, strings }: SectionEditorProps<FeatureSection>) {
156
+ return (
157
+ <div className="space-y-4">
158
+ <SectionField label={strings.heading} value={section.heading} onChange={v => patch({ heading: v })} />
159
+ <SectionField label={strings.text} value={section.text} onChange={v => patch({ text: v })} textarea />
160
+ <BulletEditor bullets={section.bullets} onChange={bullets => patch({ bullets })} strings={strings} />
161
+ </div>
162
+ );
163
+ }
164
+
165
+ /** Edits a text section's heading and Markdown body. */
166
+ export function TextSectionEditor({ section, patch, strings }: SectionEditorProps<TextSection>) {
167
+ return (
168
+ <div className="space-y-4">
169
+ <SectionField label={strings.heading} value={section.heading} onChange={v => patch({ heading: v })} />
170
+ <SectionField label={strings.body} value={section.body} onChange={v => patch({ body: v })} textarea />
171
+ </div>
172
+ );
173
+ }
174
+
175
+ /** Props for `FeedSectionEditor`. */
176
+ export interface FeedSectionEditorProps extends SectionEditorProps<FeedSection> {
177
+ /** Extra fields after the count, such as a filter. */
178
+ children?: ReactNode;
179
+ /** The classes on the grid. Defaults to `grid sm:grid-cols-2 gap-4`. */
180
+ className?: string | undefined;
181
+ }
182
+
183
+ /** Edits a feed's heading and how many items it shows, at least one. */
184
+ export function FeedSectionEditor({ section, patch, strings, children, className = "grid sm:grid-cols-2 gap-4" }: FeedSectionEditorProps) {
185
+ return (
186
+ <div className={className}>
187
+ <SectionField label={strings.heading} value={section.heading} onChange={v => patch({ heading: v })} />
188
+ <TextInput label={strings.count} type="number" value={section.limit.toString()} onChange={e => patch({ limit: Math.max(1, Number(e.target.value) || 1) })} />
189
+ {children}
190
+ </div>
191
+ );
192
+ }
193
+
194
+ /** Edits a contact section's heading and text. */
195
+ export function ContactSectionEditor({ section, patch, strings }: SectionEditorProps<ContactSection>) {
196
+ return (
197
+ <div className="space-y-4">
198
+ <SectionField label={strings.heading} value={section.heading} onChange={v => patch({ heading: v })} />
199
+ <SectionField label={strings.text} value={section.text} onChange={v => patch({ text: v })} textarea />
200
+ </div>
201
+ );
202
+ }
203
+
204
+ /** Edits a call to action's heading, text and button. */
205
+ export function CtaSectionEditor({ section, patch, strings }: SectionEditorProps<CtaSection>) {
206
+ return (
207
+ <div className="space-y-4">
208
+ <SectionField label={strings.heading} value={section.heading} onChange={v => patch({ heading: v })} />
209
+ <SectionField label={strings.text} value={section.text} onChange={v => patch({ text: v })} textarea />
210
+ <div className="grid sm:grid-cols-2 gap-4">
211
+ <SectionField label={strings.buttonLabel} value={section.primaryLabel} onChange={v => patch({ primaryLabel: v })} />
212
+ <SectionField label={strings.buttonHref} value={section.primaryHref} onChange={v => patch({ primaryHref: v })} />
213
+ </div>
214
+ </div>
215
+ );
216
+ }
217
+
218
+ /** A live number a stats item can show. */
219
+ export interface StatSource<Live extends string> {
220
+ value: Live;
221
+ /** The option's name in the source list. */
222
+ label: string;
223
+ /** The label an item gets when it picks this source with no label yet. */
224
+ defaultItemLabel: string;
225
+ }
226
+
227
+ /** Props for `StatsSectionEditor`. */
228
+ export interface StatsSectionEditorProps<Live extends string> extends SectionEditorProps<StatsSection<Live>> {
229
+ /** The live sources after the manual number, in list order. */
230
+ sources?: readonly StatSource<Live>[] | undefined;
231
+ }
232
+
233
+ /** Edits a stats band's heading and items. Each item is a manual number or one of the live sources. */
234
+ export function StatsSectionEditor<Live extends string = never>({ section, patch, strings, sources = [] }: StatsSectionEditorProps<Live>) {
235
+ const items = section.items;
236
+ const onChange = (next: StatItem<Live>[]) => patch({ items: next } as Partial<StatsSection<Live>>);
237
+ const update = (i: number, changes: Partial<StatItem<Live>>) => onChange(items.map((it, j) => (j === i ? { ...it, ...changes } : it)));
238
+ const defaults: Record<string, string> = { static: "", ...Object.fromEntries(sources.map(s => [s.value, s.defaultItemLabel])) };
239
+
240
+ return (
241
+ <div className="space-y-4">
242
+ <SectionField label={strings.headingOptional} value={section.heading} onChange={v => patch({ heading: v } as Partial<StatsSection<Live>>)} />
243
+ <div>
244
+ <label className="block text-sm font-medium text-gray-700 mb-1">{strings.stats}</label>
245
+ <div className="space-y-3">
246
+ {items.map((item, i) => (
247
+ <div key={i} className="flex flex-wrap items-end gap-2 rounded-lg border border-gray-200 p-3">
248
+ <label className="text-xs text-gray-600">
249
+ {strings.source}
250
+ <select
251
+ value={item.source}
252
+ onChange={e => {
253
+ const source = e.target.value as StatItem<Live>["source"];
254
+ update(i, { source, ...(item.label ? {} : { label: defaults[source] ?? "" }) });
255
+ }}
256
+ className="mt-1 block rounded-lg border border-gray-300 px-3 py-2 text-sm"
257
+ >
258
+ <option value="static">{strings.sourceStatic}</option>
259
+ {sources.map(s => (
260
+ <option key={s.value} value={s.value}>
261
+ {s.label}
262
+ </option>
263
+ ))}
264
+ </select>
265
+ </label>
266
+ {item.source === "static" && (
267
+ <div className="w-28">
268
+ <TextInput label={strings.value} value={item.value} onChange={e => update(i, { value: e.target.value })} />
269
+ </div>
270
+ )}
271
+ <div className="flex-1 min-w-[10rem]">
272
+ <TextInput label={strings.label} value={item.label} onChange={e => update(i, { label: e.target.value })} />
273
+ </div>
274
+ <button type="button" onClick={() => onChange(items.filter((_, j) => j !== i))} className="p-2 rounded-lg text-red-500 hover:bg-red-50">
275
+ <TrashIcon className="h-4 w-4" />
276
+ </button>
277
+ </div>
278
+ ))}
279
+ <button type="button" onClick={() => onChange([...items, { source: "static", value: "", label: "" }])} className="inline-flex items-center gap-1.5 text-sm text-gray-600 hover:text-gray-900">
280
+ <PlusIcon className="h-4 w-4" /> {strings.addStat}
281
+ </button>
282
+ </div>
283
+ </div>
284
+ </div>
285
+ );
286
+ }
287
+
288
+ /** Edits an image section: the image, the layout, the alt text, and a text or a caption by layout. */
289
+ export function ImageSectionEditor({ section, patch, strings, images }: SectionEditorProps<ImageSection>) {
290
+ const layout = section.layout ?? "standard";
291
+ const usesText = layout === "left" || layout === "right" || layout === "overlay" || layout === "overlayFull";
292
+ return (
293
+ <div className="space-y-4">
294
+ <ImagePicker imageId={section.imageId} onChange={imageId => patch({ imageId })} images={images} strings={strings} />
295
+ <div>
296
+ <label className="block text-sm font-medium text-gray-700 mb-1">{strings.layout}</label>
297
+ <select value={layout} onChange={e => patch({ layout: e.target.value as ImageSection["layout"] })} className="rounded-lg border border-gray-300 px-3 py-2 text-sm">
298
+ <option value="standard">{strings.layoutStandard}</option>
299
+ <option value="full">{strings.layoutFull}</option>
300
+ <option value="left">{strings.layoutLeft}</option>
301
+ <option value="right">{strings.layoutRight}</option>
302
+ <option value="overlay">{strings.layoutOverlay}</option>
303
+ <option value="overlayFull">{strings.layoutOverlayFull}</option>
304
+ </select>
305
+ </div>
306
+ <SectionField label={strings.altText} value={section.alt} onChange={v => patch({ alt: v })} />
307
+ {usesText && <SectionField label={strings.text} value={section.text} onChange={v => patch({ text: v })} textarea />}
308
+ {!usesText && <SectionField label={strings.caption} value={section.caption} onChange={v => patch({ caption: v })} />}
309
+ </div>
310
+ );
311
+ }
@@ -0,0 +1,26 @@
1
+ import type { BaseSection, SectionOf } from "./types";
2
+
3
+ let counter = 0;
4
+
5
+ /** A new section id, unique within the page. */
6
+ function newSectionId(): string {
7
+ return `s-${Date.now()}-${counter++}`;
8
+ }
9
+
10
+ /** The starting fields of each section type, without the id, the type and `visible`. */
11
+ export type SectionTemplates<S extends BaseSection> = {
12
+ [K in S["type"]]: Omit<SectionOf<S, K>, "id" | "type" | "visible">;
13
+ };
14
+
15
+ /**
16
+ * Builds the app's `createSection` from its starting fields. Each call gets a new id and its own
17
+ * copy of the fields, so two sections never share an array.
18
+ */
19
+ export function createSectionFactory<S extends BaseSection>(templates: SectionTemplates<S>): (type: S["type"]) => S {
20
+ return type => ({ id: newSectionId(), visible: true, type, ...structuredClone(templates[type as S["type"]]) }) as unknown as S;
21
+ }
22
+
23
+ /** A deep copy of the section with a new id. */
24
+ export function cloneSection<S extends BaseSection>(section: S): S {
25
+ return { ...structuredClone(section), id: newSectionId() };
26
+ }
@@ -0,0 +1,69 @@
1
+ export { SHARED_SECTION_TYPES } from "./types";
2
+ export type {
3
+ BaseSection,
4
+ ContactSection,
5
+ CtaSection,
6
+ FeatureSection,
7
+ FeedSection,
8
+ HeroSection,
9
+ ImageLayout,
10
+ ImageSection,
11
+ SectionExtensions,
12
+ SectionOf,
13
+ Sections,
14
+ SharedSectionType,
15
+ StatItem,
16
+ StatsSection,
17
+ TextSection,
18
+ } from "./types";
19
+ export { cloneSection, createSectionFactory } from "./factory";
20
+ export type { SectionTemplates } from "./factory";
21
+ export {
22
+ SectionRenderer,
23
+ createContactRenderer,
24
+ createImageRenderer,
25
+ createTextRenderer,
26
+ ctaRenderer,
27
+ featureRenderer,
28
+ heroRenderer,
29
+ } from "./SectionRenderer";
30
+ export type {
31
+ ContactRendererOptions,
32
+ ImageDimensions,
33
+ ImageRendererOptions,
34
+ SectionImageProps,
35
+ SectionLinkProps,
36
+ SectionRenderContext,
37
+ SectionRenderFn,
38
+ SectionRendererProps,
39
+ SectionRenderers,
40
+ TextRendererOptions,
41
+ } from "./SectionRenderer";
42
+ export { SmartLink } from "./SmartLink";
43
+ export type { SmartLinkProps } from "./SmartLink";
44
+ export { SectionsEditor } from "./SectionsEditor";
45
+ export type { SectionEditors, SectionsEditorProps } from "./SectionsEditor";
46
+ export {
47
+ ContactSectionEditor,
48
+ CtaSectionEditor,
49
+ FeatureSectionEditor,
50
+ FeedSectionEditor,
51
+ HeroSectionEditor,
52
+ ImagePicker,
53
+ ImageSectionEditor,
54
+ SectionField,
55
+ StatsSectionEditor,
56
+ TextSectionEditor,
57
+ } from "./editors";
58
+ export type {
59
+ FeedSectionEditorProps,
60
+ HeroSectionEditorProps,
61
+ ImagePickerProps,
62
+ SectionEditorProps,
63
+ SectionFieldProps,
64
+ SectionImages,
65
+ StatSource,
66
+ StatsSectionEditorProps,
67
+ } from "./editors";
68
+ export { defaultSectionTypeLabels, defaultSectionsEditorStrings } from "./sectionsEditorStrings";
69
+ export type { SectionsEditorStrings } from "./sectionsEditorStrings";
@@ -0,0 +1,108 @@
1
+ // Plain values, with no "use client", so a server component can import them.
2
+
3
+ import type { SharedSectionType } from "./types";
4
+
5
+ /** The sections editor's text. Pass the app's own language. The defaults are English. */
6
+ export interface SectionsEditorStrings {
7
+ hide: string;
8
+ show: string;
9
+ moveUp: string;
10
+ moveDown: string;
11
+ duplicate: string;
12
+ /** The title of the button that removes a section. */
13
+ remove: string;
14
+ addSection: string;
15
+ heading: string;
16
+ headingOptional: string;
17
+ subheading: string;
18
+ text: string;
19
+ body: string;
20
+ primaryButtonLabel: string;
21
+ primaryButtonHref: string;
22
+ secondaryButtonLabel: string;
23
+ secondaryButtonHref: string;
24
+ buttonLabel: string;
25
+ buttonHref: string;
26
+ bullets: string;
27
+ addBullet: string;
28
+ count: string;
29
+ stats: string;
30
+ source: string;
31
+ sourceStatic: string;
32
+ value: string;
33
+ label: string;
34
+ addStat: string;
35
+ layout: string;
36
+ layoutStandard: string;
37
+ layoutFull: string;
38
+ layoutLeft: string;
39
+ layoutRight: string;
40
+ layoutOverlay: string;
41
+ layoutOverlayFull: string;
42
+ altText: string;
43
+ caption: string;
44
+ image: string;
45
+ uploadImage: string;
46
+ replaceImage: string;
47
+ uploading: string;
48
+ /** The button that clears the picked image. */
49
+ removeImage: string;
50
+ uploadFailed: string;
51
+ }
52
+
53
+ export const defaultSectionsEditorStrings: SectionsEditorStrings = {
54
+ hide: "Hide",
55
+ show: "Show",
56
+ moveUp: "Move up",
57
+ moveDown: "Move down",
58
+ duplicate: "Duplicate",
59
+ remove: "Remove",
60
+ addSection: "Add section",
61
+ heading: "Heading",
62
+ headingOptional: "Heading (optional)",
63
+ subheading: "Subheading",
64
+ text: "Text",
65
+ body: "Body (markdown)",
66
+ primaryButtonLabel: "Primary button label",
67
+ primaryButtonHref: "Primary button link",
68
+ secondaryButtonLabel: "Secondary button label",
69
+ secondaryButtonHref: "Secondary button link",
70
+ buttonLabel: "Button label",
71
+ buttonHref: "Button link",
72
+ bullets: "Bullet points",
73
+ addBullet: "Add bullet",
74
+ count: "Number to show",
75
+ stats: "Stats",
76
+ source: "Source",
77
+ sourceStatic: "Manual number",
78
+ value: "Value",
79
+ label: "Label",
80
+ addStat: "Add stat",
81
+ layout: "Layout",
82
+ layoutStandard: "Standard (centered)",
83
+ layoutFull: "Full width",
84
+ layoutLeft: "Image left, text right",
85
+ layoutRight: "Image right, text left",
86
+ layoutOverlay: "Text over image",
87
+ layoutOverlayFull: "Text over image (full width)",
88
+ altText: "Alt text",
89
+ caption: "Caption (optional)",
90
+ image: "Image",
91
+ uploadImage: "Upload image",
92
+ replaceImage: "Replace image",
93
+ uploading: "Uploading…",
94
+ removeImage: "Remove",
95
+ uploadFailed: "Failed to upload image",
96
+ };
97
+
98
+ /** The default name of each shared section type, on its badge and in the add list. */
99
+ export const defaultSectionTypeLabels: Record<SharedSectionType, string> = {
100
+ hero: "Hero",
101
+ feature: "Feature",
102
+ text: "Text",
103
+ feed: "Feed",
104
+ contact: "Contact form",
105
+ cta: "Call to action",
106
+ stats: "Stats band",
107
+ image: "Image",
108
+ };
@@ -0,0 +1,107 @@
1
+ // Plain types, with no "use client", so a server component can import them.
2
+
3
+ /** The fields every section has. `type` picks the renderer and the editor. */
4
+ export interface BaseSection<T extends string = string> {
5
+ id: string;
6
+ type: T;
7
+ visible: boolean;
8
+ }
9
+
10
+ /** The page's opening band, with a heading and up to two buttons. */
11
+ export interface HeroSection extends BaseSection<"hero"> {
12
+ heading: string;
13
+ subheading: string;
14
+ primaryLabel: string;
15
+ primaryHref: string;
16
+ secondaryLabel: string;
17
+ secondaryHref: string;
18
+ }
19
+
20
+ /** A card with a heading, a short text and a list of ticked bullets. */
21
+ export interface FeatureSection extends BaseSection<"feature"> {
22
+ heading: string;
23
+ text: string;
24
+ bullets: string[];
25
+ }
26
+
27
+ /** A heading and a Markdown body. */
28
+ export interface TextSection extends BaseSection<"text"> {
29
+ heading: string;
30
+ body: string;
31
+ }
32
+
33
+ /** A list of the app's latest items. The app registers its renderer, since only it knows the items. */
34
+ export interface FeedSection extends BaseSection<"feed"> {
35
+ heading: string;
36
+ limit: number;
37
+ }
38
+
39
+ /** A heading and a text above the app's contact form. */
40
+ export interface ContactSection extends BaseSection<"contact"> {
41
+ heading: string;
42
+ text: string;
43
+ }
44
+
45
+ /** A band with a heading, a text and one button. */
46
+ export interface CtaSection extends BaseSection<"cta"> {
47
+ heading: string;
48
+ text: string;
49
+ primaryLabel: string;
50
+ primaryHref: string;
51
+ }
52
+
53
+ /** One number in a stats band. `static` shows `value`. Any other source is a live number the app looks up. */
54
+ export interface StatItem<Live extends string = never> {
55
+ source: "static" | Live;
56
+ value: string;
57
+ label: string;
58
+ }
59
+
60
+ /** A band of numbers. The app registers its renderer, since only it can look up the live numbers. */
61
+ export interface StatsSection<Live extends string = never> extends BaseSection<"stats"> {
62
+ heading: string;
63
+ items: StatItem<Live>[];
64
+ }
65
+
66
+ export type ImageLayout = "standard" | "full" | "left" | "right" | "overlay" | "overlayFull";
67
+
68
+ /** An uploaded image in one of the layouts, with a caption or a text. */
69
+ export interface ImageSection extends BaseSection<"image"> {
70
+ imageId: string | null;
71
+ alt: string;
72
+ caption: string;
73
+ layout: ImageLayout;
74
+ text: string;
75
+ }
76
+
77
+ /** The section types the library knows. */
78
+ export type SharedSectionType = "hero" | "feature" | "text" | "feed" | "contact" | "cta" | "stats" | "image";
79
+
80
+ /** Every shared section type, in the order the editor offers them. */
81
+ export const SHARED_SECTION_TYPES: readonly SharedSectionType[] = ["hero", "feature", "text", "feed", "contact", "cta", "stats", "image"];
82
+
83
+ /** The extra fields an app adds to a shared section, by type. */
84
+ export type SectionExtensions = { [K in SharedSectionType]?: object };
85
+
86
+ type Extra<E extends SectionExtensions, K extends SharedSectionType> = E[K] extends object ? E[K] : unknown;
87
+
88
+ /**
89
+ * The union of the shared sections, each with the app's extra fields. `Live` names the live
90
+ * sources a stats item can show. An app with its own section types adds them to the union.
91
+ *
92
+ * ```ts
93
+ * type Section = Sections<{ hero: { backgroundImageId: string | null } }, "builds" | "parts">;
94
+ * ```
95
+ */
96
+ export type Sections<E extends SectionExtensions = Record<never, never>, Live extends string = never> =
97
+ | (HeroSection & Extra<E, "hero">)
98
+ | (FeatureSection & Extra<E, "feature">)
99
+ | (TextSection & Extra<E, "text">)
100
+ | (FeedSection & Extra<E, "feed">)
101
+ | (ContactSection & Extra<E, "contact">)
102
+ | (CtaSection & Extra<E, "cta">)
103
+ | (StatsSection<Live> & Extra<E, "stats">)
104
+ | (ImageSection & Extra<E, "image">);
105
+
106
+ /** The member of a section union with the given type. */
107
+ export type SectionOf<S extends BaseSection, K extends S["type"]> = Extract<S, { type: K }>;
package/src/seo/index.ts CHANGED
@@ -4,6 +4,7 @@ export { ref, serializeGraph, type OrganizationInfo, type SchemaNode, type Schem
4
4
  export {
5
5
  defineSite,
6
6
  LEGAL_PAGE_KEYS,
7
+ type DefaultLegalPageKey,
7
8
  NOINDEX,
8
9
  SHARE_IMAGE_SIZE,
9
10
  type Crumb,
package/src/seo/site.ts CHANGED
@@ -2,6 +2,9 @@ import type { Metadata, MetadataRoute } from "next";
2
2
  import { isIndexableEnvironment } from "./environment";
3
3
  import { ref, type OrganizationInfo, type SchemaNode } from "./schema";
4
4
  import { parseTimestamp } from "./sitemap";
5
+ import { LEGAL_PAGE_KEYS, type DefaultLegalPageKey } from "../legal/keys";
6
+
7
+ export { LEGAL_PAGE_KEYS, type DefaultLegalPageKey } from "../legal/keys";
5
8
 
6
9
  /** The size of the share image. */
7
10
  export const SHARE_IMAGE_SIZE = { width: 1200, height: 630 } as const;
@@ -64,12 +67,9 @@ export interface LocaleEntryOptions<L extends string> {
64
67
  lastModified?: ((locale: L) => Date | undefined) | undefined;
65
68
  }
66
69
 
67
- /** The legal pages every site has, as `createLegalService` names them. */
68
- export const LEGAL_PAGE_KEYS = ["terms", "privacy", "cookies"] as const;
69
-
70
70
  /** Options for `legalEntries`. */
71
71
  export interface LegalEntriesInput<K extends string, L extends string> {
72
- /** The legal pages, each at `/<key>` below the locale. Defaults to `LEGAL_PAGE_KEYS`. */
72
+ /** The legal pages, each at `/<key>` below the locale, such as the legal service's `keys`. Defaults to `LEGAL_PAGE_KEYS`. */
73
73
  keys?: readonly K[] | undefined;
74
74
  /** A function that returns when a page last changed, as the API's timestamp. A site without locales passes no locale. */
75
75
  updatedAt: (key: K, locale: L | undefined) => Promise<string | null | undefined>;
@@ -221,7 +221,7 @@ export function defineSite<L extends string = string>(config: SiteConfig<L>) {
221
221
  * timestamps are fetched in parallel. An error from `updatedAt` fails the sitemap, so a stale one
222
222
  * is served rather than one without dates.
223
223
  */
224
- const legalEntries = async <K extends string = (typeof LEGAL_PAGE_KEYS)[number]>({
224
+ const legalEntries = async <K extends string = DefaultLegalPageKey>({
225
225
  keys,
226
226
  updatedAt,
227
227
  }: LegalEntriesInput<K, L>): Promise<MetadataRoute.Sitemap> => {
@@ -19,11 +19,13 @@ export const SESSION_ERRORS = {
19
19
  * The codes a failed sign-in reports in next-auth's `error`. Wrong credentials and every API answer
20
20
  * look the same, so the message never hints that a password was right. Only an API that does not
21
21
  * answer, or a gateway error, reports `unavailable`. That depends on the server, never on the
22
- * password.
22
+ * password. `lockedOut` is the API's `lockedOut` code after too many failed sign-ins, which the API
23
+ * gives whether the password was right or wrong.
23
24
  */
24
25
  export const SIGN_IN_ERRORS = {
25
26
  invalidCredentials: "InvalidCredentials",
26
27
  unavailable: "SignInUnavailable",
28
+ lockedOut: "LockedOut",
27
29
  } as const;
28
30
 
29
31
  /** One of the values in `SESSION_ERRORS`. */