@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/src/next-config/index.ts
CHANGED
|
@@ -1,128 +1,39 @@
|
|
|
1
1
|
import type { NextConfig } from "next";
|
|
2
|
+
import { contentImagesRewrite, securityHeaders, type CspOptions, type Header } from "./headers";
|
|
2
3
|
|
|
3
|
-
|
|
4
|
-
export interface Header {
|
|
5
|
-
key: string;
|
|
6
|
-
value: string;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
/** The API's origin, such as `https://api.example.com`. Empty when the URL is missing or invalid. */
|
|
10
|
-
export function apiOrigin(apiUrl: string | undefined): string {
|
|
11
|
-
if (!apiUrl) return "";
|
|
12
|
-
try {
|
|
13
|
-
return new URL(apiUrl).origin;
|
|
14
|
-
} catch {
|
|
15
|
-
return "";
|
|
16
|
-
}
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/** The API's base URL with its path, such as `https://api.example.com/api`, without a trailing slash. Empty when missing or invalid. */
|
|
20
|
-
export function apiBaseUrl(apiUrl: string | undefined): string {
|
|
21
|
-
if (!apiUrl) return "";
|
|
22
|
-
try {
|
|
23
|
-
return new URL(apiUrl).toString().replace(/\/$/, "");
|
|
24
|
-
} catch {
|
|
25
|
-
return "";
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export interface CspOptions {
|
|
30
|
-
/** The app's API URL, usually `process.env.NEXT_PUBLIC_API_URL`. Its origin may be fetched from. */
|
|
31
|
-
apiUrl: string | undefined;
|
|
32
|
-
/** Development builds also allow `'unsafe-eval'`, which hot reload needs. */
|
|
33
|
-
dev: boolean;
|
|
34
|
-
/** Also allow `ws:` and `wss:` to the API, for a WebSocket such as SignalR. */
|
|
35
|
-
websocket?: boolean | undefined;
|
|
36
|
-
/** Also allow images from the API origin. Not needed when images go through the content images rewrite. */
|
|
37
|
-
imgFromApi?: boolean | undefined;
|
|
38
|
-
/** Extra sources for `connect-src`. */
|
|
39
|
-
connectSrc?: readonly string[] | undefined;
|
|
40
|
-
/** Extra sources for `img-src`. */
|
|
41
|
-
imgSrc?: readonly string[] | undefined;
|
|
42
|
-
/** Sources for `frame-src`. Defaults to `'none'`, since no page embeds a frame. */
|
|
43
|
-
frameSrc?: readonly string[] | undefined;
|
|
44
|
-
/** Sources for `object-src`. Defaults to `'none'`, since no page embeds an object. */
|
|
45
|
-
objectSrc?: readonly string[] | undefined;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
const sources = (list: readonly (string | false | undefined)[]) => list.filter(Boolean).join(" ");
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* The Content-Security-Policy for an app's pages. Scripts allow `'unsafe-inline'`, because a nonce
|
|
52
|
-
* would make every statically rendered page dynamic.
|
|
53
|
-
*/
|
|
54
|
-
export function contentSecurityPolicy(o: CspOptions): string {
|
|
55
|
-
const origin = apiOrigin(o.apiUrl);
|
|
56
|
-
const wsOrigin = o.websocket ? origin.replace(/^https:/, "wss:").replace(/^http:/, "ws:") : "";
|
|
57
|
-
return [
|
|
58
|
-
"default-src 'self'",
|
|
59
|
-
o.dev ? "script-src 'self' 'unsafe-eval' 'unsafe-inline'" : "script-src 'self' 'unsafe-inline'",
|
|
60
|
-
"style-src 'self' 'unsafe-inline'",
|
|
61
|
-
`img-src ${sources(["'self'", o.imgFromApi && origin, "data:", "blob:", ...(o.imgSrc ?? [])])}`,
|
|
62
|
-
`connect-src ${sources(["'self'", origin, wsOrigin, ...(o.connectSrc ?? [])])}`,
|
|
63
|
-
`frame-src ${sources(o.frameSrc ?? ["'none'"])}`,
|
|
64
|
-
`object-src ${sources(o.objectSrc ?? ["'none'"])}`,
|
|
65
|
-
"font-src 'self'",
|
|
66
|
-
"base-uri 'self'",
|
|
67
|
-
"form-action 'self'",
|
|
68
|
-
"frame-ancestors 'none'",
|
|
69
|
-
"upgrade-insecure-requests",
|
|
70
|
-
].join("; ");
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/** The security headers for an app's pages, including the Content-Security-Policy. */
|
|
74
|
-
export function securityHeaders(o: CspOptions & { permissionsPolicy?: string | undefined }): Header[] {
|
|
75
|
-
return [
|
|
76
|
-
{ key: "X-Content-Type-Options", value: "nosniff" },
|
|
77
|
-
{ key: "X-Frame-Options", value: "DENY" },
|
|
78
|
-
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
|
|
79
|
-
{ key: "X-DNS-Prefetch-Control", value: "on" },
|
|
80
|
-
{ key: "Permissions-Policy", value: o.permissionsPolicy ?? "camera=(), microphone=(), geolocation=()" },
|
|
81
|
-
{ key: "Strict-Transport-Security", value: "max-age=31536000; includeSubDomains" },
|
|
82
|
-
{ key: "Content-Security-Policy", value: contentSecurityPolicy(o) },
|
|
83
|
-
];
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Content images, proxied so the browser never contacts the API host. A rewrite rather than a route
|
|
88
|
-
* handler, so the query string and the Accept header, which webp needs, pass through. No rewrite
|
|
89
|
-
* when the API URL is missing or invalid.
|
|
90
|
-
*/
|
|
91
|
-
export function contentImagesRewrite(apiUrl: string | undefined, o: { source?: string | undefined; upstreamPath?: string | undefined } = {}) {
|
|
92
|
-
const base = apiBaseUrl(apiUrl);
|
|
93
|
-
if (!base) return [];
|
|
94
|
-
const source = o.source ?? "/content-images";
|
|
95
|
-
const upstream = o.upstreamPath ?? "/content-images";
|
|
96
|
-
return [{ source: `${source}/:path*`, destination: `${base}${upstream}/:path*` }];
|
|
97
|
-
}
|
|
4
|
+
export type { CspOptions } from "./headers";
|
|
98
5
|
|
|
6
|
+
/** Options for `defineAppConfig`. */
|
|
99
7
|
export interface AppConfigOptions extends CspOptions {
|
|
100
|
-
/**
|
|
8
|
+
/**
|
|
9
|
+
* Whether to proxy `/content-images/*` to the API. The rewrite keeps the browser off the API host
|
|
10
|
+
* and passes the query string and the Accept header through.
|
|
11
|
+
*/
|
|
101
12
|
contentImages?: boolean | undefined;
|
|
102
13
|
/**
|
|
103
|
-
*
|
|
104
|
-
* they replace a page header of the same name.
|
|
14
|
+
* The headers for particular paths. Every path gets the page headers, and these come after them,
|
|
15
|
+
* so they replace a page header of the same name.
|
|
105
16
|
*/
|
|
106
17
|
pathHeaders?: readonly { source: string; headers: Header[] }[] | undefined;
|
|
107
|
-
/**
|
|
18
|
+
/** A Permissions-Policy that replaces the default one. */
|
|
108
19
|
permissionsPolicy?: string | undefined;
|
|
109
20
|
/**
|
|
110
|
-
*
|
|
111
|
-
* enough for a browser to hand back an old page and fetch the current one behind it.
|
|
112
|
-
* 300
|
|
21
|
+
* The number of seconds a cache may go on serving a page after it goes stale. Next's default is a
|
|
22
|
+
* year, long enough for a browser to hand back an old page and fetch the current one behind it.
|
|
23
|
+
* Defaults to `300`, matching the client router cache's stale time.
|
|
113
24
|
*/
|
|
114
25
|
expireTime?: number | undefined;
|
|
115
26
|
/**
|
|
116
|
-
*
|
|
117
|
-
* write to `.next/server/app`, so the incremental cache stays in memory.
|
|
27
|
+
* Whether to write revalidated pages to disk. Defaults to `false`. With a read-only root
|
|
28
|
+
* filesystem, Next cannot write to `.next/server/app`, so the incremental cache stays in memory.
|
|
118
29
|
*/
|
|
119
30
|
isrFlushToDisk?: boolean | undefined;
|
|
120
31
|
}
|
|
121
32
|
|
|
122
33
|
/**
|
|
123
|
-
*
|
|
124
|
-
* the security headers on every page, and optionally the content images rewrite.
|
|
125
|
-
* over it, with `transpilePackages` and `images` combined.
|
|
34
|
+
* Builds the shared Next config for an app: the standalone build, app-kit transpiled, no
|
|
35
|
+
* `X-Powered-By`, the security headers on every page, and optionally the content images rewrite.
|
|
36
|
+
* `extra` is merged over it, with `transpilePackages` and `images` combined.
|
|
126
37
|
*/
|
|
127
38
|
export function defineAppConfig(o: AppConfigOptions, extra: NextConfig = {}): NextConfig {
|
|
128
39
|
const pageHeaders = securityHeaders(o);
|
|
@@ -5,32 +5,34 @@ import Link from "next/link";
|
|
|
5
5
|
import type { ReactNode } from "react";
|
|
6
6
|
import { cx } from "../ui/cx";
|
|
7
7
|
|
|
8
|
+
/** The outcome of a payment, as `PaymentReturn` shows it. */
|
|
8
9
|
export type PaymentReturnState = "success" | "pending" | "failed";
|
|
9
10
|
|
|
10
|
-
/** The text and the
|
|
11
|
+
/** The text and the next step for one state. */
|
|
11
12
|
export interface PaymentReturnContent {
|
|
12
13
|
title: string;
|
|
13
14
|
body: string;
|
|
14
|
-
/**
|
|
15
|
+
/** The button's link target. */
|
|
15
16
|
href: string;
|
|
16
17
|
/** The button's text. */
|
|
17
18
|
cta: string;
|
|
18
19
|
/** A smaller line under the body. */
|
|
19
20
|
note?: string | undefined;
|
|
20
|
-
/**
|
|
21
|
+
/** Any more content under the body, such as a receipt link. */
|
|
21
22
|
extra?: ReactNode;
|
|
22
23
|
}
|
|
23
24
|
|
|
25
|
+
/** Props for `PaymentReturn`. */
|
|
24
26
|
export interface PaymentReturnProps {
|
|
25
27
|
state: PaymentReturnState;
|
|
26
28
|
success: PaymentReturnContent;
|
|
27
29
|
pending: PaymentReturnContent;
|
|
28
30
|
failed: PaymentReturnContent;
|
|
29
|
-
/**
|
|
31
|
+
/** A function that checks the status again. While the state is pending, a button runs it. */
|
|
30
32
|
onRefresh?: (() => void) | undefined;
|
|
31
33
|
/** The refresh button's text. Defaults to `Check again`. */
|
|
32
34
|
refreshLabel?: string | undefined;
|
|
33
|
-
/**
|
|
35
|
+
/** The classes for placing the component, on its outer element. */
|
|
34
36
|
className?: string | undefined;
|
|
35
37
|
}
|
|
36
38
|
|
|
@@ -42,8 +44,8 @@ const refresh = "px-4 py-2.5 rounded-payment-button bg-button-primary-bg/80 text
|
|
|
42
44
|
const secondary = "py-2.5 rounded-payment-button bg-payment-secondary-bg hover:bg-payment-secondary-hover-bg text-payment-secondary-text text-sm transition-colors";
|
|
43
45
|
|
|
44
46
|
/**
|
|
45
|
-
*
|
|
46
|
-
* The app decides the state, such as with `usePollUntilFinal`.
|
|
47
|
+
* Renders what a user sees on coming back from a payment provider: confirmed, still pending, or
|
|
48
|
+
* failed. The app decides the state, such as with `usePollUntilFinal`.
|
|
47
49
|
*/
|
|
48
50
|
export function PaymentReturn({ state, success, pending, failed, onRefresh, refreshLabel = "Check again", className }: PaymentReturnProps) {
|
|
49
51
|
const content = { success, pending, failed }[state];
|
|
@@ -2,26 +2,28 @@
|
|
|
2
2
|
|
|
3
3
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
4
4
|
|
|
5
|
+
/** What `usePollUntilFinal` returns. */
|
|
5
6
|
export interface PollResult<T> {
|
|
6
7
|
/** The latest value, or null before the first answer. */
|
|
7
8
|
data: T | null;
|
|
8
|
-
/** True until a final value, the last retry or a failed fetch. */
|
|
9
|
+
/** True until a final value, the last retry, or a failed fetch. */
|
|
9
10
|
loading: boolean;
|
|
10
11
|
/** Polls again from the start, with the spinner showing. */
|
|
11
12
|
refresh: () => void;
|
|
12
13
|
}
|
|
13
14
|
|
|
15
|
+
/** Options for `usePollUntilFinal`. */
|
|
14
16
|
export interface PollOptions {
|
|
15
|
-
/**
|
|
17
|
+
/** The number of fetches after the first. Defaults to `8`. */
|
|
16
18
|
retries?: number | undefined;
|
|
17
|
-
/**
|
|
19
|
+
/** The milliseconds between fetches. Defaults to `4000`. */
|
|
18
20
|
delayMs?: number | undefined;
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
/**
|
|
22
24
|
* Fetches until the value is final, such as an order a payment provider has not confirmed yet when
|
|
23
|
-
* the user comes back from it. Stops after the retries, on a failed fetch and on unmount. Each
|
|
24
|
-
* uses the latest `fetcher` and `isFinal` given.
|
|
25
|
+
* the user comes back from it. Stops after the retries, on a failed fetch, and on unmount. Each
|
|
26
|
+
* fetch uses the latest `fetcher` and `isFinal` given.
|
|
25
27
|
*/
|
|
26
28
|
export function usePollUntilFinal<T>(
|
|
27
29
|
fetcher: () => Promise<T | null>,
|
|
@@ -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
|
+
}
|