@sjolystinnovation/app-kit 1.0.1 → 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 +78 -33
- package/package.json +3 -2
- package/src/admin/EmailStatsPanel.tsx +86 -22
- package/src/admin/emailStatsPanelStrings.ts +20 -1
- package/src/api/index.ts +2 -2
- package/src/{api → company}/company.ts +2 -0
- package/src/company/index.ts +2 -0
- package/src/locales/nb.ts +7 -0
- package/src/next-config/index.ts +7 -4
- package/src/server/publicApi.ts +23 -1
- package/src/ui/ResetPasswordFlow.tsx +9 -3
- package/src/ui/resetPasswordFlowStrings.ts +1 -1
package/README.md
CHANGED
|
@@ -20,6 +20,7 @@ security headers. Each part is a separate entry point, so an app imports only wh
|
|
|
20
20
|
- [`auth`](#sjolystinnovationapp-kitauth) and [`next-auth`](#sjolystinnovationapp-kitnext-auth)
|
|
21
21
|
- [`session`](#sjolystinnovationapp-kitsession) and [`session/react`](#sjolystinnovationapp-kitsessionreact)
|
|
22
22
|
- [`api`](#sjolystinnovationapp-kitapi) and [`server`](#sjolystinnovationapp-kitserver)
|
|
23
|
+
- [`company`](#sjolystinnovationapp-kitcompany)
|
|
23
24
|
- [`forms`](#sjolystinnovationapp-kitforms), [`toast`](#sjolystinnovationapp-kittoast), and [`validation`](#sjolystinnovationapp-kitvalidation)
|
|
24
25
|
- [`ui`](#sjolystinnovationapp-kitui)
|
|
25
26
|
- [`admin`](#sjolystinnovationapp-kitadmin)
|
|
@@ -51,12 +52,12 @@ ones the entry points you import need:
|
|
|
51
52
|
| `react-markdown` 10, `remark-gfm` 4, and `rehype-sanitize` 6 | `markdown`, `legal`, and `sections` |
|
|
52
53
|
| `vitest` 5 | `testing` |
|
|
53
54
|
|
|
54
|
-
Every other entry point, such as the root entry, `next-config`, `seo`, `forms`, `legal`,
|
|
55
|
-
`locales/nb`, works without `@heroicons/react`. Install a single copy of `sonner`, because two
|
|
55
|
+
Every other entry point, such as the root entry, `next-config`, `seo`, `forms`, `legal`, `company`,
|
|
56
|
+
or `locales/nb`, works without `@heroicons/react`. Install a single copy of `sonner`, because two
|
|
56
57
|
copies keep two separate toast stores. `jsonwebtoken` comes with the package.
|
|
57
58
|
|
|
58
|
-
For a site without sign-in, which uses only entries such as the root entry, `ui`, `seo`,
|
|
59
|
-
`next-config`:
|
|
59
|
+
For a site without sign-in, which uses only entries such as the root entry, `ui`, `seo`, `company`,
|
|
60
|
+
or `next-config`:
|
|
60
61
|
|
|
61
62
|
```sh
|
|
62
63
|
npm install @sjolystinnovation/app-kit next react react-dom @heroicons/react
|
|
@@ -101,6 +102,7 @@ The components use Tailwind CSS 4. Import the package's stylesheet after Tailwin
|
|
|
101
102
|
| [`@sjolystinnovation/app-kit/session`](#sjolystinnovationapp-kitsession) | Session error codes and the re-sign-in prompt flag. Safe on the server. |
|
|
102
103
|
| [`@sjolystinnovation/app-kit/session/react`](#sjolystinnovationapp-kitsessionreact) | The session provider, gates, expiry guard, sign-in form, and login page. |
|
|
103
104
|
| [`@sjolystinnovation/app-kit/api`](#sjolystinnovationapp-kitapi) | The axios clients, error messages, field errors, and services for the API's endpoints. |
|
|
105
|
+
| [`@sjolystinnovation/app-kit/company`](#sjolystinnovationapp-kitcompany) | `mergeCompanyInfo`, `formatOrgNumber`, and the `CompanyInfo` type. Imports no peer dependency. |
|
|
104
106
|
| [`@sjolystinnovation/app-kit/server`](#sjolystinnovationapp-kitserver) | Cached public GETs and the revalidate route. Server only. |
|
|
105
107
|
| [`@sjolystinnovation/app-kit/forms`](#sjolystinnovationapp-kitforms) | `useFormDraft`, which keeps form values in `localStorage`. |
|
|
106
108
|
| [`@sjolystinnovation/app-kit/toast`](#sjolystinnovationapp-kittoast) | sonner's `toast`, quiet on errors while the re-sign-in prompt is open. |
|
|
@@ -163,7 +165,7 @@ The config every app shares, for `next.config.ts`. It imports nothing but Next's
|
|
|
163
165
|
loads it with no build step.
|
|
164
166
|
|
|
165
167
|
`defineAppConfig(options, extra?)` gives the standalone build, app-kit in `transpilePackages`, no
|
|
166
|
-
`X-Powered-By`, an `expireTime` of
|
|
168
|
+
`X-Powered-By`, an `expireTime` of a year, image qualities 75 and 100, the incremental cache
|
|
167
169
|
in memory, and the security headers on every page. It merges `extra` over that config. It combines
|
|
168
170
|
`transpilePackages`, `images`, `experimental`, `headers`, and `rewrites` instead of replacing them.
|
|
169
171
|
|
|
@@ -189,9 +191,18 @@ export default defineAppConfig({
|
|
|
189
191
|
| `frameSrc`, `objectSrc` | `string[]` | Sources for those directives. Both default to `'none'`. |
|
|
190
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. |
|
|
191
193
|
| `permissionsPolicy` | `string` | Replaces `camera=(), microphone=(), geolocation=()`. |
|
|
192
|
-
| `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. |
|
|
193
195
|
| `isrFlushToDisk` | `boolean` | Whether to write revalidated pages to disk. Defaults to `false`, for a read-only root filesystem. |
|
|
194
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
|
+
|
|
195
206
|
Scripts allow `'unsafe-inline'`, since a nonce would make every statically rendered page dynamic.
|
|
196
207
|
The package also exports the `AppConfigOptions` and `CspOptions` types.
|
|
197
208
|
|
|
@@ -615,24 +626,8 @@ The package also exports the response types `UserProfile`, `AdminUser`, `SiteSet
|
|
|
615
626
|
|
|
616
627
|
### `mergeCompanyInfo` and `formatOrgNumber`
|
|
617
628
|
|
|
618
|
-
`
|
|
619
|
-
|
|
620
|
-
fallback's name is required. The legal name falls back to the name, and VAT to not registered.
|
|
621
|
-
|
|
622
|
-
`formatOrgNumber({ orgNumber, vatRegistered })` adds ` MVA` once the business is VAT registered, and
|
|
623
|
-
gives an empty string without a number.
|
|
624
|
-
|
|
625
|
-
```ts
|
|
626
|
-
// src/lib/companyInfo.ts
|
|
627
|
-
import { formatOrgNumber, mergeCompanyInfo, type CompanyInfo } from "@sjolystinnovation/app-kit/api";
|
|
628
|
-
|
|
629
|
-
export const getCompanyInfo = async () =>
|
|
630
|
-
mergeCompanyInfo(await publicGetOptional<CompanyInfo>("/company"), { name: "Example AS" });
|
|
631
|
-
|
|
632
|
-
export const orgLine = (company: CompanyInfo) => formatOrgNumber(company);
|
|
633
|
-
```
|
|
634
|
-
|
|
635
|
-
The `CompanyInfo` and `CompanyFallback` types describe the two shapes.
|
|
629
|
+
`./api` also exports the [`company`](#sjolystinnovationapp-kitcompany) helpers and types, so
|
|
630
|
+
existing imports keep working.
|
|
636
631
|
|
|
637
632
|
### `defineRevalidateTargets`
|
|
638
633
|
|
|
@@ -661,7 +656,8 @@ The package also exports the `RevalidateTargetsConfig` and `RevalidateTargetPath
|
|
|
661
656
|
|
|
662
657
|
## `@sjolystinnovation/app-kit/server`
|
|
663
658
|
|
|
664
|
-
For server components and route handlers only. It imports `next/cache
|
|
659
|
+
For server components and route handlers only. It imports `next/cache`, `next/constants`, and
|
|
660
|
+
`next/server`.
|
|
665
661
|
|
|
666
662
|
### `createPublicApi`
|
|
667
663
|
|
|
@@ -688,6 +684,19 @@ export const { publicGet, publicGetOptional, publicGetWithMeta } = createPublicA
|
|
|
688
684
|
);
|
|
689
685
|
```
|
|
690
686
|
|
|
687
|
+
`next build` prerenders the pages that call these, so by default a build fails when the API is down.
|
|
688
|
+
Set `nullOnFailureDuringBuild: true` to build those pages empty instead. During the build, an
|
|
689
|
+
unreachable API or an error answer gives `null` and logs a warning with the path and status, so the
|
|
690
|
+
empty page shows in the build log. ISR fills the page on its first revalidate once the API answers.
|
|
691
|
+
At runtime a failure still throws, so a working page is never replaced by an empty one.
|
|
692
|
+
|
|
693
|
+
```ts
|
|
694
|
+
export const { publicGet, publicGetOptional, publicGetWithMeta } = createPublicApi(
|
|
695
|
+
requireEnv("NEXT_PUBLIC_API_URL", process.env.NEXT_PUBLIC_API_URL),
|
|
696
|
+
{ nullOnFailureDuringBuild: true },
|
|
697
|
+
);
|
|
698
|
+
```
|
|
699
|
+
|
|
691
700
|
The package also exports the `PublicApiOptions`, `PublicGetOptions`, and `PublicResponse` types.
|
|
692
701
|
|
|
693
702
|
### `createRevalidateRoute`
|
|
@@ -714,6 +723,30 @@ export const POST = createRevalidateRoute({ targets: TARGET_PATHS, role: "Admin"
|
|
|
714
723
|
`{ builds: ["/builds", "/builds/[slug]", "/sitemap.xml"] }`. The package also exports the
|
|
715
724
|
`RevalidateRouteOptions` type.
|
|
716
725
|
|
|
726
|
+
## `@sjolystinnovation/app-kit/company`
|
|
727
|
+
|
|
728
|
+
The company details for public pages, such as a footer or a contact page. The entry imports no
|
|
729
|
+
peer dependency, so a static site without axios or next-auth can use it.
|
|
730
|
+
|
|
731
|
+
`mergeCompanyInfo(data, fallback)` gives the company details: the API's `/company` answer, with the
|
|
732
|
+
app's own value for each field it left blank or could not give. Only the fallback's name is
|
|
733
|
+
required. The legal name falls back to the name, and VAT to not registered.
|
|
734
|
+
|
|
735
|
+
`formatOrgNumber({ orgNumber, vatRegistered })` adds ` MVA` once the business is VAT registered, and
|
|
736
|
+
gives an empty string without a number.
|
|
737
|
+
|
|
738
|
+
```ts
|
|
739
|
+
// src/lib/companyInfo.ts
|
|
740
|
+
import { formatOrgNumber, mergeCompanyInfo, type CompanyInfo } from "@sjolystinnovation/app-kit/company";
|
|
741
|
+
|
|
742
|
+
export const getCompanyInfo = async () =>
|
|
743
|
+
mergeCompanyInfo(await publicGetOptional<CompanyInfo>("/company"), { name: "Example AS" });
|
|
744
|
+
|
|
745
|
+
export const orgLine = (company: CompanyInfo) => formatOrgNumber(company);
|
|
746
|
+
```
|
|
747
|
+
|
|
748
|
+
The `CompanyInfo` and `CompanyFallback` types describe the two shapes.
|
|
749
|
+
|
|
717
750
|
## `@sjolystinnovation/app-kit/forms`
|
|
718
751
|
|
|
719
752
|
`useFormDraft(config, options)` keeps a form's values in `localStorage`, so a sign-out, reload, or
|
|
@@ -981,7 +1014,7 @@ const params = useSearchParams();
|
|
|
981
1014
|
| `loginHref` | `string` | Where the sign-in links go. |
|
|
982
1015
|
| `initialEmail`, `initialCode` | `string` | The values from the emailed link. A code opens the second step. |
|
|
983
1016
|
| `className` | `string` | Replaces the outer `max-w-md mx-auto px-4 py-16`, such as to sit in the app's own card. |
|
|
984
|
-
| `strings` | `TextOverrides<ResetPasswordFlowStrings>` | The page's text. The English defaults include an intro, the password rules as `passwordHint`, and a `done` text. Set `signInPrompt`, `intro`, `codeSent`, or `passwordHint` to `null` to leave it out. `done` set to `null` shows the API's message, or only the sign-in link when it sends none. `passwordInvalid` set to `null` lists every broken rule. `validation` and `errors` may name only some codes. |
|
|
1017
|
+
| `strings` | `TextOverrides<ResetPasswordFlowStrings>` | The page's text. The English defaults include an intro, the password rules as `passwordHint`, and a `done` text. Set `signInPrompt`, `intro`, `codeSent`, or `passwordHint` to `null` to leave it out. The hint gives way to the new password's error while one shows, since the default `passwordInvalid` repeats the rules. `done` set to `null` shows the API's message, or only the sign-in link when it sends none. `passwordInvalid` set to `null` lists every broken rule. `validation` and `errors` may name only some codes. |
|
|
985
1018
|
| `passwordStrings` | `TextOverrides<PasswordStrings>` | The rule messages, in the app's language. |
|
|
986
1019
|
|
|
987
1020
|
A password that breaks the rules is marked under the new password field, without a call to the API.
|
|
@@ -1165,19 +1198,31 @@ it plots and its label. It takes the next color of a built-in palette of five un
|
|
|
1165
1198
|
The stats page card for the transactional email, from the `MapAppKitEmailStats` endpoint of
|
|
1166
1199
|
SjolystInnovation.AppKit.Email through `createEmailStatsService`. It shows the emails sent,
|
|
1167
1200
|
delivered, bounced, and blocked or reported as spam, for the last `days` days. Hard and soft bounces
|
|
1168
|
-
count together
|
|
1169
|
-
|
|
1170
|
-
|
|
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`.
|
|
1171
1211
|
|
|
1172
1212
|
```tsx
|
|
1173
1213
|
<EmailStatsPanel service={createEmailStatsService(api)} strings={{ title: "E-post (siste {days} dager)" }} />
|
|
1214
|
+
|
|
1215
|
+
<EmailStatsPanel service={createEmailStatsService(api)} periods={[7, 30, 90]} separateCounts />
|
|
1174
1216
|
```
|
|
1175
1217
|
|
|
1176
1218
|
| Prop | Type | Description |
|
|
1177
1219
|
| --- | --- | --- |
|
|
1178
1220
|
| `service` | `{ get(days?) }` | The email stats call, such as `createEmailStatsService(api)`. |
|
|
1179
|
-
| `days` | `number` | How many days back the stats go. Defaults to 30. |
|
|
1180
|
-
| `
|
|
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. |
|
|
1181
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. |
|
|
1182
1227
|
|
|
1183
1228
|
## `@sjolystinnovation/app-kit/branding`
|
|
@@ -1744,7 +1789,7 @@ The components draw every color, corner radius, and label size from theme variab
|
|
|
1744
1789
|
| `--font-weight-auth-link` | semibold |
|
|
1745
1790
|
| `--color-admin-title` | gray 900, the title of `AdminShell` |
|
|
1746
1791
|
| `--color-admin-border` | gray 200, the line under the tabs |
|
|
1747
|
-
| `--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 |
|
|
1748
1793
|
| `--color-admin-tab-active` | gray 900 |
|
|
1749
1794
|
| `--color-admin-tab-indicator` | `var(--color-primary)`, the line under the active tab |
|
|
1750
1795
|
| `--color-chart-grid` | gray 200, the scale lines of `StatHistoryChart` |
|
|
@@ -1752,7 +1797,7 @@ The components draw every color, corner radius, and label size from theme variab
|
|
|
1752
1797
|
| `--color-admin-stat-label` | gray 500, the labels and the waiting text of `EmailStatsPanel` |
|
|
1753
1798
|
| `--color-admin-stat-value` | gray 700, the numbers of `EmailStatsPanel` |
|
|
1754
1799
|
| `--radius-button` | `var(--radius-lg)` |
|
|
1755
|
-
| `--color-button-primary-bg` | `var(--color-primary)` |
|
|
1800
|
+
| `--color-button-primary-bg` | `var(--color-primary)`, also the period `EmailStatsPanel` has picked |
|
|
1756
1801
|
| `--color-button-primary-text` | `var(--color-primary-foreground)` |
|
|
1757
1802
|
| `--color-button-secondary-border` | gray 300 |
|
|
1758
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
|
|
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",
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
"types": "./src/next-auth.d.ts"
|
|
28
28
|
},
|
|
29
29
|
"./api": "./src/api/index.ts",
|
|
30
|
+
"./company": "./src/company/index.ts",
|
|
30
31
|
"./server": "./src/server/index.ts",
|
|
31
32
|
"./ui": "./src/ui/index.ts",
|
|
32
33
|
"./admin": "./src/admin/index.ts",
|
|
@@ -111,7 +112,7 @@
|
|
|
111
112
|
"@vitejs/plugin-react": "^6.1.2",
|
|
112
113
|
"axios": "^1.20.0",
|
|
113
114
|
"jsdom": "^30.1.1",
|
|
114
|
-
"next": "^16.
|
|
115
|
+
"next": "^16.4.0",
|
|
115
116
|
"next-auth": "^4.24.15",
|
|
116
117
|
"react": "^19.3.0",
|
|
117
118
|
"react-dom": "^19.3.0",
|
|
@@ -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/api/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export { apiErrorCode } from "./apiErrorCode";
|
|
2
2
|
export { apiFieldErrors } from "./apiFieldErrors";
|
|
3
|
-
export { formatOrgNumber, mergeCompanyInfo } from "
|
|
4
|
-
export type { CompanyFallback, CompanyInfo } from "
|
|
3
|
+
export { formatOrgNumber, mergeCompanyInfo } from "../company";
|
|
4
|
+
export type { CompanyFallback, CompanyInfo } from "../company";
|
|
5
5
|
export { createApiClient } from "./createApiClient";
|
|
6
6
|
export { formatApiError } from "./formatApiError";
|
|
7
7
|
export type { StatusMessages } from "./formatApiError";
|
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 },
|
package/src/server/publicApi.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { PHASE_PRODUCTION_BUILD } from "next/constants";
|
|
2
|
+
|
|
1
3
|
/** A response from `publicGetWithMeta`, with its `Last-Modified` date. */
|
|
2
4
|
export interface PublicResponse<T> {
|
|
3
5
|
data: T;
|
|
@@ -15,6 +17,12 @@ export interface PublicGetOptions {
|
|
|
15
17
|
export interface PublicApiOptions {
|
|
16
18
|
/** The seconds a cached response stays fresh. Defaults to `60`. */
|
|
17
19
|
revalidate?: number | undefined;
|
|
20
|
+
/**
|
|
21
|
+
* Whether a failed fetch gives `null` with a warning, in place of `PublicApiError`, while
|
|
22
|
+
* `next build` prerenders. The page then builds empty and ISR fills it on its first revalidate.
|
|
23
|
+
* Outside the build a failure still throws. Defaults to `false`.
|
|
24
|
+
*/
|
|
25
|
+
nullOnFailureDuringBuild?: boolean | undefined;
|
|
18
26
|
}
|
|
19
27
|
|
|
20
28
|
/** Thrown when the API could not be reached, or answered with no usable content. */
|
|
@@ -43,7 +51,8 @@ function lastModifiedOf(response: Response): Date | null {
|
|
|
43
51
|
* handlers.
|
|
44
52
|
*
|
|
45
53
|
* A 404 returns null. An unreachable or failing API throws `PublicApiError`, so a failed fetch is
|
|
46
|
-
* never cached as an empty page in place of a working one.
|
|
54
|
+
* never cached as an empty page in place of a working one. With `nullOnFailureDuringBuild`, a
|
|
55
|
+
* failure during `next build` gives null instead, so the build does not depend on the API.
|
|
47
56
|
*/
|
|
48
57
|
export function createPublicApi(baseUrl: string, options: PublicApiOptions = {}) {
|
|
49
58
|
if (!baseUrl) {
|
|
@@ -68,6 +77,19 @@ export function createPublicApi(baseUrl: string, options: PublicApiOptions = {})
|
|
|
68
77
|
}
|
|
69
78
|
|
|
70
79
|
async function publicGetWithMeta<T>(path: string, opts: PublicGetOptions = {}): Promise<PublicResponse<T> | null> {
|
|
80
|
+
try {
|
|
81
|
+
return await fetchWithMeta<T>(path, opts);
|
|
82
|
+
} catch (error) {
|
|
83
|
+
// Read at call time, since next build sets the phase for the process that prerenders.
|
|
84
|
+
if (!options.nullOnFailureDuringBuild || !(error instanceof PublicApiError) || process.env.NEXT_PHASE !== PHASE_PRODUCTION_BUILD) {
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
console.warn(`${error.message} during next build. The page builds without it until it revalidates.`);
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function fetchWithMeta<T>(path: string, opts: PublicGetOptions): Promise<PublicResponse<T> | null> {
|
|
71
93
|
const url = urlFor(path);
|
|
72
94
|
if (url == null) return null;
|
|
73
95
|
let response: Response;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import Link from "next/link";
|
|
4
|
-
import { useState, type FormEvent } from "react";
|
|
4
|
+
import { useId, useState, type FormEvent } from "react";
|
|
5
5
|
import { errorText, fieldErrorTexts } from "../api/fieldErrorText";
|
|
6
6
|
import { passwordProblems, type PasswordStrings } from "../validation/passwordRules";
|
|
7
7
|
import { Alert } from "./Alert";
|
|
@@ -76,6 +76,7 @@ export function ResetPasswordFlow({
|
|
|
76
76
|
const [fieldErrors, setFieldErrors] = useState<Record<string, string[]>>({});
|
|
77
77
|
const [done, setDone] = useState<string | null>(null);
|
|
78
78
|
const [loading, setLoading] = useState(false);
|
|
79
|
+
const hintId = `${useId()}-hint`;
|
|
79
80
|
|
|
80
81
|
// Field errors go under the fields this step shows, and the message holds the rest.
|
|
81
82
|
const fail = (err: unknown, shown: readonly string[]) => {
|
|
@@ -91,6 +92,10 @@ export function ResetPasswordFlow({
|
|
|
91
92
|
};
|
|
92
93
|
|
|
93
94
|
const fieldError = (field: string) => fieldErrors[field]?.join(" ");
|
|
95
|
+
// The error under the new password says what to fix, and by default it holds the rules, so the
|
|
96
|
+
// hint steps aside while it shows.
|
|
97
|
+
const passwordError = fieldError("newPassword");
|
|
98
|
+
const showHint = Boolean(text.passwordHint) && !passwordError;
|
|
94
99
|
|
|
95
100
|
const sendCode = async (event: FormEvent) => {
|
|
96
101
|
event.preventDefault();
|
|
@@ -159,12 +164,13 @@ export function ResetPasswordFlow({
|
|
|
159
164
|
<PasswordInput
|
|
160
165
|
label={text.newPassword}
|
|
161
166
|
name="newPassword"
|
|
162
|
-
error={
|
|
167
|
+
error={passwordError}
|
|
168
|
+
aria-describedby={showHint ? hintId : undefined}
|
|
163
169
|
value={newPassword}
|
|
164
170
|
onChange={(e) => setNewPassword(e.target.value)}
|
|
165
171
|
required
|
|
166
172
|
/>
|
|
167
|
-
{
|
|
173
|
+
{showHint && <p id={hintId} className="text-xs text-auth-hint">{text.passwordHint}</p>}
|
|
168
174
|
<button type="submit" disabled={loading} className={button}>
|
|
169
175
|
{loading ? text.settingPassword : text.setPassword}
|
|
170
176
|
</button>
|
|
@@ -50,7 +50,7 @@ export interface ResetPasswordFlowStrings {
|
|
|
50
50
|
email: string;
|
|
51
51
|
code: string;
|
|
52
52
|
newPassword: string;
|
|
53
|
-
/** The text shown under the new password field. */
|
|
53
|
+
/** The text shown under the new password field. It gives way to the field's error while one shows. */
|
|
54
54
|
passwordHint: string | null;
|
|
55
55
|
requestCode: string;
|
|
56
56
|
requestingCode: string;
|