@sjolystinnovation/app-kit 1.0.0 → 1.0.2

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
@@ -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`, or
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`, or
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. |
@@ -615,24 +617,8 @@ The package also exports the response types `UserProfile`, `AdminUser`, `SiteSet
615
617
 
616
618
  ### `mergeCompanyInfo` and `formatOrgNumber`
617
619
 
618
- `mergeCompanyInfo(data, fallback)` gives the company details for public pages: the API's `/company`
619
- answer, with the app's own value for each field it left blank or could not give. Only the
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.
620
+ `./api` also exports the [`company`](#sjolystinnovationapp-kitcompany) helpers and types, so
621
+ existing imports keep working.
636
622
 
637
623
  ### `defineRevalidateTargets`
638
624
 
@@ -661,7 +647,8 @@ The package also exports the `RevalidateTargetsConfig` and `RevalidateTargetPath
661
647
 
662
648
  ## `@sjolystinnovation/app-kit/server`
663
649
 
664
- For server components and route handlers only. It imports `next/cache` and `next/server`.
650
+ For server components and route handlers only. It imports `next/cache`, `next/constants`, and
651
+ `next/server`.
665
652
 
666
653
  ### `createPublicApi`
667
654
 
@@ -688,6 +675,19 @@ export const { publicGet, publicGetOptional, publicGetWithMeta } = createPublicA
688
675
  );
689
676
  ```
690
677
 
678
+ `next build` prerenders the pages that call these, so by default a build fails when the API is down.
679
+ Set `nullOnFailureDuringBuild: true` to build those pages empty instead. During the build, an
680
+ unreachable API or an error answer gives `null` and logs a warning with the path and status, so the
681
+ empty page shows in the build log. ISR fills the page on its first revalidate once the API answers.
682
+ At runtime a failure still throws, so a working page is never replaced by an empty one.
683
+
684
+ ```ts
685
+ export const { publicGet, publicGetOptional, publicGetWithMeta } = createPublicApi(
686
+ requireEnv("NEXT_PUBLIC_API_URL", process.env.NEXT_PUBLIC_API_URL),
687
+ { nullOnFailureDuringBuild: true },
688
+ );
689
+ ```
690
+
691
691
  The package also exports the `PublicApiOptions`, `PublicGetOptions`, and `PublicResponse` types.
692
692
 
693
693
  ### `createRevalidateRoute`
@@ -714,6 +714,30 @@ export const POST = createRevalidateRoute({ targets: TARGET_PATHS, role: "Admin"
714
714
  `{ builds: ["/builds", "/builds/[slug]", "/sitemap.xml"] }`. The package also exports the
715
715
  `RevalidateRouteOptions` type.
716
716
 
717
+ ## `@sjolystinnovation/app-kit/company`
718
+
719
+ The company details for public pages, such as a footer or a contact page. The entry imports no
720
+ peer dependency, so a static site without axios or next-auth can use it.
721
+
722
+ `mergeCompanyInfo(data, fallback)` gives the company details: the API's `/company` answer, with the
723
+ app's own value for each field it left blank or could not give. Only the fallback's name is
724
+ required. The legal name falls back to the name, and VAT to not registered.
725
+
726
+ `formatOrgNumber({ orgNumber, vatRegistered })` adds ` MVA` once the business is VAT registered, and
727
+ gives an empty string without a number.
728
+
729
+ ```ts
730
+ // src/lib/companyInfo.ts
731
+ import { formatOrgNumber, mergeCompanyInfo, type CompanyInfo } from "@sjolystinnovation/app-kit/company";
732
+
733
+ export const getCompanyInfo = async () =>
734
+ mergeCompanyInfo(await publicGetOptional<CompanyInfo>("/company"), { name: "Example AS" });
735
+
736
+ export const orgLine = (company: CompanyInfo) => formatOrgNumber(company);
737
+ ```
738
+
739
+ The `CompanyInfo` and `CompanyFallback` types describe the two shapes.
740
+
717
741
  ## `@sjolystinnovation/app-kit/forms`
718
742
 
719
743
  `useFormDraft(config, options)` keeps a form's values in `localStorage`, so a sign-out, reload, or
@@ -977,11 +1001,11 @@ const params = useSearchParams();
977
1001
  | Prop | Type | Description |
978
1002
  | --- | --- | --- |
979
1003
  | `requestReset` | `(data: { email }) => Promise<unknown>` | Asks the API to email a code. |
980
- | `reset` | `(data: { email, code, newPassword }) => Promise<unknown>` | Sets the password. When it succeeds, the flow shows the `message` in its answer. |
1004
+ | `reset` | `(data: { email, code, newPassword }) => Promise<unknown>` | Sets the password. When it succeeds, the flow shows `strings.done`. |
981
1005
  | `loginHref` | `string` | Where the sign-in links go. |
982
1006
  | `initialEmail`, `initialCode` | `string` | The values from the emailed link. A code opens the second step. |
983
1007
  | `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. 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. |
1008
+ | `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
1009
  | `passwordStrings` | `TextOverrides<PasswordStrings>` | The rule messages, in the app's language. |
986
1010
 
987
1011
  A password that breaks the rules is marked under the new password field, without a call to the API.
@@ -1221,7 +1245,7 @@ image and toasts it. A successful save clears it. See
1221
1245
  ```tsx
1222
1246
  <BrandingManager
1223
1247
  save={form => BrandingService.update(form)}
1224
- strings={{ validation: { invalidImage: "Bruk PNG, JPEG, WebP, GIF eller ICO.", imageTooLarge: "Bildet er for stort." } }}
1248
+ strings={{ validation: { invalidImage: "Ugyldig filformat. Last opp en bildefil.", imageTooLarge: "Filen er for stor. Last opp en fil på 2 MB eller mindre." } }}
1225
1249
  />
1226
1250
  ```
1227
1251
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjolystinnovation/app-kit",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
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.3.6",
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",
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 "./company";
4
- export type { CompanyFallback, CompanyInfo } from "./company";
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";
@@ -43,7 +43,7 @@ export const defaultBrandingManagerStrings: BrandingManagerStrings = {
43
43
  saved: "Branding saved",
44
44
  saveFailed: "Failed to save branding",
45
45
  validation: {
46
- invalidImage: "The image must be a PNG, JPEG, WebP, GIF or ICO file of at most 2 MB.",
47
- imageTooLarge: "The image must be a PNG, JPEG, WebP, GIF or ICO file of at most 2 MB.",
46
+ invalidImage: "Invalid file format. Upload a PNG, JPEG, WebP, GIF or ICO image.",
47
+ imageTooLarge: "The file is too large. Upload a file of 2 MB or less.",
48
48
  },
49
49
  };
@@ -1,3 +1,5 @@
1
+ // Imports nothing, so the company entry works without axios or next-auth.
2
+
1
3
  /** The business as SjolystInnovation.AppKit.SiteSettings's public `/company` endpoint describes it. */
2
4
  export interface CompanyInfo {
3
5
  name: string;
@@ -0,0 +1,2 @@
1
+ export { formatOrgNumber, mergeCompanyInfo } from "./company";
2
+ export type { CompanyFallback, CompanyInfo } from "./company";
@@ -9,7 +9,7 @@ export interface SectionsEditorStrings {
9
9
  moveUp: string;
10
10
  moveDown: string;
11
11
  duplicate: string;
12
- /** The title of the button that removes a section. */
12
+ /** The title of the button that deletes a section. Keep it apart from `removeImage`, since both can show on one section. */
13
13
  remove: string;
14
14
  addSection: string;
15
15
  heading: string;
@@ -56,7 +56,7 @@ export const defaultSectionsEditorStrings: SectionsEditorStrings = {
56
56
  moveUp: "Move up",
57
57
  moveDown: "Move down",
58
58
  duplicate: "Duplicate",
59
- remove: "Remove",
59
+ remove: "Delete section",
60
60
  addSection: "Add section",
61
61
  heading: "Heading",
62
62
  headingOptional: "Heading (optional)",
@@ -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={fieldError("newPassword")}
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
- {text.passwordHint && <p className="text-xs text-auth-hint">{text.passwordHint}</p>}
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>
@@ -1,5 +1,10 @@
1
1
  // Plain values, with no "use client", so a server component can import them.
2
2
 
3
+ import { PASSWORD_MIN_LENGTH } from "../validation/passwordRules";
4
+
5
+ // The rules passwordProblems checks, for the hint and the weak password text.
6
+ const passwordRules = `Use at least ${PASSWORD_MIN_LENGTH} characters, with at least one lowercase and one uppercase letter from A to Z and one digit from 0 to 9.`;
7
+
3
8
  /**
4
9
  * The flow's text for each field error code of SjolystInnovation.AppKit.Auth's reset endpoints,
5
10
  * shown under the email, code or new password field. `{max}` in `passwordTooShort` becomes the
@@ -45,7 +50,7 @@ export interface ResetPasswordFlowStrings {
45
50
  email: string;
46
51
  code: string;
47
52
  newPassword: string;
48
- /** 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. */
49
54
  passwordHint: string | null;
50
55
  requestCode: string;
51
56
  requestingCode: string;
@@ -67,18 +72,18 @@ export const defaultResetPasswordFlowStrings: ResetPasswordFlowStrings = {
67
72
  signInPrompt: "Remembered it?",
68
73
  signIn: "Sign in",
69
74
  backToSignIn: "Back to sign in →",
70
- intro: null,
75
+ intro: "Enter your email address and we will send you a code.",
71
76
  codeSent: "Check your email for the reset code.",
72
77
  email: "Email",
73
78
  code: "Reset code",
74
79
  newPassword: "New password",
75
- passwordHint: null,
80
+ passwordHint: passwordRules,
76
81
  requestCode: "Send reset code",
77
82
  requestingCode: "Sending…",
78
83
  setPassword: "Reset password",
79
84
  settingPassword: "Resetting…",
80
- done: null,
81
- passwordInvalid: null,
85
+ done: "Your password has been changed. You can sign in now.",
86
+ passwordInvalid: `The password does not meet the requirements. ${passwordRules}`,
82
87
  somethingWentWrong: "Something went wrong",
83
88
  validation: {
84
89
  required: "Required.",