@sjolystinnovation/app-kit 1.0.2 → 1.1.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 +30 -9
- package/package.json +1 -1
- package/src/admin/EmailStatsPanel.tsx +86 -22
- package/src/admin/emailStatsPanelStrings.ts +20 -1
- package/src/locales/nb.ts +7 -0
- package/src/next-config/index.ts +7 -4
package/README.md
CHANGED
|
@@ -165,7 +165,7 @@ The config every app shares, for `next.config.ts`. It imports nothing but Next's
|
|
|
165
165
|
loads it with no build step.
|
|
166
166
|
|
|
167
167
|
`defineAppConfig(options, extra?)` gives the standalone build, app-kit in `transpilePackages`, no
|
|
168
|
-
`X-Powered-By`, an `expireTime` of
|
|
168
|
+
`X-Powered-By`, an `expireTime` of a year, image qualities 75 and 100, the incremental cache
|
|
169
169
|
in memory, and the security headers on every page. It merges `extra` over that config. It combines
|
|
170
170
|
`transpilePackages`, `images`, `experimental`, `headers`, and `rewrites` instead of replacing them.
|
|
171
171
|
|
|
@@ -191,9 +191,18 @@ export default defineAppConfig({
|
|
|
191
191
|
| `frameSrc`, `objectSrc` | `string[]` | Sources for those directives. Both default to `'none'`. |
|
|
192
192
|
| `pathHeaders` | `{ source, headers }[]` | Headers for particular paths, such as a file download. They come after the page headers and replace a page header of the same name. Every path keeps the rest. |
|
|
193
193
|
| `permissionsPolicy` | `string` | Replaces `camera=(), microphone=(), geolocation=()`. |
|
|
194
|
-
| `expireTime` | `number` | The seconds a
|
|
194
|
+
| `expireTime` | `number` | The seconds a cached page may be served after it was last generated. Defaults to a year, Next's default. |
|
|
195
195
|
| `isrFlushToDisk` | `boolean` | Whether to write revalidated pages to disk. Defaults to `false`, for a read-only root filesystem. |
|
|
196
196
|
|
|
197
|
+
Until a page reaches its `expireTime`, Next serves the cached copy at once and regenerates it in the
|
|
198
|
+
background when it is stale. If the regeneration fails, for example because the API is down, Next
|
|
199
|
+
keeps the last good copy and tries again on a later request. Once the page is past its
|
|
200
|
+
`expireTime`, Next regenerates it while the visitor waits, and a page whose `publicGet` throws then
|
|
201
|
+
answers 500. The long default keeps legal pages, the sitemap, and other pages with few visitors up
|
|
202
|
+
through an API outage. A page that was never generated still answers 500 while the API is down,
|
|
203
|
+
rather than being cached empty. Set a shorter `expireTime` only if a page must never be served more
|
|
204
|
+
than that old, and accept the 500.
|
|
205
|
+
|
|
197
206
|
Scripts allow `'unsafe-inline'`, since a nonce would make every statically rendered page dynamic.
|
|
198
207
|
The package also exports the `AppConfigOptions` and `CspOptions` types.
|
|
199
208
|
|
|
@@ -1189,19 +1198,31 @@ it plots and its label. It takes the next color of a built-in palette of five un
|
|
|
1189
1198
|
The stats page card for the transactional email, from the `MapAppKitEmailStats` endpoint of
|
|
1190
1199
|
SjolystInnovation.AppKit.Email through `createEmailStatsService`. It shows the emails sent,
|
|
1191
1200
|
delivered, bounced, and blocked or reported as spam, for the last `days` days. Hard and soft bounces
|
|
1192
|
-
count together
|
|
1193
|
-
|
|
1194
|
-
|
|
1201
|
+
count together, and so do blocked emails and spam reports. With `separateCounts`, each count the API
|
|
1202
|
+
returns gets its own row: hard bounces, soft bounces, blocked, spam reports, and invalid addresses.
|
|
1203
|
+
The title names the days the API answered for. While the stats load, the panel shows `loading`.
|
|
1204
|
+
When they cannot load, such as when Brevo does not answer, it shows `unavailable` without a toast.
|
|
1205
|
+
The panel loads again when the days change, not when an inline `service` does, and shows `loading`
|
|
1206
|
+
until the new days answer.
|
|
1207
|
+
|
|
1208
|
+
Give `periods` to let an admin pick the days with a row of buttons next to the title. The panel then
|
|
1209
|
+
keeps the picked days itself, starting on `days`. Give `onDaysChange` as well to keep the days in the
|
|
1210
|
+
app, which then passes the picked days back as `days`.
|
|
1195
1211
|
|
|
1196
1212
|
```tsx
|
|
1197
1213
|
<EmailStatsPanel service={createEmailStatsService(api)} strings={{ title: "E-post (siste {days} dager)" }} />
|
|
1214
|
+
|
|
1215
|
+
<EmailStatsPanel service={createEmailStatsService(api)} periods={[7, 30, 90]} separateCounts />
|
|
1198
1216
|
```
|
|
1199
1217
|
|
|
1200
1218
|
| Prop | Type | Description |
|
|
1201
1219
|
| --- | --- | --- |
|
|
1202
1220
|
| `service` | `{ get(days?) }` | The email stats call, such as `createEmailStatsService(api)`. |
|
|
1203
|
-
| `days` | `number` | How many days back the stats go. Defaults to 30. |
|
|
1204
|
-
| `
|
|
1221
|
+
| `days` | `number` | How many days back the stats go. Defaults to 30. With `periods` and no `onDaysChange`, the days the picker starts on. |
|
|
1222
|
+
| `periods` | `readonly number[]` | The days an admin can pick between, such as `[7, 30, 90]`. Without it, there is no picker. |
|
|
1223
|
+
| `onDaysChange` | `(days: number) => void` | Called with the picked days. Give it to control `days` from the app. |
|
|
1224
|
+
| `separateCounts` | `boolean` | Shows each count on its own row. Defaults to `false`. |
|
|
1225
|
+
| `strings` | `TextOverrides<EmailStatsPanelStrings>` | `title` and `periodOption`, where `{days}` becomes the days, `sent`, `delivered`, `bounces`, `blocked`, `loading`, and `unavailable`, the rows of `separateCounts` in `hardBounces`, `softBounces`, `blockedOnly`, `spamReports`, and `invalid`, and `period`, the picker's name for screen readers. |
|
|
1205
1226
|
| `className` | `string` | Replaces the card's border and spacing, `rounded-2xl border border-admin-border p-5`, such as to sit in the app's own card. |
|
|
1206
1227
|
|
|
1207
1228
|
## `@sjolystinnovation/app-kit/branding`
|
|
@@ -1768,7 +1789,7 @@ The components draw every color, corner radius, and label size from theme variab
|
|
|
1768
1789
|
| `--font-weight-auth-link` | semibold |
|
|
1769
1790
|
| `--color-admin-title` | gray 900, the title of `AdminShell` |
|
|
1770
1791
|
| `--color-admin-border` | gray 200, the line under the tabs |
|
|
1771
|
-
| `--color-admin-tab`, `-hover` | gray 500 and gray 900 |
|
|
1792
|
+
| `--color-admin-tab`, `-hover` | gray 500 and gray 900, also the periods `EmailStatsPanel` has not picked |
|
|
1772
1793
|
| `--color-admin-tab-active` | gray 900 |
|
|
1773
1794
|
| `--color-admin-tab-indicator` | `var(--color-primary)`, the line under the active tab |
|
|
1774
1795
|
| `--color-chart-grid` | gray 200, the scale lines of `StatHistoryChart` |
|
|
@@ -1776,7 +1797,7 @@ The components draw every color, corner radius, and label size from theme variab
|
|
|
1776
1797
|
| `--color-admin-stat-label` | gray 500, the labels and the waiting text of `EmailStatsPanel` |
|
|
1777
1798
|
| `--color-admin-stat-value` | gray 700, the numbers of `EmailStatsPanel` |
|
|
1778
1799
|
| `--radius-button` | `var(--radius-lg)` |
|
|
1779
|
-
| `--color-button-primary-bg` | `var(--color-primary)` |
|
|
1800
|
+
| `--color-button-primary-bg` | `var(--color-primary)`, also the period `EmailStatsPanel` has picked |
|
|
1780
1801
|
| `--color-button-primary-text` | `var(--color-primary-foreground)` |
|
|
1781
1802
|
| `--color-button-secondary-border` | gray 300 |
|
|
1782
1803
|
| `--color-button-secondary-text` | gray 700 |
|
package/package.json
CHANGED
|
@@ -9,59 +9,123 @@ import { defaultEmailStatsPanelStrings, type EmailStatsPanelStrings } from "./em
|
|
|
9
9
|
export interface EmailStatsPanelProps {
|
|
10
10
|
/** The email stats call, such as `createEmailStatsService(api)`. */
|
|
11
11
|
service: { get: (days?: number) => Promise<EmailStats> };
|
|
12
|
-
/**
|
|
12
|
+
/**
|
|
13
|
+
* How many days back the stats go. Defaults to 30. With `onDaysChange` the panel shows these days
|
|
14
|
+
* and leaves the choice to the app. Without it, this is where the picker starts.
|
|
15
|
+
*/
|
|
13
16
|
days?: number | undefined;
|
|
17
|
+
/** The days an admin can pick between, such as `[7, 30, 90]`. Without it, the panel has no picker. */
|
|
18
|
+
periods?: readonly number[] | undefined;
|
|
19
|
+
/** Called with the days an admin picks. Give it to control `days` from the app. */
|
|
20
|
+
onDaysChange?: ((days: number) => void) | undefined;
|
|
21
|
+
/** Shows hard and soft bounces, blocked, spam reports, and invalid addresses on their own rows. */
|
|
22
|
+
separateCounts?: boolean | undefined;
|
|
14
23
|
strings?: TextOverrides<EmailStatsPanelStrings> | undefined;
|
|
15
24
|
/** The classes that replace the card's border and spacing, such as when the panel sits in the app's own card. */
|
|
16
25
|
className?: string | undefined;
|
|
17
26
|
}
|
|
18
27
|
|
|
19
|
-
type State = {
|
|
28
|
+
type State = { days: number } & ({ status: "failed" } | { status: "loaded"; stats: EmailStats });
|
|
20
29
|
|
|
21
30
|
/**
|
|
22
31
|
* The admin card for the transactional email of the last days, from SjolystInnovation.AppKit.Email's
|
|
23
|
-
* stats endpoint: sent, delivered, bounces, and blocked or reported as spam
|
|
24
|
-
* unavailable when they cannot load, without a toast, so the rest of the stats
|
|
32
|
+
* stats endpoint: sent, delivered, bounces, and blocked or reported as spam, or each count on its own.
|
|
33
|
+
* It says the stats are unavailable when they cannot load, without a toast, so the rest of the stats
|
|
34
|
+
* page stays calm.
|
|
25
35
|
*/
|
|
26
|
-
export function EmailStatsPanel({
|
|
36
|
+
export function EmailStatsPanel({
|
|
37
|
+
service,
|
|
38
|
+
days = 30,
|
|
39
|
+
periods,
|
|
40
|
+
onDaysChange,
|
|
41
|
+
separateCounts = false,
|
|
42
|
+
strings,
|
|
43
|
+
className = "rounded-2xl border border-admin-border p-5",
|
|
44
|
+
}: EmailStatsPanelProps) {
|
|
27
45
|
const text = withDefaults(defaultEmailStatsPanelStrings, strings);
|
|
28
|
-
|
|
46
|
+
// The days picked in the panel itself, until the app gives other days. A panel with
|
|
47
|
+
// `onDaysChange` never picks on its own.
|
|
48
|
+
const [picked, setPicked] = useState<{ from: number; days: number } | null>(null);
|
|
49
|
+
const selected = picked?.from === days ? picked.days : days;
|
|
50
|
+
const hasPicker = periods !== undefined && periods.length > 0;
|
|
51
|
+
const [state, setState] = useState<State | null>(null);
|
|
29
52
|
// The latest service, so an inline one does not load the stats again on every render.
|
|
30
53
|
const serviceRef = useRef(service);
|
|
31
54
|
useEffect(() => {
|
|
32
55
|
serviceRef.current = service;
|
|
33
56
|
}, [service]);
|
|
34
57
|
|
|
35
|
-
// An answer for
|
|
58
|
+
// An answer for earlier days is ignored, so a slow one cannot replace the newer stats.
|
|
36
59
|
useEffect(() => {
|
|
37
60
|
let replaced = false;
|
|
38
61
|
serviceRef.current
|
|
39
|
-
.get(
|
|
62
|
+
.get(selected)
|
|
40
63
|
.then((stats) => {
|
|
41
|
-
if (!replaced) setState({ status: "loaded", stats });
|
|
64
|
+
if (!replaced) setState({ days: selected, status: "loaded", stats });
|
|
42
65
|
})
|
|
43
66
|
.catch(() => {
|
|
44
|
-
if (!replaced) setState({ status: "failed" });
|
|
67
|
+
if (!replaced) setState({ days: selected, status: "failed" });
|
|
45
68
|
});
|
|
46
69
|
return () => {
|
|
47
70
|
replaced = true;
|
|
48
71
|
};
|
|
49
|
-
}, [
|
|
72
|
+
}, [selected]);
|
|
50
73
|
|
|
51
|
-
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
74
|
+
// Stats for other days than the selected ones count as loading.
|
|
75
|
+
const current = state?.days === selected ? state : null;
|
|
76
|
+
const shownDays = current?.status === "loaded" ? current.stats.days : selected;
|
|
77
|
+
const rows = (stats: EmailStats) =>
|
|
78
|
+
separateCounts
|
|
79
|
+
? [
|
|
80
|
+
{ label: text.sent, value: stats.requests },
|
|
81
|
+
{ label: text.delivered, value: stats.delivered },
|
|
82
|
+
{ label: text.hardBounces, value: stats.hardBounces },
|
|
83
|
+
{ label: text.softBounces, value: stats.softBounces },
|
|
84
|
+
{ label: text.blockedOnly, value: stats.blocked },
|
|
85
|
+
{ label: text.spamReports, value: stats.spamReports },
|
|
86
|
+
{ label: text.invalid, value: stats.invalid },
|
|
87
|
+
]
|
|
88
|
+
: [
|
|
89
|
+
{ label: text.sent, value: stats.requests },
|
|
90
|
+
{ label: text.delivered, value: stats.delivered },
|
|
91
|
+
{ label: text.bounces, value: stats.hardBounces + stats.softBounces },
|
|
92
|
+
{ label: text.blocked, value: stats.blocked + stats.spamReports },
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
const pick = (next: number) => {
|
|
96
|
+
if (onDaysChange) onDaysChange(next);
|
|
97
|
+
else setPicked({ from: days, days: next });
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const title = <h2 className={`font-bold text-admin-title${hasPicker ? "" : " mb-3"}`}>{text.title.replaceAll("{days}", String(shownDays))}</h2>;
|
|
58
101
|
|
|
59
102
|
return (
|
|
60
103
|
<section className={className}>
|
|
61
|
-
|
|
62
|
-
|
|
104
|
+
{hasPicker ? (
|
|
105
|
+
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
|
106
|
+
{title}
|
|
107
|
+
<div role="group" aria-label={text.period} className="flex gap-1">
|
|
108
|
+
{periods.map((option) => (
|
|
109
|
+
<button
|
|
110
|
+
key={option}
|
|
111
|
+
type="button"
|
|
112
|
+
onClick={() => pick(option)}
|
|
113
|
+
aria-pressed={option === selected}
|
|
114
|
+
className={`rounded-button px-2.5 py-1 text-xs transition-colors ${
|
|
115
|
+
option === selected ? "bg-button-primary-bg text-button-primary-text" : "text-admin-tab hover:text-admin-tab-hover"
|
|
116
|
+
}`}
|
|
117
|
+
>
|
|
118
|
+
{text.periodOption.replaceAll("{days}", String(option))}
|
|
119
|
+
</button>
|
|
120
|
+
))}
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
) : (
|
|
124
|
+
title
|
|
125
|
+
)}
|
|
126
|
+
{current?.status === "loaded" ? (
|
|
63
127
|
<dl className="space-y-1.5 text-sm">
|
|
64
|
-
{rows(
|
|
128
|
+
{rows(current.stats).map((row) => (
|
|
65
129
|
<div key={row.label} className="flex justify-between">
|
|
66
130
|
<dt className="text-admin-stat-label">{row.label}</dt>
|
|
67
131
|
<dd className="tabular-nums text-admin-stat-value">{row.value}</dd>
|
|
@@ -69,7 +133,7 @@ export function EmailStatsPanel({ service, days = 30, strings, className = "roun
|
|
|
69
133
|
))}
|
|
70
134
|
</dl>
|
|
71
135
|
) : (
|
|
72
|
-
<p className="text-sm text-admin-stat-label">{
|
|
136
|
+
<p className="text-sm text-admin-stat-label">{current?.status === "failed" ? text.unavailable : text.loading}</p>
|
|
73
137
|
)}
|
|
74
138
|
</section>
|
|
75
139
|
);
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* The email stats panel's text. Pass the app's own language. The defaults are English. The panel
|
|
5
|
-
* fills in `{days}` in `title`.
|
|
5
|
+
* fills in `{days}` in `title` and `periodOption`.
|
|
6
6
|
*/
|
|
7
7
|
export interface EmailStatsPanelStrings {
|
|
8
8
|
title: string;
|
|
@@ -13,6 +13,18 @@ export interface EmailStatsPanelStrings {
|
|
|
13
13
|
bounces: string;
|
|
14
14
|
/** Blocked emails and spam reports together. */
|
|
15
15
|
blocked: string;
|
|
16
|
+
/** The rows of `separateCounts`, each count on its own. */
|
|
17
|
+
hardBounces: string;
|
|
18
|
+
softBounces: string;
|
|
19
|
+
/** Blocked emails without spam reports. */
|
|
20
|
+
blockedOnly: string;
|
|
21
|
+
spamReports: string;
|
|
22
|
+
/** Emails to an address that does not exist or cannot be used. */
|
|
23
|
+
invalid: string;
|
|
24
|
+
/** The name of the period picker, for screen readers. */
|
|
25
|
+
period: string;
|
|
26
|
+
/** Each choice in the period picker, such as `7 days`. */
|
|
27
|
+
periodOption: string;
|
|
16
28
|
loading: string;
|
|
17
29
|
/** The text shown when the stats could not load, such as when Brevo does not answer. */
|
|
18
30
|
unavailable: string;
|
|
@@ -24,6 +36,13 @@ export const defaultEmailStatsPanelStrings: EmailStatsPanelStrings = {
|
|
|
24
36
|
delivered: "Delivered",
|
|
25
37
|
bounces: "Bounces",
|
|
26
38
|
blocked: "Blocked / spam",
|
|
39
|
+
hardBounces: "Hard bounces",
|
|
40
|
+
softBounces: "Soft bounces",
|
|
41
|
+
blockedOnly: "Blocked",
|
|
42
|
+
spamReports: "Spam reports",
|
|
43
|
+
invalid: "Invalid",
|
|
44
|
+
period: "Period",
|
|
45
|
+
periodOption: "{days} days",
|
|
27
46
|
loading: "Loading…",
|
|
28
47
|
unavailable: "Unavailable.",
|
|
29
48
|
};
|
package/src/locales/nb.ts
CHANGED
|
@@ -240,6 +240,13 @@ export const nbEmailStatsPanelStrings: EmailStatsPanelStrings = {
|
|
|
240
240
|
delivered: "Levert",
|
|
241
241
|
bounces: "Returnert",
|
|
242
242
|
blocked: "Blokkert eller spam",
|
|
243
|
+
hardBounces: "Returnert, permanent feil",
|
|
244
|
+
softBounces: "Returnert, midlertidig feil",
|
|
245
|
+
blockedOnly: "Blokkert",
|
|
246
|
+
spamReports: "Meldt som spam",
|
|
247
|
+
invalid: "Ugyldig adresse",
|
|
248
|
+
period: "Periode",
|
|
249
|
+
periodOption: "{days} dager",
|
|
243
250
|
loading: "Laster…",
|
|
244
251
|
unavailable: "E-poststatistikken er ikke tilgjengelig akkurat nå. Prøv igjen senere.",
|
|
245
252
|
};
|
package/src/next-config/index.ts
CHANGED
|
@@ -3,6 +3,8 @@ import { contentImagesRewrite, securityHeaders, type CspOptions, type Header } f
|
|
|
3
3
|
|
|
4
4
|
export type { CspOptions } from "./headers";
|
|
5
5
|
|
|
6
|
+
const ONE_YEAR_SECONDS = 31_536_000;
|
|
7
|
+
|
|
6
8
|
/** Options for `defineAppConfig`. */
|
|
7
9
|
export interface AppConfigOptions extends CspOptions {
|
|
8
10
|
/**
|
|
@@ -18,9 +20,10 @@ export interface AppConfigOptions extends CspOptions {
|
|
|
18
20
|
/** A Permissions-Policy that replaces the default one. */
|
|
19
21
|
permissionsPolicy?: string | undefined;
|
|
20
22
|
/**
|
|
21
|
-
* The number of seconds a
|
|
22
|
-
* year,
|
|
23
|
-
*
|
|
23
|
+
* The number of seconds a cached page may go on being served after it was last generated. Defaults
|
|
24
|
+
* to a year, Next's own default. Until then a stale page is served at once and regenerated in the
|
|
25
|
+
* background, and a failed regeneration keeps the last good page. Past it, the page is regenerated
|
|
26
|
+
* while the visitor waits, so a page that reads a down API answers 500 instead.
|
|
24
27
|
*/
|
|
25
28
|
expireTime?: number | undefined;
|
|
26
29
|
/**
|
|
@@ -43,7 +46,7 @@ export function defineAppConfig(o: AppConfigOptions, extra: NextConfig = {}): Ne
|
|
|
43
46
|
return {
|
|
44
47
|
output: "standalone",
|
|
45
48
|
poweredByHeader: false,
|
|
46
|
-
expireTime: o.expireTime ??
|
|
49
|
+
expireTime: o.expireTime ?? ONE_YEAR_SECONDS,
|
|
47
50
|
...rest,
|
|
48
51
|
transpilePackages: [...new Set(["@sjolystinnovation/app-kit", ...(transpilePackages ?? [])])],
|
|
49
52
|
images: { qualities: [75, 100], ...images },
|