@sjolystinnovation/app-kit 0.14.0 → 0.16.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 +48 -2
- package/package.json +2 -1
- package/src/auth/createAuthOptions.ts +17 -0
- package/src/payments/PaymentReturn.tsx +73 -0
- package/src/payments/formatNok.ts +4 -0
- package/src/payments/index.ts +3 -0
- package/src/payments/usePollUntilFinal.ts +79 -0
- package/src/styles.css +11 -0
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`, `branding` 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
|
|
@@ -170,7 +170,7 @@ export { handler as GET, handler as POST };
|
|
|
170
170
|
|
|
171
171
|
| Setting | Type | Description |
|
|
172
172
|
| --- | --- | --- |
|
|
173
|
-
| `apiUrl` | `string` | Base URL of the API. Sign-in posts to `/auth/login
|
|
173
|
+
| `apiUrl` | `string` | Base URL of the API. Sign-in posts to `/auth/login`, refresh to `/auth/refresh-token` and sign-out to `/auth/logout` under it. An empty value throws. |
|
|
174
174
|
| `env` | `Record<string, string \| undefined>` | The server environment, usually `process.env`. It supplies the secret named by `jwtSecretEnvVar`. |
|
|
175
175
|
| `http` | `AxiosInstance` | In place of `apiUrl`, for tests. The client for sign-in and refresh, with its own base URL. |
|
|
176
176
|
|
|
@@ -523,6 +523,48 @@ fetch and revalidate the tag after a save, so a new icon shows at once. `iconVer
|
|
|
523
523
|
short digest of the icon bytes, for a manifest icon URL such as `/icon?v=<digest>`, since Android
|
|
524
524
|
refreshes a home screen icon only when the manifest changes.
|
|
525
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
|
+
|
|
526
568
|
### `@sjolystinnovation/app-kit/session`
|
|
527
569
|
|
|
528
570
|
Decides when a user has to sign in again. Safe to import from server code, such as the next-auth
|
|
@@ -1016,6 +1058,10 @@ selector such as `.dark` for a scoped theme:
|
|
|
1016
1058
|
| `--color-markdown-link-hover` | gray 600 |
|
|
1017
1059
|
| `--color-markdown-border` | gray 200, rules, quotes and table lines |
|
|
1018
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 |
|
|
1019
1065
|
|
|
1020
1066
|
A default that points at another variable is read once for the whole page. The focus ring and the
|
|
1021
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.
|
|
3
|
+
"version": "0.16.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",
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
"./seo/og": "./src/seo/og/index.tsx",
|
|
32
32
|
"./branding": "./src/branding/index.ts",
|
|
33
33
|
"./branding/server": "./src/branding/server.ts",
|
|
34
|
+
"./payments": "./src/payments/index.ts",
|
|
34
35
|
"./styles.css": "./src/styles.css"
|
|
35
36
|
},
|
|
36
37
|
"files": [
|
|
@@ -73,6 +73,10 @@ const REMEMBER_ROTATION_MS = 60 * 1000;
|
|
|
73
73
|
// Sign-in and session reads wait for the API, so a hung connection must not hold them forever.
|
|
74
74
|
const API_TIMEOUT_MS = 10_000;
|
|
75
75
|
|
|
76
|
+
// Sign-out waits for the API to drop the refresh token, but only briefly, because the cookie is
|
|
77
|
+
// cleared either way.
|
|
78
|
+
const SIGN_OUT_TIMEOUT_MS = 3_000;
|
|
79
|
+
|
|
76
80
|
function rolesOf(claims: ApiClaims): string[] {
|
|
77
81
|
const raw = claims.role;
|
|
78
82
|
return Array.isArray(raw) ? raw : raw ? [raw] : [];
|
|
@@ -249,6 +253,19 @@ export function createAuthOptions(config: SessionConfig, settings: AuthOptionsSe
|
|
|
249
253
|
session: {
|
|
250
254
|
maxAge: ABSOLUTE_SESSION_MAX_AGE / 1000,
|
|
251
255
|
},
|
|
256
|
+
events: {
|
|
257
|
+
// Sign-out also asks the API to delete the refresh token, so a copy of it cannot be used again.
|
|
258
|
+
// A failure leaves sign-out to the cookie alone.
|
|
259
|
+
async signOut({ token }) {
|
|
260
|
+
const refreshToken = (token as AppToken | null)?.refreshToken;
|
|
261
|
+
if (!refreshToken) return;
|
|
262
|
+
try {
|
|
263
|
+
await http.post("/auth/logout", { refreshToken }, { timeout: SIGN_OUT_TIMEOUT_MS });
|
|
264
|
+
} catch (error) {
|
|
265
|
+
console.error("Sign-out could not delete the refresh token at the API", failureShape(error));
|
|
266
|
+
}
|
|
267
|
+
},
|
|
268
|
+
},
|
|
252
269
|
callbacks: {
|
|
253
270
|
async jwt({ token: current, user }) {
|
|
254
271
|
let token = current as AppToken;
|
|
@@ -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,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);
|