@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 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 five minutes, image qualities 75 and 100, the incremental cache
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 cache may serve a stale page. Defaults to 300. Next's default is a year. |
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. The title names the days the API answered for. While the stats load, the panel shows
1193
- `loading`. When they cannot load, such as when Brevo does not answer, it shows `unavailable` without
1194
- a toast. The panel loads again when `days` changes, not when an inline `service` does.
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
- | `strings` | `TextOverrides<EmailStatsPanelStrings>` | `title`, where `{days}` becomes the days, and `sent`, `delivered`, `bounces`, `blocked`, `loading`, and `unavailable`. |
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjolystinnovation/app-kit",
3
- "version": "1.0.2",
3
+ "version": "1.1.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",
@@ -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
- /** How many days back the stats go. Defaults to 30. */
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 = { status: "loading" } | { status: "failed" } | { status: "loaded"; stats: EmailStats };
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. It says the stats are
24
- * unavailable when they cannot load, without a toast, so the rest of the stats page stays calm.
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({ service, days = 30, strings, className = "rounded-2xl border border-admin-border p-5" }: EmailStatsPanelProps) {
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
- const [state, setState] = useState<State>({ status: "loading" });
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 an earlier `days` is ignored, so a slow one cannot replace the newer stats.
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(days)
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
- }, [days]);
72
+ }, [selected]);
50
73
 
51
- const shownDays = state.status === "loaded" ? state.stats.days : days;
52
- const rows = (stats: EmailStats) => [
53
- { label: text.sent, value: stats.requests },
54
- { label: text.delivered, value: stats.delivered },
55
- { label: text.bounces, value: stats.hardBounces + stats.softBounces },
56
- { label: text.blocked, value: stats.blocked + stats.spamReports },
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
- <h2 className="font-bold text-admin-title mb-3">{text.title.replaceAll("{days}", String(shownDays))}</h2>
62
- {state.status === "loaded" ? (
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(state.stats).map((row) => (
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">{state.status === "failed" ? text.unavailable : text.loading}</p>
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
  };
@@ -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 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.
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 ?? 300,
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 },