@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,275 @@
1
+ // No "use client" and no hooks, so a server component can render the sections.
2
+
3
+ import { CheckIcon } from "@heroicons/react/24/outline";
4
+ import type { ComponentType, ReactNode } from "react";
5
+ import { Markdown } from "../markdown/Markdown";
6
+ import { ContentImage } from "../ui/ContentImage";
7
+ import { SmartLink } from "./SmartLink";
8
+ import type { BaseSection, ContactSection, CtaSection, FeatureSection, HeroSection, ImageSection, SectionOf, TextSection } from "./types";
9
+
10
+ /** What a section renderer gets to draw an image. */
11
+ export interface SectionImageProps {
12
+ imageId: string;
13
+ alt: string;
14
+ /** The widths the image is shown at, for a responsive image. A plain image may ignore it. */
15
+ sizes: string;
16
+ className: string;
17
+ /** The intrinsic size, only where the image keeps its own ratio. */
18
+ width?: number | undefined;
19
+ height?: number | undefined;
20
+ }
21
+
22
+ /** What a link in a section gets. */
23
+ export interface SectionLinkProps {
24
+ href: string;
25
+ className?: string | undefined;
26
+ children: ReactNode;
27
+ }
28
+
29
+ /** Intrinsic image sizes by image id. */
30
+ export type ImageDimensions = Record<string, { width: number; height: number }>;
31
+
32
+ /** The app's pieces the renderers draw with. */
33
+ export interface SectionRenderContext {
34
+ /** Turns a button's path into the link it points at, such as one with the locale in front. Defaults to the path as it is. */
35
+ href: (path: string) => string;
36
+ /** The link for buttons. Defaults to `SmartLink`. */
37
+ Link: ComponentType<SectionLinkProps>;
38
+ /** The image for image sections. Defaults to `ContentImage`. */
39
+ Image: ComponentType<SectionImageProps>;
40
+ /** Intrinsic sizes by image id, for the image layouts that keep the image's ratio. */
41
+ imageDimensions: ImageDimensions;
42
+ }
43
+
44
+ /** Draws one section type. */
45
+ export type SectionRenderFn<S> = (section: S, context: SectionRenderContext) => ReactNode;
46
+
47
+ /** A renderer per section type. An app's renderers replace the defaults for the same type. */
48
+ export type SectionRenderers<S extends BaseSection> = {
49
+ [K in S["type"]]?: SectionRenderFn<SectionOf<S, K>>;
50
+ };
51
+
52
+ function ScrimText({ text, big }: { text: string; big?: boolean }) {
53
+ if (!text) return null;
54
+ return (
55
+ <div className="absolute inset-0 bg-black/40 flex items-center justify-center p-6">
56
+ <p className={`text-white font-black text-center whitespace-pre-line ${big ? "text-3xl sm:text-5xl max-w-3xl" : "text-2xl sm:text-4xl max-w-2xl"}`}>{text}</p>
57
+ </div>
58
+ );
59
+ }
60
+
61
+ /** The plain hero: the heading and the buttons on the primary color. */
62
+ export const heroRenderer: SectionRenderFn<HeroSection> = (section, { href, Link }) => (
63
+ <section className="bg-primary">
64
+ <div className="max-w-7xl mx-auto px-4 sm:px-6 py-20 sm:py-28">
65
+ <h1 className="text-4xl sm:text-6xl font-black tracking-tight text-primary-foreground max-w-3xl whitespace-pre-line">{section.heading}</h1>
66
+ {section.subheading && <p className="mt-5 text-lg text-primary-foreground/80 max-w-xl whitespace-pre-line">{section.subheading}</p>}
67
+ <div className="mt-8 flex flex-wrap gap-3">
68
+ {section.primaryLabel && (
69
+ <Link href={href(section.primaryHref || "#")} className="rounded-lg bg-gray-900 text-white font-semibold px-6 py-3 hover:bg-gray-800 transition">
70
+ {section.primaryLabel}
71
+ </Link>
72
+ )}
73
+ {section.secondaryLabel && (
74
+ <Link href={href(section.secondaryHref || "#")} className="rounded-lg bg-white text-gray-900 font-semibold px-6 py-3 hover:bg-gray-100 transition">
75
+ {section.secondaryLabel}
76
+ </Link>
77
+ )}
78
+ </div>
79
+ </div>
80
+ </section>
81
+ );
82
+
83
+ /** A card with the ticked bullets. Empty bullets are left out. */
84
+ export const featureRenderer: SectionRenderFn<FeatureSection> = section => (
85
+ <section className="max-w-7xl mx-auto px-4 sm:px-6 py-16">
86
+ {section.heading && <h2 className="text-2xl font-bold text-gray-900 mb-6">{section.heading}</h2>}
87
+ <div className="w-full sm:max-w-md rounded-2xl border border-gray-200 p-6">
88
+ {section.text && <p className="text-sm text-gray-600">{section.text}</p>}
89
+ <ul className="mt-4 space-y-2">
90
+ {section.bullets.filter(Boolean).map((item, i) => (
91
+ <li key={i} className="flex items-center gap-2 text-sm text-gray-700">
92
+ <CheckIcon className="h-4 w-4 text-gray-900 shrink-0" />
93
+ {item}
94
+ </li>
95
+ ))}
96
+ </ul>
97
+ </div>
98
+ </section>
99
+ );
100
+
101
+ /** Options for `createTextRenderer`. */
102
+ export interface TextRendererOptions {
103
+ /** The classes on the element around the Markdown body. Defaults to `space-y-4 text-gray-700`. */
104
+ bodyClassName?: string | undefined;
105
+ }
106
+
107
+ /** The heading and the Markdown body. */
108
+ export function createTextRenderer({ bodyClassName = "space-y-4 text-gray-700" }: TextRendererOptions = {}): SectionRenderFn<TextSection> {
109
+ return section => (
110
+ <section className="max-w-3xl mx-auto px-4 sm:px-6 py-12">
111
+ {section.heading && <h2 className="text-2xl font-bold text-gray-900 mb-4">{section.heading}</h2>}
112
+ <div className={bodyClassName}>
113
+ <Markdown>{section.body}</Markdown>
114
+ </div>
115
+ </section>
116
+ );
117
+ }
118
+
119
+ /** Options for `createContactRenderer`. */
120
+ export interface ContactRendererOptions {
121
+ /** The app's contact form, shown below the heading and the text. */
122
+ form?: ReactNode;
123
+ /** The classes on the section, which has the id `contact`. Defaults to `bg-gray-50 border-t border-gray-200`. */
124
+ className?: string | undefined;
125
+ }
126
+
127
+ /** The heading and the text above the app's contact form, as the `#contact` anchor. */
128
+ export function createContactRenderer({ form = null, className = "bg-gray-50 border-t border-gray-200" }: ContactRendererOptions = {}): SectionRenderFn<ContactSection> {
129
+ return section => (
130
+ <section id="contact" className={className}>
131
+ <div className="max-w-3xl mx-auto px-4 sm:px-6 py-16">
132
+ {section.heading && <h2 className="text-2xl font-bold text-gray-900">{section.heading}</h2>}
133
+ {section.text && <p className="mt-1 text-sm text-gray-600 mb-6">{section.text}</p>}
134
+ {form}
135
+ </div>
136
+ </section>
137
+ );
138
+ }
139
+
140
+ /** The band with a heading and one button, on the primary color. */
141
+ export const ctaRenderer: SectionRenderFn<CtaSection> = (section, { href, Link }) => (
142
+ <section className="bg-primary">
143
+ <div className="max-w-5xl mx-auto px-4 sm:px-6 py-14 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-6">
144
+ <div>
145
+ <h2 className="text-2xl sm:text-3xl font-black text-primary-foreground whitespace-pre-line">{section.heading}</h2>
146
+ {section.text && <p className="mt-2 text-primary-foreground/80 whitespace-pre-line">{section.text}</p>}
147
+ </div>
148
+ {section.primaryLabel && (
149
+ <Link href={href(section.primaryHref || "#")} className="shrink-0 rounded-lg bg-gray-900 text-white font-semibold px-6 py-3 hover:bg-gray-800 transition">
150
+ {section.primaryLabel}
151
+ </Link>
152
+ )}
153
+ </div>
154
+ </section>
155
+ );
156
+
157
+ /** Options for `createImageRenderer`. */
158
+ export interface ImageRendererOptions {
159
+ /**
160
+ * Whether the layouts that keep the image's ratio (standard, left and right) pass the intrinsic
161
+ * size from `imageDimensions` and add `h-auto`, so the browser reserves the space. Defaults to true.
162
+ */
163
+ intrinsicSize?: boolean | undefined;
164
+ }
165
+
166
+ /** An uploaded image in its layout. Nothing renders without an image. */
167
+ export function createImageRenderer({ intrinsicSize = true }: ImageRendererOptions = {}): SectionRenderFn<ImageSection> {
168
+ return (section, { Image, imageDimensions }) => {
169
+ if (section.imageId == null) return null;
170
+ const layout = section.layout ?? "standard";
171
+ const intrinsic = intrinsicSize ? imageDimensions[section.imageId] : undefined;
172
+ const size = intrinsicSize ? { width: intrinsic?.width, height: intrinsic?.height } : {};
173
+ const auto = intrinsicSize ? " h-auto" : "";
174
+
175
+ if (layout === "full") {
176
+ return (
177
+ <section className="py-8">
178
+ <Image imageId={section.imageId} alt={section.alt} sizes="100vw" className="w-full max-h-[70vh] object-cover" />
179
+ </section>
180
+ );
181
+ }
182
+
183
+ if (layout === "overlay") {
184
+ return (
185
+ <section className="max-w-7xl mx-auto px-4 sm:px-6 py-8">
186
+ <div className="relative rounded-2xl overflow-hidden">
187
+ <Image imageId={section.imageId} alt={section.alt} sizes="(max-width: 1280px) 100vw, 1280px" className="w-full h-[420px] object-cover" />
188
+ <ScrimText text={section.text} />
189
+ </div>
190
+ </section>
191
+ );
192
+ }
193
+
194
+ if (layout === "overlayFull") {
195
+ return (
196
+ <section className="relative">
197
+ <Image imageId={section.imageId} alt={section.alt} sizes="100vw" className="w-full h-[480px] object-cover" />
198
+ <ScrimText text={section.text} big />
199
+ </section>
200
+ );
201
+ }
202
+
203
+ if (layout === "left" || layout === "right") {
204
+ return (
205
+ <section className="max-w-7xl mx-auto px-4 sm:px-6 py-8">
206
+ <div className="grid sm:grid-cols-2 gap-8 items-center">
207
+ <Image
208
+ imageId={section.imageId}
209
+ alt={section.alt}
210
+ sizes="(max-width: 640px) 100vw, 640px"
211
+ {...size}
212
+ className={`w-full${auto} rounded-2xl ${layout === "right" ? "sm:order-2" : ""}`}
213
+ />
214
+ {section.text && <p className="text-gray-700 leading-relaxed whitespace-pre-line">{section.text}</p>}
215
+ </div>
216
+ </section>
217
+ );
218
+ }
219
+
220
+ return (
221
+ <section className="max-w-5xl mx-auto px-4 sm:px-6 py-8">
222
+ <figure>
223
+ <Image imageId={section.imageId} alt={section.alt} sizes="(max-width: 1024px) 100vw, 1024px" {...size} className={`w-full${auto} rounded-2xl`} />
224
+ {section.caption && <figcaption className="mt-2 text-center text-sm text-gray-500">{section.caption}</figcaption>}
225
+ </figure>
226
+ </section>
227
+ );
228
+ };
229
+ }
230
+
231
+ /**
232
+ * The renderers for the shared types. `feed` and `stats` have none, since only the app knows its
233
+ * items and its live numbers. `contact` renders no form until the app registers one through
234
+ * `createContactRenderer`.
235
+ */
236
+ const defaultSectionRenderers = {
237
+ hero: heroRenderer,
238
+ feature: featureRenderer,
239
+ text: createTextRenderer(),
240
+ contact: createContactRenderer(),
241
+ cta: ctaRenderer,
242
+ image: createImageRenderer(),
243
+ };
244
+
245
+ const defaultContext: SectionRenderContext = {
246
+ href: path => path,
247
+ Link: SmartLink,
248
+ Image: ContentImage,
249
+ imageDimensions: {},
250
+ };
251
+
252
+ /** Props for `SectionRenderer`. */
253
+ export interface SectionRendererProps<S extends BaseSection> {
254
+ section: S;
255
+ /** The app's renderers. They replace the defaults for the same type, and add the app's own types. */
256
+ renderers?: SectionRenderers<S> | undefined;
257
+ /** The app's link, image, path mapping and image sizes. Anything left out keeps its default. */
258
+ context?: Partial<SectionRenderContext> | undefined;
259
+ }
260
+
261
+ /**
262
+ * Renders one section through the renderer for its type. A hidden section, or one whose type has
263
+ * no renderer, renders nothing. It holds no state, so it works in a server component.
264
+ */
265
+ export function SectionRenderer<S extends BaseSection>({ section, renderers, context }: SectionRendererProps<S>) {
266
+ if (!section.visible) return null;
267
+ const all = { ...defaultSectionRenderers, ...renderers } as unknown as Record<string, SectionRenderFn<S> | undefined>;
268
+ const render = all[section.type];
269
+ if (!render) return null;
270
+ return <>{render(section, { ...defaultContext, ...definedOnly(context) })}</>;
271
+ }
272
+
273
+ function definedOnly(context: Partial<SectionRenderContext> | undefined): Partial<SectionRenderContext> {
274
+ return Object.fromEntries(Object.entries(context ?? {}).filter(([, value]) => value !== undefined));
275
+ }
@@ -0,0 +1,151 @@
1
+ "use client";
2
+
3
+ import { ArrowDownIcon, ArrowUpIcon, DocumentDuplicateIcon, EyeIcon, EyeSlashIcon, PlusIcon, TrashIcon } from "@heroicons/react/24/outline";
4
+ import { useState, type ComponentType } from "react";
5
+ import { withDefaults, type TextOverrides } from "../ui/withDefaults";
6
+ import {
7
+ ContactSectionEditor,
8
+ CtaSectionEditor,
9
+ FeatureSectionEditor,
10
+ FeedSectionEditor,
11
+ HeroSectionEditor,
12
+ ImageSectionEditor,
13
+ StatsSectionEditor,
14
+ TextSectionEditor,
15
+ type SectionEditorProps,
16
+ type SectionImages,
17
+ } from "./editors";
18
+ import { cloneSection as defaultClone } from "./factory";
19
+ import { defaultSectionsEditorStrings, defaultSectionTypeLabels, type SectionsEditorStrings } from "./sectionsEditorStrings";
20
+ import { SHARED_SECTION_TYPES, type BaseSection, type SectionOf } from "./types";
21
+
22
+ /** An editor per section type. An app's editors replace the defaults for the same type. */
23
+ export type SectionEditors<S extends BaseSection> = {
24
+ [K in S["type"]]?: ComponentType<SectionEditorProps<SectionOf<S, K>>>;
25
+ };
26
+
27
+ /** The editors for the shared types. The stats editor offers only the manual number until the app registers its sources. */
28
+ const defaultSectionEditors = {
29
+ hero: HeroSectionEditor,
30
+ feature: FeatureSectionEditor,
31
+ text: TextSectionEditor,
32
+ feed: FeedSectionEditor,
33
+ contact: ContactSectionEditor,
34
+ cta: CtaSectionEditor,
35
+ stats: StatsSectionEditor,
36
+ image: ImageSectionEditor,
37
+ };
38
+
39
+ /** Props for `SectionsEditor`. */
40
+ export interface SectionsEditorProps<S extends BaseSection> {
41
+ sections: S[];
42
+ onChange: (sections: S[]) => void;
43
+ /** Makes a new section of the type, such as one from `createSectionFactory`. */
44
+ createSection: (type: S["type"]) => S;
45
+ /** Copies a section with a new id. Defaults to `cloneSection`. */
46
+ cloneSection?: ((section: S) => S) | undefined;
47
+ /** The types the add list offers, in order. Defaults to `SHARED_SECTION_TYPES`. */
48
+ types?: readonly S["type"][] | undefined;
49
+ /** The type the add list starts on. Defaults to the first of `types`. */
50
+ initialAddType?: S["type"] | undefined;
51
+ /** The app's editors. They replace the defaults for the same type, and add the app's own types. */
52
+ editors?: SectionEditors<S> | undefined;
53
+ images: SectionImages;
54
+ strings?: TextOverrides<SectionsEditorStrings> | undefined;
55
+ /** The name of each type, on its badge and in the add list. Missing types keep the default, or show the type itself. */
56
+ typeLabels?: Partial<Record<S["type"], string>> | undefined;
57
+ /** Renders the cards and the add row without the wrapping element, for a page that already spaces its children. */
58
+ unwrapped?: boolean | undefined;
59
+ }
60
+
61
+ /**
62
+ * The admin editor for a page of sections. It is controlled: every change, whether an edit, a
63
+ * move, a copy, a removal or a new section, goes out through `onChange`.
64
+ */
65
+ export function SectionsEditor<S extends BaseSection>({
66
+ sections,
67
+ onChange,
68
+ createSection,
69
+ cloneSection = defaultClone,
70
+ types = SHARED_SECTION_TYPES as readonly string[] as readonly S["type"][],
71
+ initialAddType,
72
+ editors,
73
+ images,
74
+ strings,
75
+ typeLabels,
76
+ unwrapped = false,
77
+ }: SectionsEditorProps<S>) {
78
+ const text = withDefaults(defaultSectionsEditorStrings, strings);
79
+ const [addType, setAddType] = useState<S["type"] | undefined>(initialAddType ?? types[0]);
80
+ const labels: Record<string, string | undefined> = { ...defaultSectionTypeLabels, ...typeLabels };
81
+ const label = (type: string) => labels[type] ?? type;
82
+ const allEditors = { ...defaultSectionEditors, ...editors } as unknown as Record<string, ComponentType<SectionEditorProps<S>> | undefined>;
83
+
84
+ const patch = (id: string, changes: Partial<S>) => onChange(sections.map(s => (s.id === id ? ({ ...s, ...changes } as S) : s)));
85
+
86
+ const move = (index: number, dir: -1 | 1) => {
87
+ const target = index + dir;
88
+ if (target < 0 || target >= sections.length) return;
89
+ const next = [...sections];
90
+ [next[index], next[target]] = [next[target], next[index]];
91
+ onChange(next);
92
+ };
93
+
94
+ const duplicate = (index: number) => {
95
+ const next = [...sections];
96
+ next.splice(index + 1, 0, cloneSection(sections[index]));
97
+ onChange(next);
98
+ };
99
+
100
+ const content = (
101
+ <>
102
+ {sections.map((section, index) => {
103
+ const Editor = allEditors[section.type];
104
+ return (
105
+ <div key={section.id} className="rounded-2xl border border-gray-200 p-5">
106
+ <div className="flex items-center justify-between mb-4">
107
+ <span className="inline-flex items-center rounded-full bg-gray-100 px-3 py-1 text-xs font-semibold text-gray-700">{label(section.type)}</span>
108
+ <div className="flex items-center gap-1">
109
+ <button type="button" onClick={() => patch(section.id, { visible: !section.visible } as Partial<S>)} title={section.visible ? text.hide : text.show} className="p-2 rounded-lg text-gray-500 hover:text-gray-900 hover:bg-gray-100">
110
+ {section.visible ? <EyeIcon className="h-4 w-4" /> : <EyeSlashIcon className="h-4 w-4" />}
111
+ </button>
112
+ <button type="button" onClick={() => move(index, -1)} disabled={index === 0} title={text.moveUp} className="p-2 rounded-lg text-gray-500 hover:text-gray-900 hover:bg-gray-100 disabled:opacity-30">
113
+ <ArrowUpIcon className="h-4 w-4" />
114
+ </button>
115
+ <button type="button" onClick={() => move(index, 1)} disabled={index === sections.length - 1} title={text.moveDown} className="p-2 rounded-lg text-gray-500 hover:text-gray-900 hover:bg-gray-100 disabled:opacity-30">
116
+ <ArrowDownIcon className="h-4 w-4" />
117
+ </button>
118
+ <button type="button" onClick={() => duplicate(index)} title={text.duplicate} className="p-2 rounded-lg text-gray-500 hover:text-gray-900 hover:bg-gray-100">
119
+ <DocumentDuplicateIcon className="h-4 w-4" />
120
+ </button>
121
+ <button type="button" onClick={() => onChange(sections.filter(s => s.id !== section.id))} title={text.remove} className="p-2 rounded-lg text-red-500 hover:text-red-700 hover:bg-red-50">
122
+ <TrashIcon className="h-4 w-4" />
123
+ </button>
124
+ </div>
125
+ </div>
126
+ {Editor && <Editor section={section} patch={changes => patch(section.id, changes)} strings={text} images={images} />}
127
+ </div>
128
+ );
129
+ })}
130
+
131
+ <div className="flex items-center gap-2 pt-2">
132
+ <select value={addType} onChange={e => setAddType(e.target.value as S["type"])} className="rounded-lg border border-gray-300 px-3 py-2 text-sm">
133
+ {types.map(t => (
134
+ <option key={t} value={t}>
135
+ {label(t)}
136
+ </option>
137
+ ))}
138
+ </select>
139
+ <button
140
+ type="button"
141
+ onClick={() => addType !== undefined && onChange([...sections, createSection(addType)])}
142
+ 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"
143
+ >
144
+ <PlusIcon className="h-4 w-4" /> {text.addSection}
145
+ </button>
146
+ </div>
147
+ </>
148
+ );
149
+
150
+ return unwrapped ? content : <div className="space-y-4">{content}</div>;
151
+ }
@@ -0,0 +1,42 @@
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import type { ReactNode } from "react";
5
+
6
+ /** Props for `SmartLink`. */
7
+ export interface SmartLinkProps {
8
+ href: string;
9
+ className?: string | undefined;
10
+ children: ReactNode;
11
+ }
12
+
13
+ /**
14
+ * A link that scrolls smoothly to an anchor on the page, such as `#contact`, and goes through
15
+ * next/link for anything else.
16
+ */
17
+ export function SmartLink({ href, className, children }: SmartLinkProps) {
18
+ if (href.startsWith("#")) {
19
+ const id = href.slice(1);
20
+ return (
21
+ <a
22
+ href={href}
23
+ className={className}
24
+ onClick={e => {
25
+ const el = document.getElementById(id);
26
+ if (el) {
27
+ e.preventDefault();
28
+ el.scrollIntoView({ behavior: "smooth" });
29
+ history.replaceState(null, "", href);
30
+ }
31
+ }}
32
+ >
33
+ {children}
34
+ </a>
35
+ );
36
+ }
37
+ return (
38
+ <Link href={href} className={className}>
39
+ {children}
40
+ </Link>
41
+ );
42
+ }