@sjolystinnovation/app-kit 0.13.0 → 0.15.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 CHANGED
@@ -28,7 +28,7 @@ const nextConfig: NextConfig = {
28
28
  };
29
29
  ```
30
30
 
31
- The components in `@sjolystinnovation/app-kit/ui`, `markdown` and `session/react` are styled with Tailwind CSS.
31
+ The components in `@sjolystinnovation/app-kit/ui`, `markdown`, `branding`, `payments` and `session/react` are styled with Tailwind CSS.
32
32
  Import the package's stylesheet after Tailwind:
33
33
 
34
34
  ```css
@@ -460,6 +460,111 @@ export async function GET(_request: Request, { params }: { params: Promise<{ loc
460
460
  It is a route rather than the `opengraph-image` file convention, since a page's own `openGraph`
461
461
  block replaces that image.
462
462
 
463
+ ### `@sjolystinnovation/app-kit/branding`
464
+
465
+ The logo and favicon an admin uploads, as the API's `/branding` endpoint returns them.
466
+
467
+ `BrandingProvider` holds them for the components below it. It takes the branding the server
468
+ rendered with, loads it on mount when there is none, and loads it again every five minutes, so an
469
+ open tab picks up a change. A failed load keeps what it has. `useBranding()` gives `branding`,
470
+ `logoUrl` as a `data:` URL or null, and `refresh()`.
471
+
472
+ ```tsx
473
+ "use client";
474
+ import { BrandingProvider, useBranding } from "@sjolystinnovation/app-kit/branding";
475
+
476
+ <BrandingProvider load={BrandingService.get} initial={initialBranding}>{children}</BrandingProvider>;
477
+
478
+ const { logoUrl } = useBranding();
479
+ <img src={logoUrl ?? "/logo.png"} alt="" />;
480
+ ```
481
+
482
+ `BrandingManager` is the admin form for uploading or removing them. It needs a `BrandingProvider`
483
+ above it, and refreshes it after a save.
484
+
485
+ ```tsx
486
+ <BrandingManager save={form => BrandingService.update(form)} />
487
+ ```
488
+
489
+ | Prop | Type | Description |
490
+ | --- | --- | --- |
491
+ | `save` | `(form: FormData) => Promise<unknown>` | Sends `logo` and `icon` files, and `removeLogo` and `removeIcon` set to `true`. Throw to show the error's message. |
492
+ | `strings` | `TextOverrides<BrandingManagerStrings>` | The form's text. |
493
+
494
+ `toDataUrl(data, contentType)` turns base64 bytes into a `data:` URL.
495
+
496
+ ### `@sjolystinnovation/app-kit/branding/server`
497
+
498
+ `brandingIcon({ load, fallbackPath, fallbackType })` is the response for an `app/icon.ts` route:
499
+ the favicon set in admin settings when it is a PNG, JPEG, WebP, GIF or ICO, or the bundled file
500
+ otherwise, since an SVG or HTML upload could run script on the site's origin. Both carry `nosniff`
501
+ and a sandbox CSP, which a CSP header from the app's `next.config` replaces. When the branding cannot be
502
+ loaded it answers 503 with `no-store`, since a browser would cache the bundled icon in place of the
503
+ one set in settings.
504
+
505
+ ```ts
506
+ // src/app/icon.ts
507
+ import { join } from "node:path";
508
+ import { brandingIcon } from "@sjolystinnovation/app-kit/branding/server";
509
+
510
+ export const dynamic = "force-dynamic";
511
+
512
+ export default function Icon() {
513
+ return brandingIcon({
514
+ load: () => publicGet<Branding>("/branding", { tags: ["branding"] }),
515
+ fallbackPath: join(process.cwd(), "public", "icon.png"),
516
+ fallbackType: "image/png",
517
+ });
518
+ }
519
+ ```
520
+
521
+ The route needs `dynamic = "force-dynamic"`, or the build bakes in the icon of the moment. Tag the
522
+ fetch and revalidate the tag after a save, so a new icon shows at once. `iconVersion(data)` is a
523
+ short digest of the icon bytes, for a manifest icon URL such as `/icon?v=<digest>`, since Android
524
+ refreshes a home screen icon only when the manifest changes.
525
+
526
+ ### `@sjolystinnovation/app-kit/payments`
527
+
528
+ The page a user comes back to from a payment provider, such as after approving a Vipps payment or
529
+ agreement. The .NET side of the Vipps calls is `SjolystInnovation.AppKit.Vipps` on NuGet.
530
+
531
+ `usePollUntilFinal(fetcher, isFinal, { retries, delayMs })` fetches until the value is final, since
532
+ the provider may confirm a moment after the user is back. It polls every 4 seconds, 8 more times at
533
+ most, and stops on a failed fetch and on unmount. It gives `data`, `loading` and `refresh()`, and
534
+ each fetch uses the latest `fetcher` and `isFinal`.
535
+
536
+ `PaymentReturn` shows the outcome: confirmed, still pending, or failed. It takes the text and the way
537
+ on for each state, and a refresh button while pending when given `onRefresh`.
538
+
539
+ ```tsx
540
+ "use client";
541
+ import { PaymentReturn, usePollUntilFinal } from "@sjolystinnovation/app-kit/payments";
542
+
543
+ const { data: order, loading, refresh } = usePollUntilFinal(
544
+ () => ShopService.getOrder(orderId),
545
+ o => o != null && o.status !== "Pending",
546
+ );
547
+ if (loading) return <Spinner />;
548
+
549
+ <PaymentReturn
550
+ state={order?.status === "Paid" ? "success" : order?.status === "Pending" ? "pending" : "failed"}
551
+ success={{ title: "Payment received!", body: "Your order is confirmed.", href: "/shop", cta: "Back to shop" }}
552
+ pending={{ title: "Payment pending", body: "Vipps is confirming it.", note: "We'll email a receipt.", href: "/profile/billing", cta: "View billing" }}
553
+ failed={{ title: "Payment failed", body: "No charge was made.", href: "/shop", cta: "Try again" }}
554
+ onRefresh={refresh}
555
+ />;
556
+ ```
557
+
558
+ | Prop | Type | Description |
559
+ | --- | --- | --- |
560
+ | `state` | `"success" \| "pending" \| "failed"` | Which content to show. |
561
+ | `success`, `pending`, `failed` | `{ title, body, href, cta, note?, extra? }` | The text, the button's target and text, a smaller note line and anything more, per state. |
562
+ | `onRefresh` | `() => void` | Shows a refresh button while pending. |
563
+ | `refreshLabel` | `string` | Its text. Defaults to `Check again`. |
564
+ | `className` | `string` | Classes for placing it, on the outer element. |
565
+
566
+ `formatNok(ore)` formats øre as kroner, such as `NOK 373,75`.
567
+
463
568
  ### `@sjolystinnovation/app-kit/session`
464
569
 
465
570
  Decides when a user has to sign in again. Safe to import from server code, such as the next-auth
@@ -953,6 +1058,10 @@ selector such as `.dark` for a scoped theme:
953
1058
  | `--color-markdown-link-hover` | gray 600 |
954
1059
  | `--color-markdown-border` | gray 200, rules, quotes and table lines |
955
1060
  | `--color-markdown-code-bg`, `-code-text` | gray 100, gray 900 |
1061
+ | `--radius-payment-button` | `var(--radius-button)`, the buttons on a `PaymentReturn` |
1062
+ | `--color-payment-success`, `-pending`, `-failed` | green 600, amber 500, red 600, the icon on a `PaymentReturn` |
1063
+ | `--color-payment-title`, `-text`, `-note` | gray 900, 600, 500 |
1064
+ | `--color-payment-secondary-bg`, `-hover-bg`, `-text` | gray 100, 200, 700, its secondary link |
956
1065
 
957
1066
  A default that points at another variable is read once for the whole page. The focus ring and the
958
1067
  primary button point at `--color-primary` and `--color-primary-foreground`. A scoped theme that
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjolystinnovation/app-kit",
3
- "version": "0.13.0",
3
+ "version": "0.15.0",
4
4
  "description": "Building blocks for Next.js apps that sign in against a JWT API through next-auth",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Sjølyst Innovation AS",
@@ -29,6 +29,9 @@
29
29
  "./markdown": "./src/markdown/index.ts",
30
30
  "./seo": "./src/seo/index.ts",
31
31
  "./seo/og": "./src/seo/og/index.tsx",
32
+ "./branding": "./src/branding/index.ts",
33
+ "./branding/server": "./src/branding/server.ts",
34
+ "./payments": "./src/payments/index.ts",
32
35
  "./styles.css": "./src/styles.css"
33
36
  },
34
37
  "files": [
@@ -0,0 +1,185 @@
1
+ "use client";
2
+
3
+ import { ArrowUpTrayIcon, TrashIcon } from "@heroicons/react/24/outline";
4
+ import { useEffect, useRef, useState } from "react";
5
+ import { toast } from "../toast";
6
+ import { withDefaults, type TextOverrides } from "../ui/withDefaults";
7
+ import { useBranding } from "./BrandingProvider";
8
+ import { toDataUrl } from "./types";
9
+
10
+ export interface BrandingManagerStrings {
11
+ title: string;
12
+ logo: string;
13
+ logoHint: string;
14
+ icon: string;
15
+ iconHint: string;
16
+ none: string;
17
+ choose: string;
18
+ remove: string;
19
+ save: string;
20
+ saving: string;
21
+ saved: string;
22
+ saveFailed: string;
23
+ }
24
+
25
+ export const defaultBrandingManagerStrings: BrandingManagerStrings = {
26
+ title: "Branding",
27
+ logo: "Logo",
28
+ logoHint: "Shown in the navbar and footer. PNG/SVG, max 2 MB.",
29
+ icon: "Icon (favicon)",
30
+ iconHint: "Browser tab icon. Square PNG/ICO, max 2 MB.",
31
+ none: "none",
32
+ choose: "Choose",
33
+ remove: "Remove",
34
+ save: "Save branding",
35
+ saving: "Saving…",
36
+ saved: "Branding saved",
37
+ saveFailed: "Failed to save branding",
38
+ };
39
+
40
+ export interface BrandingManagerProps {
41
+ /**
42
+ * Sends the form: `logo` and `icon` files, and `removeLogo` and `removeIcon` set to `true`. Throw
43
+ * to show the error's message, such as one from `formatApiError`.
44
+ */
45
+ save: (form: FormData) => Promise<unknown>;
46
+ strings?: TextOverrides<BrandingManagerStrings> | undefined;
47
+ }
48
+
49
+ /** A preview is an object URL for the chosen file or a data URL of a raster image. Anything else shows none. */
50
+ const PREVIEW_URL = /^(blob:|data:image\/(png|jpeg|webp|gif|x-icon|vnd\.microsoft\.icon);base64,)/i;
51
+ const previewUrl = (url: string | null): string | null => (url !== null && PREVIEW_URL.test(url) ? url : null);
52
+
53
+ interface SlotProps {
54
+ label: string;
55
+ hint: string;
56
+ currentUrl: string | null;
57
+ file: File | null;
58
+ onPick: (file: File | null) => void;
59
+ onClear: () => void;
60
+ text: BrandingManagerStrings;
61
+ }
62
+
63
+ function Slot({ label, hint, currentUrl, file, onPick, onClear, text }: SlotProps) {
64
+ const inputRef = useRef<HTMLInputElement>(null);
65
+ const [objectUrl, setObjectUrl] = useState<string | null>(null);
66
+
67
+ // An object URL is an outside resource with a lifetime, which is what an effect is for. Creating
68
+ // it during render instead leaks the second URL that StrictMode builds when it renders twice.
69
+ /* eslint-disable react-hooks/set-state-in-effect */
70
+ useEffect(() => {
71
+ if (!file) {
72
+ setObjectUrl(null);
73
+ return;
74
+ }
75
+ const url = URL.createObjectURL(file);
76
+ setObjectUrl(url);
77
+ return () => URL.revokeObjectURL(url);
78
+ }, [file]);
79
+ /* eslint-enable react-hooks/set-state-in-effect */
80
+
81
+ const preview = previewUrl(objectUrl ?? currentUrl);
82
+ return (
83
+ <div>
84
+ <label className="block text-sm font-medium text-gray-700 mb-1">{label}</label>
85
+ <div className="flex items-center gap-3">
86
+ <div className="h-14 w-14 rounded-lg border border-gray-200 bg-gray-50 flex items-center justify-center overflow-hidden">
87
+ {preview ? (
88
+ <img src={preview} alt={label} className="max-h-full max-w-full object-contain" />
89
+ ) : (
90
+ <span className="text-xs text-gray-400">{text.none}</span>
91
+ )}
92
+ </div>
93
+ <input
94
+ ref={inputRef}
95
+ type="file"
96
+ accept="image/*"
97
+ aria-label={label}
98
+ className="hidden"
99
+ onChange={e => onPick(e.target.files?.[0] ?? null)}
100
+ />
101
+ <button
102
+ type="button"
103
+ onClick={() => inputRef.current?.click()}
104
+ className="inline-flex items-center gap-1.5 rounded-lg bg-gray-100 text-gray-700 font-medium px-3 py-2 text-sm hover:bg-gray-200"
105
+ >
106
+ <ArrowUpTrayIcon className="h-4 w-4" /> {text.choose}
107
+ </button>
108
+ {(currentUrl || file) && (
109
+ <button type="button" onClick={onClear} className="p-2 rounded-lg text-red-500 hover:bg-red-50" title={text.remove} aria-label={`${text.remove} ${label}`}>
110
+ <TrashIcon className="h-4 w-4" />
111
+ </button>
112
+ )}
113
+ </div>
114
+ <p className="mt-1 text-xs text-gray-500">{hint}</p>
115
+ </div>
116
+ );
117
+ }
118
+
119
+ /** The admin form for uploading or removing the logo and the favicon. Needs a `BrandingProvider` above it. */
120
+ export function BrandingManager({ save, strings }: BrandingManagerProps) {
121
+ const text = withDefaults(defaultBrandingManagerStrings, strings);
122
+ const { branding, refresh } = useBranding();
123
+ const [logo, setLogo] = useState<File | null>(null);
124
+ const [icon, setIcon] = useState<File | null>(null);
125
+ const [removeLogo, setRemoveLogo] = useState(false);
126
+ const [removeIcon, setRemoveIcon] = useState(false);
127
+ const [saving, setSaving] = useState(false);
128
+
129
+ const currentLogo = removeLogo ? null : toDataUrl(branding.logoData, branding.logoContentType);
130
+ const currentIcon = removeIcon ? null : toDataUrl(branding.iconData, branding.iconContentType);
131
+
132
+ const submit = async () => {
133
+ setSaving(true);
134
+ try {
135
+ const form = new FormData();
136
+ if (logo) form.append("logo", logo);
137
+ if (icon) form.append("icon", icon);
138
+ if (removeLogo) form.append("removeLogo", "true");
139
+ if (removeIcon) form.append("removeIcon", "true");
140
+ await save(form);
141
+ setLogo(null);
142
+ setIcon(null);
143
+ setRemoveLogo(false);
144
+ setRemoveIcon(false);
145
+ refresh();
146
+ toast.success(text.saved);
147
+ } catch (err) {
148
+ toast.error(err instanceof Error && err.message ? err.message : text.saveFailed);
149
+ } finally {
150
+ setSaving(false);
151
+ }
152
+ };
153
+
154
+ return (
155
+ <div className="max-w-md space-y-5 border-t border-gray-200 pt-6">
156
+ <h2 className="font-bold text-gray-900">{text.title}</h2>
157
+ <Slot
158
+ label={text.logo}
159
+ hint={text.logoHint}
160
+ currentUrl={currentLogo}
161
+ file={logo}
162
+ onPick={f => { setLogo(f); setRemoveLogo(false); }}
163
+ onClear={() => { setLogo(null); setRemoveLogo(true); }}
164
+ text={text}
165
+ />
166
+ <Slot
167
+ label={text.icon}
168
+ hint={text.iconHint}
169
+ currentUrl={currentIcon}
170
+ file={icon}
171
+ onPick={f => { setIcon(f); setRemoveIcon(false); }}
172
+ onClear={() => { setIcon(null); setRemoveIcon(true); }}
173
+ text={text}
174
+ />
175
+ <button
176
+ type="button"
177
+ onClick={submit}
178
+ disabled={saving || (!logo && !icon && !removeLogo && !removeIcon)}
179
+ className="rounded-button bg-button-primary-bg text-button-primary-text font-semibold px-5 py-2.5 hover:brightness-95 disabled:opacity-60 transition"
180
+ >
181
+ {saving ? text.saving : text.save}
182
+ </button>
183
+ </div>
184
+ );
185
+ }
@@ -0,0 +1,53 @@
1
+ "use client";
2
+
3
+ import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from "react";
4
+ import { toDataUrl, type Branding } from "./types";
5
+
6
+ export interface BrandingContextValue {
7
+ branding: Branding;
8
+ /** The logo as a `data:` URL, or null when none is set. */
9
+ logoUrl: string | null;
10
+ /** Loads the branding again, such as after saving it. */
11
+ refresh: () => void;
12
+ }
13
+
14
+ const BrandingContext = createContext<BrandingContextValue>({ branding: {}, logoUrl: null, refresh: () => {} });
15
+
16
+ /** The branding from the nearest `BrandingProvider`. Empty outside one. */
17
+ export const useBranding = () => useContext(BrandingContext);
18
+
19
+ /** How often the provider loads the branding again, so an open tab picks up a change. */
20
+ export const BRANDING_REFRESH_MS = 5 * 60 * 1000;
21
+
22
+ export interface BrandingProviderProps {
23
+ children: ReactNode;
24
+ /** Loads the branding, such as a GET of `/branding`. A failure keeps the branding it has. */
25
+ load: () => Promise<Branding>;
26
+ /** The branding the server rendered with. Without it, the provider loads it on mount. */
27
+ initial?: Branding | undefined;
28
+ }
29
+
30
+ /** Holds the logo and favicon for the components below it, and loads them again every five minutes. */
31
+ export function BrandingProvider({ children, load, initial }: BrandingProviderProps) {
32
+ const [branding, setBranding] = useState<Branding>(initial ?? {});
33
+ // The latest load, so an inline function does not restart the refresh timer on every render.
34
+ const loadRef = useRef(load);
35
+ useEffect(() => {
36
+ loadRef.current = load;
37
+ }, [load]);
38
+ const hasInitial = initial !== undefined;
39
+
40
+ const refresh = useCallback(() => {
41
+ loadRef.current().then(setBranding).catch(() => {});
42
+ }, []);
43
+
44
+ useEffect(() => {
45
+ if (!hasInitial) refresh();
46
+ const id = setInterval(refresh, BRANDING_REFRESH_MS);
47
+ return () => clearInterval(id);
48
+ }, [refresh, hasInitial]);
49
+
50
+ const logoUrl = toDataUrl(branding.logoData, branding.logoContentType);
51
+
52
+ return <BrandingContext.Provider value={{ branding, logoUrl, refresh }}>{children}</BrandingContext.Provider>;
53
+ }
@@ -0,0 +1,14 @@
1
+ export {
2
+ BrandingManager,
3
+ defaultBrandingManagerStrings,
4
+ type BrandingManagerProps,
5
+ type BrandingManagerStrings,
6
+ } from "./BrandingManager";
7
+ export {
8
+ BRANDING_REFRESH_MS,
9
+ BrandingProvider,
10
+ useBranding,
11
+ type BrandingContextValue,
12
+ type BrandingProviderProps,
13
+ } from "./BrandingProvider";
14
+ export { toDataUrl, type Branding } from "./types";
@@ -0,0 +1,54 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFile } from "node:fs/promises";
3
+ import type { Branding } from "./types";
4
+
5
+ export interface BrandingIconOptions {
6
+ /** Loads the branding, such as a cached GET of `/branding`. Throws when the API cannot be reached. */
7
+ load: () => Promise<Branding | null | undefined>;
8
+ /** The bundled icon for when none is set, such as `public/icon.png`. */
9
+ fallbackPath: string;
10
+ /** Its content type, such as `image/png` or `image/svg+xml`. */
11
+ fallbackType: string;
12
+ }
13
+
14
+ /**
15
+ * The response for an `app/icon.ts` route: the favicon set in admin settings when it is a PNG,
16
+ * JPEG, WebP, GIF or ICO, or the bundled one otherwise. Both carry `nosniff`, and a sandbox CSP
17
+ * that a CSP header from the app's `next.config` replaces.
18
+ * When the branding cannot be loaded it answers 503 with `no-store`, not the
19
+ * bundled icon, since a browser would cache that in place of the one set in settings. The route
20
+ * needs `dynamic = "force-dynamic"`, or the build bakes in the icon of the moment.
21
+ */
22
+ export async function brandingIcon({ load, fallbackPath, fallbackType }: BrandingIconOptions): Promise<Response> {
23
+ let branding: Branding | null | undefined;
24
+ try {
25
+ branding = await load();
26
+ } catch {
27
+ return new Response(null, { status: 503, headers: { "Cache-Control": "no-store" } });
28
+ }
29
+
30
+ const type = branding?.iconContentType?.trim().toLowerCase();
31
+ if (branding?.iconData && type && RASTER_TYPES.has(type)) {
32
+ return new Response(new Uint8Array(Buffer.from(branding.iconData, "base64")), {
33
+ headers: { "Content-Type": type, ...HARDENING },
34
+ });
35
+ }
36
+
37
+ return new Response(new Uint8Array(await readFile(fallbackPath)), { headers: { "Content-Type": fallbackType, ...HARDENING } });
38
+ }
39
+
40
+ /**
41
+ * The uploaded types the icon route serves. An SVG or HTML upload could carry script and would run
42
+ * on the site's origin, so anything else gives the bundled icon instead.
43
+ */
44
+ const RASTER_TYPES = new Set(["image/png", "image/jpeg", "image/webp", "image/gif", "image/x-icon", "image/vnd.microsoft.icon"]);
45
+
46
+ /** Keeps a browser from sniffing the bytes as another type, and from running anything in them where the app sets no CSP. */
47
+ const HARDENING = { "X-Content-Type-Options": "nosniff", "Content-Security-Policy": "default-src 'none'; sandbox" };
48
+
49
+ /**
50
+ * A short digest of icon bytes, for a URL that changes when the icon does, such as
51
+ * `/icon?v=<digest>` in the web app manifest. Android refreshes a home screen icon only when the
52
+ * manifest changes.
53
+ */
54
+ export const iconVersion = (data: string | Buffer): string => createHash("sha256").update(data).digest("hex").slice(0, 12);
@@ -0,0 +1,13 @@
1
+ /** The logo and favicon set in admin settings, as the API's `/branding` endpoint returns them. */
2
+ export interface Branding {
3
+ /** Base64 image bytes. */
4
+ logoData?: string | null | undefined;
5
+ logoContentType?: string | null | undefined;
6
+ /** Base64 image bytes. */
7
+ iconData?: string | null | undefined;
8
+ iconContentType?: string | null | undefined;
9
+ }
10
+
11
+ /** A `data:` URL for base64 image bytes, or null when either part is missing. */
12
+ export const toDataUrl = (data?: string | null, contentType?: string | null): string | null =>
13
+ data && contentType ? `data:${contentType};base64,${data}` : null;
@@ -0,0 +1,73 @@
1
+ "use client";
2
+
3
+ import { ArrowPathIcon, CheckCircleIcon, ClockIcon, XCircleIcon } from "@heroicons/react/24/outline";
4
+ import Link from "next/link";
5
+ import type { ReactNode } from "react";
6
+ import { cx } from "../ui/cx";
7
+
8
+ export type PaymentReturnState = "success" | "pending" | "failed";
9
+
10
+ /** The text and the way on for one state. */
11
+ export interface PaymentReturnContent {
12
+ title: string;
13
+ body: string;
14
+ /** Where the button goes. */
15
+ href: string;
16
+ /** The button's text. */
17
+ cta: string;
18
+ /** A smaller line under the body. */
19
+ note?: string | undefined;
20
+ /** Anything more under the body, such as a receipt link. */
21
+ extra?: ReactNode;
22
+ }
23
+
24
+ export interface PaymentReturnProps {
25
+ state: PaymentReturnState;
26
+ success: PaymentReturnContent;
27
+ pending: PaymentReturnContent;
28
+ failed: PaymentReturnContent;
29
+ /** Checks the status again. Shows a button for it while pending. */
30
+ onRefresh?: (() => void) | undefined;
31
+ /** The refresh button's text. Defaults to `Check again`. */
32
+ refreshLabel?: string | undefined;
33
+ /** Classes for placing it, on the outer element. */
34
+ className?: string | undefined;
35
+ }
36
+
37
+ const ICONS = { success: CheckCircleIcon, pending: ClockIcon, failed: XCircleIcon } as const;
38
+ const ICON_COLOURS = { success: "text-payment-success", pending: "text-payment-pending", failed: "text-payment-failed" } as const;
39
+
40
+ const primary = "px-5 py-2.5 rounded-payment-button bg-button-primary-bg text-button-primary-text hover:brightness-110 text-sm font-medium transition";
41
+ const refresh = "px-4 py-2.5 rounded-payment-button bg-button-primary-bg/80 text-button-primary-text hover:bg-button-primary-bg text-sm font-medium transition-colors flex items-center gap-2";
42
+ 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
+
44
+ /**
45
+ * What a user sees on coming back from a payment provider: confirmed, still pending, or failed.
46
+ * The app decides the state, such as with `usePollUntilFinal`.
47
+ */
48
+ export function PaymentReturn({ state, success, pending, failed, onRefresh, refreshLabel = "Check again", className }: PaymentReturnProps) {
49
+ const content = { success, pending, failed }[state];
50
+ const Icon = ICONS[state];
51
+ return (
52
+ <div className={cx("max-w-md mx-auto px-4 py-12 flex flex-col items-center gap-5 text-center", className)}>
53
+ <Icon className={cx("h-16 w-16", ICON_COLOURS[state])} aria-hidden />
54
+ <h1 className="text-xl font-bold text-payment-title">{content.title}</h1>
55
+ <p className="text-sm text-payment-text">{content.body}</p>
56
+ {content.note && <p className="text-xs text-payment-note">{content.note}</p>}
57
+ {content.extra}
58
+ {state === "pending" ? (
59
+ <div className="flex gap-2">
60
+ {onRefresh && (
61
+ <button type="button" onClick={onRefresh} className={refresh}>
62
+ <ArrowPathIcon className="h-4 w-4" aria-hidden />
63
+ {refreshLabel}
64
+ </button>
65
+ )}
66
+ <Link href={content.href} className={cx(secondary, "px-4")}>{content.cta}</Link>
67
+ </div>
68
+ ) : (
69
+ <Link href={content.href} className={state === "success" ? primary : cx(secondary, "px-5")}>{content.cta}</Link>
70
+ )}
71
+ </div>
72
+ );
73
+ }
@@ -0,0 +1,4 @@
1
+ /** An amount in øre as Norwegian kroner, such as `NOK 373,75`. */
2
+ export function formatNok(ore: number): string {
3
+ return `NOK ${(ore / 100).toFixed(2).replace(".", ",")}`;
4
+ }
@@ -0,0 +1,3 @@
1
+ export { formatNok } from "./formatNok";
2
+ export { PaymentReturn, type PaymentReturnContent, type PaymentReturnProps, type PaymentReturnState } from "./PaymentReturn";
3
+ export { usePollUntilFinal, type PollOptions, type PollResult } from "./usePollUntilFinal";
@@ -0,0 +1,79 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useRef, useState } from "react";
4
+
5
+ export interface PollResult<T> {
6
+ /** The latest value, or null before the first answer. */
7
+ data: T | null;
8
+ /** True until a final value, the last retry or a failed fetch. */
9
+ loading: boolean;
10
+ /** Polls again from the start, with the spinner showing. */
11
+ refresh: () => void;
12
+ }
13
+
14
+ export interface PollOptions {
15
+ /** How many more times to fetch after the first. Defaults to 8. */
16
+ retries?: number | undefined;
17
+ /** Milliseconds between fetches. Defaults to 4000. */
18
+ delayMs?: number | undefined;
19
+ }
20
+
21
+ /**
22
+ * 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 fetch
24
+ * uses the latest `fetcher` and `isFinal` given.
25
+ */
26
+ export function usePollUntilFinal<T>(
27
+ fetcher: () => Promise<T | null>,
28
+ isFinal: (value: T | null) => boolean,
29
+ { retries = 8, delayMs = 4000 }: PollOptions = {},
30
+ ): PollResult<T> {
31
+ const [data, setData] = useState<T | null>(null);
32
+ const [loading, setLoading] = useState(true);
33
+ const latest = useRef({ fetcher, isFinal });
34
+ useEffect(() => {
35
+ latest.current = { fetcher, isFinal };
36
+ }, [fetcher, isFinal]);
37
+ const run = useRef(0);
38
+ const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
39
+
40
+ const start = useCallback(() => {
41
+ const id = ++run.current;
42
+ let attempt = 0;
43
+ const tick = async () => {
44
+ try {
45
+ const value = await latest.current.fetcher();
46
+ if (id !== run.current) return;
47
+ setData(value);
48
+ if (latest.current.isFinal(value) || attempt >= retries) {
49
+ setLoading(false);
50
+ return;
51
+ }
52
+ attempt += 1;
53
+ timer.current = setTimeout(tick, delayMs);
54
+ } catch {
55
+ if (id === run.current) setLoading(false);
56
+ }
57
+ };
58
+ void tick();
59
+ }, [retries, delayMs]);
60
+
61
+ // `loading` starts true, so the first poll needs no flag of its own.
62
+ useEffect(() => {
63
+ start();
64
+ return () => {
65
+ // A new run number cancels whichever run is current, including one a refresh started.
66
+ // eslint-disable-next-line react-hooks/exhaustive-deps
67
+ run.current++;
68
+ clearTimeout(timer.current);
69
+ };
70
+ }, [start]);
71
+
72
+ const refresh = useCallback(() => {
73
+ clearTimeout(timer.current);
74
+ setLoading(true);
75
+ start();
76
+ }, [start]);
77
+
78
+ return { data, loading, refresh };
79
+ }
package/src/styles.css CHANGED
@@ -60,6 +60,17 @@
60
60
  --color-markdown-code-bg: var(--color-gray-100);
61
61
  --color-markdown-code-text: var(--color-gray-900);
62
62
 
63
+ --radius-payment-button: var(--radius-button);
64
+ --color-payment-success: var(--color-green-600);
65
+ --color-payment-pending: var(--color-amber-500);
66
+ --color-payment-failed: var(--color-red-600);
67
+ --color-payment-title: var(--color-gray-900);
68
+ --color-payment-text: var(--color-gray-600);
69
+ --color-payment-note: var(--color-gray-500);
70
+ --color-payment-secondary-bg: var(--color-gray-100);
71
+ --color-payment-secondary-hover-bg: var(--color-gray-200);
72
+ --color-payment-secondary-text: var(--color-gray-700);
73
+
63
74
  --radius-button: var(--radius-lg);
64
75
  --color-button-primary-bg: var(--color-primary);
65
76
  --color-button-primary-text: var(--color-primary-foreground);