@sjolystinnovation/app-kit 0.20.0 → 0.21.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
@@ -243,6 +243,17 @@ export const getRecipe = (id: number) =>
243
243
  request(() => api.get<Recipe>(`/recipes/${id}`), "Could not load the recipe.");
244
244
  ```
245
245
 
246
+ `mergeCompanyInfo(data, fallback)` gives the company details for public pages: the API's
247
+ `/company` answer, with the app's own value for each field it left blank or could not give. Only
248
+ the fallback's name is required. The legal name falls back to the name, and VAT to not registered.
249
+ `formatOrgNumber({ orgNumber, vatRegistered })` adds ` MVA` once the business is VAT registered,
250
+ and gives an empty string without a number.
251
+
252
+ ```ts
253
+ // src/lib/companyInfo.ts
254
+ export const getCompanyInfo = async () => mergeCompanyInfo(await publicGetOptional<CompanyInfo>("/company"), COMPANY);
255
+ ```
256
+
246
257
  `requestRevalidate(target, endpoint?)` asks the app's revalidate route to purge the pages behind
247
258
  `target`, so an edit shows at once. It posts to `/api/revalidate` unless `endpoint` says otherwise.
248
259
  It never throws. On failure the pages' own revalidate window is the fallback.
@@ -321,6 +332,26 @@ export const POST = createRevalidateRoute({
321
332
  });
322
333
  ```
323
334
 
335
+ `defineRevalidateTargets({ locales, targets })`, from `@sjolystinnovation/app-kit/api`, keeps the
336
+ targets in one file that both sides import. `pages` are below each locale segment and `root` paths
337
+ are not. It returns `targets` for fetch tags, `paths` for the route, and `revalidate(target)` for
338
+ the admin services.
339
+
340
+ ```ts
341
+ // src/lib/cacheTags.ts
342
+ export const { targets: REVALIDATE_TARGETS, paths: TARGET_PATHS, revalidate: revalidateTarget } = defineRevalidateTargets({
343
+ locales: LOCALES,
344
+ targets: {
345
+ legal: { pages: ["/terms", "/privacy", "/cookies"], root: ["/sitemap.xml"] },
346
+ // Every page renders branding through the layout, so its tag reaches them all.
347
+ branding: {},
348
+ },
349
+ });
350
+
351
+ // src/app/api/revalidate/route.ts
352
+ export const POST = createRevalidateRoute({ targets: TARGET_PATHS, role: ADMIN_ROLE });
353
+ ```
354
+
324
355
  ### `@sjolystinnovation/app-kit/next-config`
325
356
 
326
357
  For `next.config.ts`. It imports nothing but Next's types, so Next loads it with no build step.
@@ -431,10 +462,14 @@ The site gives:
431
462
  - `pageMetadata({ locale, path, title, description, images })` for a page: the canonical URL,
432
463
  hreflang, and Open Graph with the locale's share image unless `images` is given.
433
464
  - `localeEntries(path, { locales, lastModified })` for `sitemap.ts`: one entry per locale, each
434
- listing the others. `lastModified` is left out when unknown.
465
+ listing the others. `lastModified` is left out when unknown. A site without locales gets one
466
+ entry per page.
467
+ - `forEachLocale(load)` runs `load` for every locale at once and keys the results by locale.
468
+ - `legalEntries({ keys, updatedAt })` gives the legal pages' entries, `/terms`, `/privacy` and
469
+ `/cookies` unless `keys` says otherwise, each dated by the timestamp `updatedAt` returns for it.
435
470
  - `robots({ privatePaths, privateRootPaths, indexable })` for `robots.ts`. The private paths are
436
471
  disallowed under every locale segment. A deployment that is not production disallows everything.
437
- - `organizationNode(company, { type, country, image, logo })`, `webSiteNode(locale)`,
472
+ - `organizationNode(company, { type, sameAs, country, image, logo })`, `webSiteNode(locale)`,
438
473
  `breadcrumbNode(crumbs, pageUrl)`, `itemListNode(paths, pageUrl)`, `siteId(fragment)` and `ids`
439
474
  for structured data.
440
475
 
@@ -443,6 +478,15 @@ The site gives:
443
478
  export const dynamic = "force-dynamic";
444
479
  export default () => site.robots({ privatePaths: ["/admin", "/login"], privateRootPaths: ["/api/"] });
445
480
 
481
+ // src/app/sitemap.ts
482
+ export const revalidate = 3600;
483
+ export default async () => [
484
+ ...site.localeEntries(""),
485
+ ...(await site.legalEntries({
486
+ updatedAt: async (key, locale) => (await publicGet<LegalPage>(`/content/legal/${key}?locale=${locale}`))?.updatedAt,
487
+ })),
488
+ ];
489
+
446
490
  // in a page
447
491
  <JsonLd nodes={[site.organizationNode(company), site.webSiteNode(locale)]} />
448
492
  ```
@@ -450,8 +494,8 @@ export default () => site.robots({ privatePaths: ["/admin", "/login"], privateRo
450
494
  `isIndexableEnvironment()` reads `SITE_ENV`. `prod`, `production` and unset or blank count as
451
495
  production, so a missing variable never deindexes the live site. `robots.ts` needs
452
496
  `dynamic = "force-dynamic"`, so it reads the variable per request. The package also exports
453
- `JsonLd`, `serializeGraph`, `ref`, `NOINDEX`, `SHARE_IMAGE_SIZE`, `normalizePath`, `latest` and
454
- `parseTimestamp`.
497
+ `JsonLd`, `serializeGraph`, `ref`, `NOINDEX`, `LEGAL_PAGE_KEYS`, `SHARE_IMAGE_SIZE`,
498
+ `normalizePath`, `latest` and `parseTimestamp`.
455
499
 
456
500
  ### `@sjolystinnovation/app-kit/seo/og`
457
501
 
@@ -579,6 +623,16 @@ if (loading) return <Spinner />;
579
623
 
580
624
  ### `@sjolystinnovation/app-kit/admin`
581
625
 
626
+ `CompanySettingsForm` is the admin form for the business details the public pages show, saved
627
+ through `createSettingsService`. Its children show below the form once the settings have loaded,
628
+ such as the `BrandingManager`. `strings` holds every label, hint and message, English by default.
629
+
630
+ ```tsx
631
+ <CompanySettingsForm service={SettingsService} strings={dict.companySettings}>
632
+ <BrandingManager save={BrandingService.update} />
633
+ </CompanySettingsForm>
634
+ ```
635
+
582
636
  The admin area. `AdminShell` is the frame: a title and a tab bar above the page, behind a
583
637
  `RoleGate`. It takes `RoleGate`'s `role`, `fallback`, and `denied` or `deniedHref`. The tab whose
584
638
  `href` is the current path is active, with `aria-current="page"`.
@@ -602,6 +656,23 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
602
656
  }
603
657
  ```
604
658
 
659
+ `StatHistoryChart` draws the admin stats over time: a line per series, a scale from zero to the
660
+ highest value shown, and legend buttons that hide and show each line. `data` is one row per day,
661
+ oldest first, with `date` as `YYYY-MM-DD`. A missing value counts as zero. Each series names the
662
+ field it plots and its label, and takes the next colour in `STAT_SERIES_COLORS` unless it has its
663
+ own. `strings` holds `empty`, shown when there are no days, and `chart`, the accessible name.
664
+
665
+ ```tsx
666
+ <StatHistoryChart
667
+ data={history}
668
+ series={[
669
+ { key: "totalUsers", label: dict.stats.users },
670
+ { key: "contentImages", label: dict.stats.images },
671
+ ]}
672
+ strings={{ empty: dict.admin.noHistory, chart: dict.admin.overTime }}
673
+ />
674
+ ```
675
+
605
676
  `AdminUsersPanel` is the users page. It lists every user with their roles, shows deleted users
606
677
  behind a toggle, gives and takes roles, deletes an account after a confirm, and invites a user by
607
678
  email. It calls the API through `createAdminUsersService`. `getRoles` returns the roles an admin
@@ -621,6 +692,39 @@ import { AdminUsersPanel } from "@sjolystinnovation/app-kit/admin";
621
692
  | `defaultInviteRole` | `string` | The role a new invite starts with. Defaults to `Admin`. |
622
693
  | `strings` | `TextOverrides<AdminUsersPanelStrings>` | The text, English by default. `{name}` and `{role}` are filled in where a text names them. |
623
694
 
695
+ `LegalPagesEditor` edits the legal pages one page and one locale at a time, through
696
+ `createLegalService`. A locale tab whose page has no content yet gets a dot. `LocaleTabs` is the
697
+ same tab bar on its own, for other translated forms.
698
+
699
+ ```tsx
700
+ <LegalPagesEditor
701
+ service={LegalService}
702
+ keys={LEGAL_PAGE_KEYS}
703
+ keyLabels={{ terms: dict.footer.terms, privacy: dict.footer.privacy, cookies: dict.footer.cookies }}
704
+ locales={LOCALES}
705
+ localeLabels={LOCALE_LABELS}
706
+ strings={dict.legalEditor}
707
+ />
708
+ ```
709
+
710
+ ### `@sjolystinnovation/app-kit/legal`
711
+
712
+ `LegalPageView` renders a terms, privacy or cookies page as an admin wrote it: the title, the date
713
+ it last changed in `dateLocale`, and the Markdown body. With no page it shows `title` and says the
714
+ page is missing. It is a server component and needs the same peers as `markdown`. The route file
715
+ stays in the app, since Next reads `revalidate` and the metadata from it.
716
+
717
+ ```tsx
718
+ // src/app/[locale]/terms/page.tsx
719
+ export const revalidate = 3600;
720
+
721
+ export default async function TermsPage({ params }: { params: Promise<{ locale: Locale }> }) {
722
+ const { locale } = await params;
723
+ const page = await publicGet<LegalPage>(`/content/legal/terms?locale=${locale}`, { tags: [REVALIDATE_TARGETS.legal] });
724
+ return <LegalPageView page={page} title={dict.footer.terms} homeHref={`/${locale}`} dateLocale="nb-NO" strings={dict.legal} />;
725
+ }
726
+ ```
727
+
624
728
  ### `@sjolystinnovation/app-kit/session`
625
729
 
626
730
  Decides when a user has to sign in again. Safe to import from server code, such as the next-auth
@@ -1114,6 +1218,39 @@ const parsed = contactSchema.safeParse(form);
1114
1218
  if (!parsed.success) setErrors(zodFieldErrors(parsed.error, { message: (issue) => issueMessage(issue, dict) }));
1115
1219
  ```
1116
1220
 
1221
+ ### `@sjolystinnovation/app-kit/testing`
1222
+
1223
+ Test doubles for the modules every app's tests mock: `next-auth/react`, `next/navigation`, `sonner`
1224
+ or `@sjolystinnovation/app-kit/toast`, and the app's axios instance. vitest hoists `vi.mock` above
1225
+ the file's imports, so the factory imports the helpers itself. The test imports the same module to
1226
+ set the state and read the spies. The state lasts for the whole file, so call `resetTestMocks`
1227
+ before each test. It needs vitest 5, as an optional peer dependency.
1228
+
1229
+ ```tsx
1230
+ import { makeSession, navigation, resetTestMocks, setSession } from "@sjolystinnovation/app-kit/testing";
1231
+
1232
+ vi.mock("next-auth/react", async () => (await import("@sjolystinnovation/app-kit/testing")).nextAuthMock());
1233
+ vi.mock("next/navigation", async () => (await import("@sjolystinnovation/app-kit/testing")).nextNavigationMock());
1234
+
1235
+ beforeEach(resetTestMocks);
1236
+
1237
+ it("sends a user without the role away", () => {
1238
+ navigation.pathname = "/no/admin";
1239
+ setSession(makeSession({ roles: [] }));
1240
+ render(<AdminLayout><p>admin work</p></AdminLayout>);
1241
+ expect(navigation.router.replace).toHaveBeenCalledWith("/no");
1242
+ });
1243
+ ```
1244
+
1245
+ - `setSession(session | null | "loading")` and `makeSession({ roles, ...overrides })` set what
1246
+ `useSession` returns. `auth.signIn`, `auth.signOut` and `auth.getSession` are the spies behind
1247
+ next-auth's calls.
1248
+ - `navigation.pathname`, `navigation.searchParams` and `navigation.router` feed `usePathname`,
1249
+ `useSearchParams` and `useRouter`.
1250
+ - `toastMock()` records every toast in `toastSpies`.
1251
+ - `axiosInstanceMock()` is a module whose default export is `axiosSpies`, for
1252
+ `vi.mock("@/services/axiosInstance", ...)`.
1253
+
1117
1254
  ## Theming
1118
1255
 
1119
1256
  The components draw every colour, corner radius and label size from theme variables with a light
@@ -1173,6 +1310,8 @@ selector such as `.dark` for a scoped theme:
1173
1310
  | `--color-admin-tab`, `-hover` | gray 500 and gray 900 |
1174
1311
  | `--color-admin-tab-active` | gray 900 |
1175
1312
  | `--color-admin-tab-indicator` | `var(--color-primary)`, the line under the active tab |
1313
+ | `--color-chart-grid` | gray 200, the scale lines of `StatHistoryChart` |
1314
+ | `--color-chart-label` | gray 400, the chart's numbers and dates |
1176
1315
  | `--radius-button` | `var(--radius-lg)` |
1177
1316
  | `--color-button-primary-bg` | `var(--color-primary)` |
1178
1317
  | `--color-button-primary-text` | `var(--color-primary-foreground)` |
@@ -1209,9 +1348,13 @@ with it and has to set the ones it uses itself.
1209
1348
 
1210
1349
  ## Requirements
1211
1350
 
1212
- - Next.js 16, React 19, next-auth 4.24 or later and axios 1.20 or later, as peer dependencies the
1213
- app installs itself.
1351
+ - Next.js 16 and React 19, as peer dependencies the app installs itself.
1352
+ - For the root entry, `auth`, `api`, `server`, `session/react`, `ui` and `admin`: next-auth 4.24
1353
+ or later and axios 1.20 or later, as optional peer dependencies. A site without sign-in, which
1354
+ uses only entries such as `seo`, `next-config`, `markdown`, `toast` or `validation`, leaves them
1355
+ out.
1214
1356
  - For `@sjolystinnovation/app-kit/validation`: zod 4, as an optional peer dependency.
1357
+ - For `@sjolystinnovation/app-kit/testing`: vitest 5, as an optional peer dependency.
1215
1358
  - For `@sjolystinnovation/app-kit/toast`: sonner 2, as an optional peer dependency. Install a single
1216
1359
  copy, because two copies keep two separate toast stores.
1217
1360
  - `jsonwebtoken` comes with the package. `@sjolystinnovation/app-kit/auth` uses it on the server.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjolystinnovation/app-kit",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "description": "Building blocks for Next.js apps that sign in against a JWT API through next-auth",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Sjølyst Innovation AS",
@@ -31,11 +31,13 @@
31
31
  "./validation": "./src/validation/index.ts",
32
32
  "./next-config": "./src/next-config/index.ts",
33
33
  "./markdown": "./src/markdown/index.ts",
34
+ "./legal": "./src/legal/index.ts",
34
35
  "./seo": "./src/seo/index.ts",
35
36
  "./seo/og": "./src/seo/og/index.tsx",
36
37
  "./branding": "./src/branding/index.ts",
37
38
  "./branding/server": "./src/branding/server.ts",
38
39
  "./payments": "./src/payments/index.ts",
40
+ "./testing": "./src/testing/index.ts",
39
41
  "./styles.css": "./src/styles.css"
40
42
  },
41
43
  "files": [
@@ -60,12 +62,22 @@
60
62
  "rehype-sanitize": "^6",
61
63
  "remark-gfm": "^4",
62
64
  "sonner": "^2",
65
+ "vitest": "^5",
63
66
  "zod": "^4"
64
67
  },
65
68
  "peerDependenciesMeta": {
69
+ "axios": {
70
+ "optional": true
71
+ },
72
+ "next-auth": {
73
+ "optional": true
74
+ },
66
75
  "sonner": {
67
76
  "optional": true
68
77
  },
78
+ "vitest": {
79
+ "optional": true
80
+ },
69
81
  "zod": {
70
82
  "optional": true
71
83
  },
@@ -0,0 +1,122 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef, useState, type FormEvent, type ReactNode } from "react";
4
+ import type { SiteSettings } from "../api/services";
5
+ import { toast } from "../toast";
6
+ import { TextInput } from "../ui/TextInput";
7
+ import { Toggle } from "../ui/Toggle";
8
+ import { withDefaults, type TextOverrides } from "../ui/withDefaults";
9
+ import { defaultCompanySettingsFormStrings, type CompanySettingsFormStrings } from "./companySettingsFormStrings";
10
+
11
+ export interface CompanySettingsFormProps {
12
+ /** The settings calls, such as `createSettingsService(api)`. */
13
+ service: { get: () => Promise<SiteSettings>; update: (settings: SiteSettings) => Promise<SiteSettings> };
14
+ strings?: TextOverrides<CompanySettingsFormStrings> | undefined;
15
+ /** Shown below the form once the settings have loaded, such as the `BrandingManager`. */
16
+ children?: ReactNode;
17
+ }
18
+
19
+ const EMPTY: SiteSettings = {
20
+ contactRecipientEmail: "",
21
+ companyName: "",
22
+ companyLegalName: "",
23
+ orgNumber: "",
24
+ vatRegistered: false,
25
+ streetAddress: "",
26
+ postalCode: "",
27
+ addressLocality: "",
28
+ addressRegion: "",
29
+ publicEmail: "",
30
+ publicPhone: "",
31
+ };
32
+
33
+ /**
34
+ * The admin form for the business details the public pages show: the contact recipient, names, org
35
+ * number and VAT, the address in parts, and the public email and phone.
36
+ */
37
+ export function CompanySettingsForm({ service, strings, children }: CompanySettingsFormProps) {
38
+ const text = withDefaults(defaultCompanySettingsFormStrings, strings);
39
+ const [settings, setSettings] = useState<SiteSettings>(EMPTY);
40
+ const [loading, setLoading] = useState(true);
41
+ const [saving, setSaving] = useState(false);
42
+ const [failed, setFailed] = useState(false);
43
+ // The latest service, so an inline one does not load the settings again on every render.
44
+ const serviceRef = useRef(service);
45
+ useEffect(() => {
46
+ serviceRef.current = service;
47
+ }, [service]);
48
+
49
+ useEffect(() => {
50
+ serviceRef.current
51
+ .get()
52
+ .then(setSettings)
53
+ .catch(() => {
54
+ // No form without the saved settings, since saving a blank one would erase them.
55
+ setFailed(true);
56
+ toast.error(text.loadFailed);
57
+ })
58
+ .finally(() => setLoading(false));
59
+ }, [text.loadFailed]);
60
+
61
+ const patch = (changes: Partial<SiteSettings>) => setSettings(prev => ({ ...prev, ...changes }));
62
+
63
+ const handleSubmit = async (e: FormEvent) => {
64
+ e.preventDefault();
65
+ setSaving(true);
66
+ try {
67
+ setSettings(await serviceRef.current.update(settings));
68
+ toast.success(text.saved);
69
+ } catch (err) {
70
+ toast.error(err instanceof Error && err.message ? err.message : text.saveFailed);
71
+ } finally {
72
+ setSaving(false);
73
+ }
74
+ };
75
+
76
+ if (loading) return <p className="text-gray-500">{text.loading}</p>;
77
+ if (failed) return <p className="text-gray-500">{text.loadFailed}</p>;
78
+
79
+ return (
80
+ <div className="space-y-8">
81
+ <form onSubmit={handleSubmit} className="max-w-md space-y-4">
82
+ <TextInput
83
+ label={text.contactRecipient}
84
+ name="contactRecipientEmail"
85
+ type="email"
86
+ value={settings.contactRecipientEmail}
87
+ onChange={e => patch({ contactRecipientEmail: e.target.value })}
88
+ required
89
+ />
90
+ <p className="text-xs text-gray-500">{text.contactRecipientHint}</p>
91
+
92
+ <TextInput label={text.companyName} name="companyName" value={settings.companyName} onChange={e => patch({ companyName: e.target.value })} required />
93
+ <TextInput label={text.legalName} name="companyLegalName" value={settings.companyLegalName} onChange={e => patch({ companyLegalName: e.target.value })} />
94
+ <p className="text-xs text-gray-500">{text.legalNameHint}</p>
95
+
96
+ <TextInput label={text.orgNumber} name="orgNumber" value={settings.orgNumber} onChange={e => patch({ orgNumber: e.target.value })} />
97
+ <Toggle label={text.vatRegistered} checked={settings.vatRegistered} onChange={v => patch({ vatRegistered: v })} />
98
+ <p className="text-xs text-gray-500">{text.vatHint}</p>
99
+
100
+ <TextInput label={text.streetAddress} name="streetAddress" value={settings.streetAddress} onChange={e => patch({ streetAddress: e.target.value })} required />
101
+ <div className="grid gap-4 sm:grid-cols-[8rem_1fr]">
102
+ <TextInput label={text.postalCode} name="postalCode" inputMode="numeric" value={settings.postalCode} onChange={e => patch({ postalCode: e.target.value })} />
103
+ <TextInput label={text.addressLocality} name="addressLocality" value={settings.addressLocality} onChange={e => patch({ addressLocality: e.target.value })} />
104
+ </div>
105
+ <TextInput label={text.addressRegion} name="addressRegion" value={settings.addressRegion} onChange={e => patch({ addressRegion: e.target.value })} />
106
+ <p className="text-xs text-gray-500">{text.addressHint}</p>
107
+ <TextInput label={text.publicEmail} name="publicEmail" type="email" value={settings.publicEmail} onChange={e => patch({ publicEmail: e.target.value })} required />
108
+ <TextInput label={text.publicPhone} name="publicPhone" value={settings.publicPhone} onChange={e => patch({ publicPhone: e.target.value })} required />
109
+
110
+ <button
111
+ type="submit"
112
+ disabled={saving}
113
+ className="rounded-button bg-button-primary-bg text-button-primary-text font-semibold px-5 py-2.5 hover:brightness-95 disabled:opacity-60 transition"
114
+ >
115
+ {saving ? text.saving : text.save}
116
+ </button>
117
+ </form>
118
+
119
+ {children}
120
+ </div>
121
+ );
122
+ }
@@ -0,0 +1,131 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef, useState } from "react";
4
+ import type { LegalPage } from "../api/services";
5
+ import { toast } from "../toast";
6
+ import { TextInput } from "../ui/TextInput";
7
+ import { withDefaults, type TextOverrides } from "../ui/withDefaults";
8
+ import { defaultLegalPagesEditorStrings, type LegalPagesEditorStrings } from "./legalPagesEditorStrings";
9
+ import { LocaleTabs } from "./LocaleTabs";
10
+
11
+ export interface LegalPagesEditorProps<K extends string, L extends string> {
12
+ /** The legal page calls, such as `createLegalService(api, publicApi)`. */
13
+ service: {
14
+ listAll: () => Promise<LegalPage<K>[]>;
15
+ save: (key: K, locale: L, title: string, bodyMarkdown: string) => Promise<unknown>;
16
+ };
17
+ /** The pages, in tab order. The first opens. */
18
+ keys: readonly K[];
19
+ /** Each page's tab label, such as `Terms`. */
20
+ keyLabels: Record<K, string>;
21
+ /** The locales each page is written in. The first opens. */
22
+ locales: readonly L[];
23
+ /** Each locale's tab label, such as `Norsk`. */
24
+ localeLabels: Record<L, string>;
25
+ strings?: TextOverrides<LegalPagesEditorStrings> | undefined;
26
+ }
27
+
28
+ type Draft = { title: string; bodyMarkdown: string };
29
+
30
+ const emptyDraft: Draft = { title: "", bodyMarkdown: "" };
31
+
32
+ /** The admin editor for the legal pages: one page and one locale at a time, with the title and the Markdown body. */
33
+ export function LegalPagesEditor<K extends string, L extends string>({ service, keys, keyLabels, locales, localeLabels, strings }: LegalPagesEditorProps<K, L>) {
34
+ const text = withDefaults(defaultLegalPagesEditorStrings, strings);
35
+ const [drafts, setDrafts] = useState<Record<string, Draft>>({});
36
+ const [activeKey, setActiveKey] = useState<K | undefined>(keys[0]);
37
+ const [activeLocale, setActiveLocale] = useState<L | undefined>(locales[0]);
38
+ const [loading, setLoading] = useState(true);
39
+ const [saving, setSaving] = useState(false);
40
+ const [failed, setFailed] = useState(false);
41
+ // The latest service, so an inline one does not load the pages again on every render.
42
+ const serviceRef = useRef(service);
43
+ useEffect(() => {
44
+ serviceRef.current = service;
45
+ }, [service]);
46
+
47
+ useEffect(() => {
48
+ serviceRef.current
49
+ .listAll()
50
+ .then(pages => setDrafts(Object.fromEntries(pages.map(p => [`${p.key}:${p.locale}`, { title: p.title, bodyMarkdown: p.bodyMarkdown }]))))
51
+ .catch((err: unknown) => {
52
+ // No editor without the saved pages, since saving a blank one would erase it.
53
+ setFailed(true);
54
+ toast.error(err instanceof Error && err.message ? err.message : text.loadFailed);
55
+ })
56
+ .finally(() => setLoading(false));
57
+ }, [text.loadFailed]);
58
+
59
+ if (activeKey === undefined || activeLocale === undefined) return null;
60
+
61
+ const slot = `${activeKey}:${activeLocale}`;
62
+ const draft = drafts[slot] ?? emptyDraft;
63
+
64
+ const patch = (changes: Partial<Draft>) => setDrafts(prev => ({ ...prev, [slot]: { ...(prev[slot] ?? emptyDraft), ...changes } }));
65
+
66
+ const save = async () => {
67
+ if (draft.title.trim() === "" || draft.bodyMarkdown.trim() === "") {
68
+ toast.error(text.required);
69
+ return;
70
+ }
71
+ setSaving(true);
72
+ try {
73
+ await serviceRef.current.save(activeKey, activeLocale, draft.title.trim(), draft.bodyMarkdown);
74
+ toast.success(text.saved);
75
+ } catch (err) {
76
+ toast.error(err instanceof Error && err.message ? err.message : text.saveFailed);
77
+ } finally {
78
+ setSaving(false);
79
+ }
80
+ };
81
+
82
+ if (loading) return <p className="text-gray-500">{text.loading}</p>;
83
+ if (failed) return <p className="text-gray-500">{text.loadFailed}</p>;
84
+
85
+ const filled = Object.fromEntries(locales.map(l => [l, (drafts[`${activeKey}:${l}`]?.bodyMarkdown ?? "") !== ""])) as Record<L, boolean>;
86
+
87
+ return (
88
+ <div className="space-y-4">
89
+ <div className="flex items-center justify-between">
90
+ <h2 className="font-bold text-gray-900">{text.heading}</h2>
91
+ <button
92
+ onClick={save}
93
+ disabled={saving}
94
+ className="rounded-button bg-button-primary-bg text-button-primary-text font-semibold px-5 py-2 text-sm hover:brightness-95 disabled:opacity-60 transition"
95
+ >
96
+ {saving ? text.saving : text.save}
97
+ </button>
98
+ </div>
99
+
100
+ <div className="flex gap-1">
101
+ {keys.map(key => (
102
+ <button
103
+ key={key}
104
+ onClick={() => setActiveKey(key)}
105
+ aria-pressed={key === activeKey}
106
+ className={`rounded-lg px-3 py-1.5 text-sm font-medium transition-colors ${key === activeKey ? "bg-gray-900 text-white" : "text-gray-600 hover:bg-gray-100"}`}
107
+ >
108
+ {keyLabels[key]}
109
+ </button>
110
+ ))}
111
+ </div>
112
+
113
+ <LocaleTabs locales={locales} labels={localeLabels} active={activeLocale} onChange={setActiveLocale} filled={filled} notFilled={text.notFilled} />
114
+
115
+ <TextInput label={text.title} value={draft.title} onChange={e => patch({ title: e.target.value })} />
116
+
117
+ <div>
118
+ <label htmlFor="legal-body" className="block text-sm font-medium text-gray-700 mb-1">
119
+ {text.bodyMarkdown}
120
+ </label>
121
+ <textarea
122
+ id="legal-body"
123
+ rows={24}
124
+ value={draft.bodyMarkdown}
125
+ onChange={e => patch({ bodyMarkdown: e.target.value })}
126
+ className="w-full rounded-lg border border-gray-300 px-3 py-2 font-mono text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-primary"
127
+ />
128
+ </div>
129
+ </div>
130
+ );
131
+ }
@@ -0,0 +1,35 @@
1
+ "use client";
2
+
3
+ export interface LocaleTabsProps<L extends string> {
4
+ locales: readonly L[];
5
+ /** Each locale's tab label, such as `Norsk`. */
6
+ labels: Record<L, string>;
7
+ active: L;
8
+ onChange: (locale: L) => void;
9
+ /** Which locales have content. A locale marked false gets a dot titled `notFilled`. */
10
+ filled?: Partial<Record<L, boolean>> | undefined;
11
+ /** The dot's title, such as "Not filled in". */
12
+ notFilled?: string | undefined;
13
+ }
14
+
15
+ /** Tabs for editing one translation at a time. */
16
+ export function LocaleTabs<L extends string>({ locales, labels, active, onChange, filled, notFilled = "Not filled in" }: LocaleTabsProps<L>) {
17
+ return (
18
+ <div className="flex items-center gap-1 border-b border-gray-200">
19
+ {locales.map(locale => (
20
+ <button
21
+ key={locale}
22
+ type="button"
23
+ onClick={() => onChange(locale)}
24
+ aria-pressed={locale === active}
25
+ className={`px-4 py-2 text-sm font-semibold border-b-2 -mb-px transition-colors ${
26
+ locale === active ? "border-gray-900 text-gray-900" : "border-transparent text-gray-500 hover:text-gray-900"
27
+ }`}
28
+ >
29
+ {labels[locale]}
30
+ {filled?.[locale] === false && <span className="ml-1.5 inline-block h-1.5 w-1.5 rounded-full bg-amber-400 align-middle" title={notFilled} />}
31
+ </button>
32
+ ))}
33
+ </div>
34
+ );
35
+ }
@@ -0,0 +1,99 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { withDefaults, type TextOverrides } from "../ui/withDefaults";
5
+ import { defaultStatHistoryChartStrings, STAT_SERIES_COLORS, type StatHistoryChartStrings } from "./statHistoryChartStrings";
6
+
7
+ /** One line on the chart. */
8
+ export interface StatSeries<T> {
9
+ /** The field of each day's row the line plots. A missing or non-numeric value counts as zero. */
10
+ key: keyof T & string;
11
+ label: string;
12
+ /** The line's colour. Defaults to the next colour in `STAT_SERIES_COLORS`. */
13
+ color?: string | undefined;
14
+ }
15
+
16
+ export interface StatHistoryChartProps<T extends { date: string }> {
17
+ /** One row per day, oldest first, with `date` as `YYYY-MM-DD`. */
18
+ data: readonly T[];
19
+ series: readonly StatSeries<T>[];
20
+ strings?: TextOverrides<StatHistoryChartStrings> | undefined;
21
+ }
22
+
23
+ const W = 720;
24
+ const H = 240;
25
+ const PAD = { top: 16, right: 16, bottom: 28, left: 32 };
26
+
27
+ /**
28
+ * A line per series over the days, with the scale from zero to the highest shown value. The legend
29
+ * buttons hide and show a line, and the scale follows the lines that are shown.
30
+ */
31
+ export function StatHistoryChart<T extends { date: string }>({ data, series, strings }: StatHistoryChartProps<T>) {
32
+ const text = withDefaults(defaultStatHistoryChartStrings, strings);
33
+ const [hidden, setHidden] = useState<string[]>([]);
34
+
35
+ const toggle = (key: string) => setHidden(prev => (prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key]));
36
+
37
+ if (data.length === 0) {
38
+ return <p className="text-sm text-gray-500">{text.empty}</p>;
39
+ }
40
+
41
+ const lines = series.map((s, i) => ({ ...s, color: s.color ?? STAT_SERIES_COLORS[i % STAT_SERIES_COLORS.length] }));
42
+ const shown = lines.filter(s => !hidden.includes(s.key));
43
+ const innerW = W - PAD.left - PAD.right;
44
+ const innerH = H - PAD.top - PAD.bottom;
45
+ // An API without a given series sends no value. It counts as zero rather than poisoning the scale with NaN.
46
+ const value = (d: T, key: keyof T) => Number(d[key]) || 0;
47
+ const maxVal = Math.max(1, ...data.flatMap(d => shown.map(s => value(d, s.key))));
48
+ const n = data.length;
49
+
50
+ const x = (i: number) => PAD.left + (n === 1 ? innerW / 2 : (i / (n - 1)) * innerW);
51
+ const y = (v: number) => PAD.top + innerH - (v / maxVal) * innerH;
52
+
53
+ const path = (key: keyof T) => data.map((d, i) => `${i === 0 ? "M" : "L"} ${x(i).toFixed(1)} ${y(value(d, key)).toFixed(1)}`).join(" ");
54
+
55
+ const ticks = [...new Set([0, Math.round(maxVal / 2), maxVal])];
56
+ const labelIdx = [...new Set([0, Math.floor((n - 1) / 2), n - 1])];
57
+
58
+ return (
59
+ <div>
60
+ <svg viewBox={`0 0 ${W} ${H}`} className="w-full h-auto" role="img" aria-label={text.chart}>
61
+ {ticks.map(t => (
62
+ <g key={t}>
63
+ <line x1={PAD.left} y1={y(t)} x2={W - PAD.right} y2={y(t)} className="stroke-chart-grid" strokeWidth={1} />
64
+ <text x={PAD.left - 6} y={y(t) + 3} textAnchor="end" fontSize={10} className="fill-chart-label">
65
+ {t}
66
+ </text>
67
+ </g>
68
+ ))}
69
+ {labelIdx.map(i => (
70
+ <text key={i} x={x(i)} y={H - 8} textAnchor="middle" fontSize={10} className="fill-chart-label">
71
+ {data[i]?.date.slice(5)}
72
+ </text>
73
+ ))}
74
+ {shown.map(s => (
75
+ <path key={s.key} d={path(s.key)} fill="none" stroke={s.color} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />
76
+ ))}
77
+ </svg>
78
+ <div className="mt-3 flex flex-wrap gap-2">
79
+ {lines.map(s => {
80
+ const off = hidden.includes(s.key);
81
+ return (
82
+ <button
83
+ key={s.key}
84
+ type="button"
85
+ onClick={() => toggle(s.key)}
86
+ aria-pressed={!off}
87
+ className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs transition ${
88
+ off ? "border-gray-200 bg-white text-gray-400" : "border-gray-300 bg-gray-50 text-gray-700"
89
+ }`}
90
+ >
91
+ <span className="inline-block h-2.5 w-2.5 rounded-full" style={{ backgroundColor: off ? "#d1d5db" : s.color }} />
92
+ {s.label}
93
+ </button>
94
+ );
95
+ })}
96
+ </div>
97
+ </div>
98
+ );
99
+ }
@@ -0,0 +1,50 @@
1
+ // Plain values, with no "use client", so a server component can import them.
2
+
3
+ /** The company settings form's text. Pass the app's own language. The defaults are English. */
4
+ export interface CompanySettingsFormStrings {
5
+ contactRecipient: string;
6
+ contactRecipientHint: string;
7
+ companyName: string;
8
+ legalName: string;
9
+ legalNameHint: string;
10
+ orgNumber: string;
11
+ vatRegistered: string;
12
+ vatHint: string;
13
+ streetAddress: string;
14
+ postalCode: string;
15
+ addressLocality: string;
16
+ addressRegion: string;
17
+ addressHint: string;
18
+ publicEmail: string;
19
+ publicPhone: string;
20
+ loading: string;
21
+ save: string;
22
+ saving: string;
23
+ loadFailed: string;
24
+ saved: string;
25
+ saveFailed: string;
26
+ }
27
+
28
+ export const defaultCompanySettingsFormStrings: CompanySettingsFormStrings = {
29
+ contactRecipient: "Contact enquiry recipient",
30
+ contactRecipientHint: "Contact form enquiries are emailed to this address.",
31
+ companyName: "Company name",
32
+ legalName: "Legal name",
33
+ legalNameHint: "The registered name. Leave blank to use the company name.",
34
+ orgNumber: "Organisation number",
35
+ vatRegistered: "VAT (MVA) registered",
36
+ vatHint: "When on, the org number is shown with the “MVA” suffix.",
37
+ streetAddress: "Street address",
38
+ postalCode: "Postal code",
39
+ addressLocality: "Place",
40
+ addressRegion: "County",
41
+ addressHint: "The county is optional.",
42
+ publicEmail: "Public email",
43
+ publicPhone: "Public phone",
44
+ loading: "Loading…",
45
+ save: "Save",
46
+ saving: "Saving…",
47
+ loadFailed: "Failed to load settings",
48
+ saved: "Settings saved",
49
+ saveFailed: "Failed to save",
50
+ };
@@ -1,6 +1,20 @@
1
+ export { CompanySettingsForm } from "./CompanySettingsForm";
2
+ export type { CompanySettingsFormProps } from "./CompanySettingsForm";
3
+ export { defaultCompanySettingsFormStrings } from "./companySettingsFormStrings";
4
+ export type { CompanySettingsFormStrings } from "./companySettingsFormStrings";
1
5
  export { AdminShell } from "./AdminShell";
2
6
  export type { AdminShellProps, AdminTab } from "./AdminShell";
7
+ export { StatHistoryChart } from "./StatHistoryChart";
8
+ export { STAT_SERIES_COLORS, defaultStatHistoryChartStrings } from "./statHistoryChartStrings";
9
+ export type { StatHistoryChartProps, StatSeries } from "./StatHistoryChart";
10
+ export type { StatHistoryChartStrings } from "./statHistoryChartStrings";
3
11
  export { AdminUsersPanel } from "./AdminUsersPanel";
4
12
  export type { AdminUsersPanelProps } from "./AdminUsersPanel";
5
13
  export { defaultAdminUsersPanelStrings } from "./adminUsersPanelStrings";
6
14
  export type { AdminUsersPanelStrings } from "./adminUsersPanelStrings";
15
+ export { LegalPagesEditor } from "./LegalPagesEditor";
16
+ export type { LegalPagesEditorProps } from "./LegalPagesEditor";
17
+ export { defaultLegalPagesEditorStrings } from "./legalPagesEditorStrings";
18
+ export type { LegalPagesEditorStrings } from "./legalPagesEditorStrings";
19
+ export { LocaleTabs } from "./LocaleTabs";
20
+ export type { LocaleTabsProps } from "./LocaleTabs";
@@ -0,0 +1,32 @@
1
+ // Plain values, with no "use client", so a server component can import them.
2
+
3
+ /** The legal pages editor's text. Pass the app's own language. The defaults are English. */
4
+ export interface LegalPagesEditorStrings {
5
+ heading: string;
6
+ title: string;
7
+ bodyMarkdown: string;
8
+ /** The title of the dot on a locale with no content yet. */
9
+ notFilled: string;
10
+ loading: string;
11
+ save: string;
12
+ saving: string;
13
+ loadFailed: string;
14
+ /** When the title or the content is blank. */
15
+ required: string;
16
+ saved: string;
17
+ saveFailed: string;
18
+ }
19
+
20
+ export const defaultLegalPagesEditorStrings: LegalPagesEditorStrings = {
21
+ heading: "Legal pages",
22
+ title: "Title",
23
+ bodyMarkdown: "Content (Markdown)",
24
+ notFilled: "Not filled in",
25
+ loading: "Loading…",
26
+ save: "Save",
27
+ saving: "Saving…",
28
+ loadFailed: "Failed to load the legal pages",
29
+ required: "Fill in the title and the content",
30
+ saved: "Page saved",
31
+ saveFailed: "Failed to save the page",
32
+ };
@@ -0,0 +1,16 @@
1
+ // Plain values, with no "use client", so a server component can import them.
2
+
3
+ export interface StatHistoryChartStrings {
4
+ /** Shown instead of the chart when there are no days yet. */
5
+ empty: string;
6
+ /** The chart's accessible name. */
7
+ chart: string;
8
+ }
9
+
10
+ export const defaultStatHistoryChartStrings: StatHistoryChartStrings = {
11
+ empty: "No history yet. Check back tomorrow.",
12
+ chart: "Stats over time",
13
+ };
14
+
15
+ /** The default line colours, in order: blue, green, amber, teal and purple. */
16
+ export const STAT_SERIES_COLORS = ["#2563eb", "#16a34a", "#d97706", "#0d9488", "#9333ea"] as const;
@@ -0,0 +1,46 @@
1
+ /** The business as SjolystInnovation.AppKit.SiteSettings's public `/company` endpoint describes it. */
2
+ export interface CompanyInfo {
3
+ name: string;
4
+ legalName: string;
5
+ orgNumber: string;
6
+ vatRegistered: boolean;
7
+ /** The address on one line, for display. The API derives it from the parts below. */
8
+ address: string;
9
+ streetAddress: string;
10
+ postalCode: string;
11
+ addressLocality: string;
12
+ addressRegion: string;
13
+ email: string;
14
+ phone: string;
15
+ }
16
+
17
+ /** What the app knows about the business without the API. Only the name is required. */
18
+ export type CompanyFallback = Partial<CompanyInfo> & { name: string };
19
+
20
+ /**
21
+ * The company details for public pages: the API's answer, with the app's own values for any field
22
+ * it left blank or could not give. The legal name falls back to the name, and VAT to not registered.
23
+ */
24
+ export function mergeCompanyInfo(data: Partial<CompanyInfo> | null | undefined, fallback: CompanyFallback): CompanyInfo {
25
+ const name = data?.name || fallback.name;
26
+ const pick = (key: Exclude<keyof CompanyInfo, "name" | "legalName" | "vatRegistered">) => data?.[key] || fallback[key] || "";
27
+ return {
28
+ name,
29
+ legalName: data?.legalName || fallback.legalName || name,
30
+ orgNumber: pick("orgNumber"),
31
+ vatRegistered: data?.vatRegistered ?? fallback.vatRegistered ?? false,
32
+ address: pick("address"),
33
+ streetAddress: pick("streetAddress"),
34
+ postalCode: pick("postalCode"),
35
+ addressLocality: pick("addressLocality"),
36
+ addressRegion: pick("addressRegion"),
37
+ email: pick("email"),
38
+ phone: pick("phone"),
39
+ };
40
+ }
41
+
42
+ /** The org number with the Norwegian ` MVA` suffix once the business is VAT registered. Empty without a number. */
43
+ export function formatOrgNumber({ orgNumber, vatRegistered }: { orgNumber?: string | null | undefined; vatRegistered?: boolean | null | undefined }): string {
44
+ if (!orgNumber) return "";
45
+ return vatRegistered ? `${orgNumber} MVA` : orgNumber;
46
+ }
package/src/api/index.ts CHANGED
@@ -1,7 +1,11 @@
1
1
  export { apiFieldErrors } from "./apiFieldErrors";
2
+ export { formatOrgNumber, mergeCompanyInfo } from "./company";
3
+ export type { CompanyFallback, CompanyInfo } from "./company";
2
4
  export { createApiClient } from "./createApiClient";
3
5
  export { request } from "./request";
4
6
  export { requestRevalidate } from "./requestRevalidate";
7
+ export { defineRevalidateTargets } from "./revalidateTargets";
8
+ export type { RevalidateTargetPaths, RevalidateTargetsConfig } from "./revalidateTargets";
5
9
  export {
6
10
  createPublicClient,
7
11
  createUserService,
@@ -0,0 +1,40 @@
1
+ import { normalizePath } from "../seo/site";
2
+ import { requestRevalidate } from "./requestRevalidate";
3
+
4
+ /** The pages one admin edit changes. */
5
+ export interface RevalidateTargetPaths {
6
+ /** Paths below each locale segment, such as `/builds/[slug]`. `""` is the locale's front page. */
7
+ pages?: readonly string[] | undefined;
8
+ /** Paths outside the locale segments, such as `/sitemap.xml`. */
9
+ root?: readonly string[] | undefined;
10
+ }
11
+
12
+ export interface RevalidateTargetsConfig<T extends string> {
13
+ /** The locale segments every page is under. A site without them omits this. */
14
+ locales?: readonly string[] | undefined;
15
+ targets: Record<T, RevalidateTargetPaths>;
16
+ }
17
+
18
+ /**
19
+ * The cache targets an app's admin edits purge, in one place for both sides: `targets` names them
20
+ * for fetch tags, `paths` is what `createRevalidateRoute` purges for each, and `revalidate` asks the
21
+ * route to purge one after an edit. A target with no paths is purged by its tag alone.
22
+ */
23
+ export function defineRevalidateTargets<T extends string>({ locales = [], targets }: RevalidateTargetsConfig<T>) {
24
+ const names = Object.keys(targets) as T[];
25
+
26
+ return {
27
+ targets: Object.fromEntries(names.map(name => [name, name])) as { readonly [K in T]: K },
28
+ paths: Object.fromEntries(
29
+ names.map(name => {
30
+ const { pages = [], root = [] } = targets[name];
31
+ const localized =
32
+ locales.length > 0
33
+ ? locales.flatMap(locale => pages.map(page => `/${locale}${normalizePath(page)}`))
34
+ : pages.map(page => normalizePath(page) || "/");
35
+ return [name, [...localized, ...root]];
36
+ }),
37
+ ) as Record<T, string[]>,
38
+ revalidate: (target: T): Promise<void> => requestRevalidate(target),
39
+ };
40
+ }
@@ -0,0 +1,61 @@
1
+ import Link from "next/link";
2
+ import type { LegalPage } from "../api/services";
3
+ import { Markdown } from "../markdown";
4
+ import { parseTimestamp } from "../seo/sitemap";
5
+ import { withDefaults, type TextOverrides } from "../ui/withDefaults";
6
+
7
+ /** The legal page's text around the content. Pass the app's own language. The defaults are English. */
8
+ export interface LegalPageViewStrings {
9
+ /** Before the date the page last changed. */
10
+ updated: string;
11
+ /** Shown in place of the content when nobody has written the page yet. */
12
+ missing: string;
13
+ /** The link back to the front page. */
14
+ toFrontPage: string;
15
+ }
16
+
17
+ export const defaultLegalPageViewStrings: LegalPageViewStrings = {
18
+ updated: "Last updated",
19
+ missing: "This content has not been written yet.",
20
+ toFrontPage: "Back to the front page",
21
+ };
22
+
23
+ export interface LegalPageViewProps {
24
+ /** The page from the API, or null when nobody has written it yet. */
25
+ page: LegalPage | null;
26
+ /** The heading while there is no page. */
27
+ title: string;
28
+ /** The front page, for the link at the bottom. */
29
+ homeHref: string;
30
+ /** The BCP 47 tag the date is written in, such as `nb-NO`. */
31
+ dateLocale: string;
32
+ strings?: TextOverrides<LegalPageViewStrings> | undefined;
33
+ }
34
+
35
+ /** A terms, privacy or cookies page as an admin wrote it: the title, when it last changed and the Markdown body. */
36
+ export function LegalPageView({ page, title, homeHref, dateLocale, strings }: LegalPageViewProps) {
37
+ const text = withDefaults(defaultLegalPageViewStrings, strings);
38
+ // A malformed timestamp leaves the date out rather than failing the page.
39
+ const updatedAt = parseTimestamp(page?.updatedAt);
40
+
41
+ return (
42
+ <div className="max-w-2xl mx-auto px-4 sm:px-6 py-12 space-y-6">
43
+ <h1 className="text-3xl font-black text-gray-900">{page?.title ?? title}</h1>
44
+ {updatedAt && (
45
+ <p className="text-xs text-gray-500">
46
+ {text.updated}: {new Intl.DateTimeFormat(dateLocale, { dateStyle: "long" }).format(updatedAt)}
47
+ </p>
48
+ )}
49
+
50
+ <div className="bg-white border border-gray-200 rounded-2xl p-6 shadow-sm space-y-4 text-sm text-gray-600">
51
+ {page ? <Markdown>{page.bodyMarkdown}</Markdown> : <p>{text.missing}</p>}
52
+ </div>
53
+
54
+ <p className="text-xs text-gray-500">
55
+ <Link href={homeHref} className="hover:text-gray-900 transition-colors">
56
+ ← {text.toFrontPage}
57
+ </Link>
58
+ </p>
59
+ </div>
60
+ );
61
+ }
@@ -0,0 +1 @@
1
+ export { LegalPageView, defaultLegalPageViewStrings, type LegalPageViewProps, type LegalPageViewStrings } from "./LegalPageView";
package/src/seo/index.ts CHANGED
@@ -3,10 +3,12 @@ export { JsonLd } from "./JsonLd";
3
3
  export { ref, serializeGraph, type OrganizationInfo, type SchemaNode, type SchemaRef } from "./schema";
4
4
  export {
5
5
  defineSite,
6
+ LEGAL_PAGE_KEYS,
6
7
  normalizePath,
7
8
  NOINDEX,
8
9
  SHARE_IMAGE_SIZE,
9
10
  type Crumb,
11
+ type LegalEntriesInput,
10
12
  type LocaleEntryOptions,
11
13
  type OrganizationOptions,
12
14
  type PageMetadataInput,
package/src/seo/site.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { Metadata, MetadataRoute } from "next";
2
2
  import { isIndexableEnvironment } from "./environment";
3
3
  import { ref, type OrganizationInfo, type SchemaNode } from "./schema";
4
+ import { parseTimestamp } from "./sitemap";
4
5
 
5
6
  /** The size of the share image. */
6
7
  export const SHARE_IMAGE_SIZE = { width: 1200, height: 630 } as const;
@@ -52,12 +53,22 @@ export interface Crumb {
52
53
  }
53
54
 
54
55
  export interface LocaleEntryOptions<L extends string> {
55
- /** The locales the page exists in. Defaults to all of them. */
56
+ /** The locales the page exists in. Defaults to all of them. A site without locales ignores it. */
56
57
  locales?: readonly L[] | undefined;
57
- /** When the page last changed, per locale. Left out when unknown. */
58
+ /** When the page last changed, per locale. Left out when unknown. A site without locales calls it once, with no locale. */
58
59
  lastModified?: ((locale: L) => Date | undefined) | undefined;
59
60
  }
60
61
 
62
+ /** The legal pages every site has, as `createLegalService` names them. */
63
+ export const LEGAL_PAGE_KEYS = ["terms", "privacy", "cookies"] as const;
64
+
65
+ export interface LegalEntriesInput<K extends string, L extends string> {
66
+ /** The legal pages, each at `/<key>` below the locale. Defaults to `LEGAL_PAGE_KEYS`. */
67
+ keys?: readonly K[] | undefined;
68
+ /** When a page last changed, as the API's timestamp. A site without locales gets no locale. */
69
+ updatedAt: (key: K, locale: L | undefined) => Promise<string | null | undefined>;
70
+ }
71
+
61
72
  export interface RobotsInput {
62
73
  /** Paths below each locale segment that must not be crawled, such as `/admin`. */
63
74
  privatePaths?: readonly string[] | undefined;
@@ -68,8 +79,10 @@ export interface RobotsInput {
68
79
  }
69
80
 
70
81
  export interface OrganizationOptions {
71
- /** The schema.org type, such as `Organization` or `ComputerStore`. Defaults to `Organization`. */
72
- type?: string | undefined;
82
+ /** The schema.org type, such as `ComputerStore`, or several, such as `["Organization", "LocalBusiness"]`. Defaults to `Organization`. */
83
+ type?: string | readonly string[] | undefined;
84
+ /** Pages about the same business elsewhere, such as its entry in Brønnøysundregistrene. */
85
+ sameAs?: readonly string[] | undefined;
73
86
  /** The ISO country code for the address and the area served. Defaults to `NO`. */
74
87
  country?: string | undefined;
75
88
  /** Site-relative paths of the square image and the logo. Default to `/icon.png` and `/logo.png`. */
@@ -166,8 +179,16 @@ export function defineSite<L extends string = string>(config: SiteConfig<L>) {
166
179
  };
167
180
  };
168
181
 
169
- /** One sitemap entry per locale a page is served in, each listing the others as alternates. */
182
+ /**
183
+ * One sitemap entry per locale a page is served in, each listing the others as alternates. A site
184
+ * without locales has one URL per page, so it gets one entry with no alternates.
185
+ */
170
186
  const localeEntries = (path: string, options: LocaleEntryOptions<L> = {}): MetadataRoute.Sitemap => {
187
+ if (locales.length === 0) {
188
+ // There is no locale to pass, and the callback's type cannot say so without making every caller's simpler.
189
+ const lastModified = options.lastModified?.(undefined as never);
190
+ return [{ url: absoluteUrl(path), ...(lastModified ? { lastModified } : {}) }];
191
+ }
171
192
  const served = options.locales ?? locales;
172
193
 
173
194
  const languages: Record<string, string> = Object.fromEntries(served.map(l => [tag(l), absoluteLocaleUrl(l, path)]));
@@ -181,6 +202,27 @@ export function defineSite<L extends string = string>(config: SiteConfig<L>) {
181
202
  });
182
203
  };
183
204
 
205
+ /** Runs `load` for every locale at once and keys the results by locale. A site without locales gets `{}`. */
206
+ const forEachLocale = async <T>(load: (locale: L) => Promise<T>): Promise<Partial<Record<L, T>>> =>
207
+ Object.fromEntries(await Promise.all(locales.map(async locale => [locale, await load(locale)] as const))) as Partial<Record<L, T>>;
208
+
209
+ /**
210
+ * The legal pages' sitemap entries, each dated by when it last changed in that locale. The
211
+ * timestamps are fetched at once. An error from `updatedAt` fails the sitemap, so a stale one is
212
+ * served rather than one without dates.
213
+ */
214
+ const legalEntries = async <K extends string = (typeof LEGAL_PAGE_KEYS)[number]>({
215
+ keys,
216
+ updatedAt,
217
+ }: LegalEntriesInput<K, L>): Promise<MetadataRoute.Sitemap> => {
218
+ const pages = keys ?? (LEGAL_PAGE_KEYS as unknown as readonly K[]);
219
+ const served: (L | undefined)[] = locales.length > 0 ? [...locales] : [undefined];
220
+ const stamps = await Promise.all(
221
+ pages.map(key => Promise.all(served.map(async locale => parseTimestamp(await updatedAt(key, locale))))),
222
+ );
223
+ return pages.flatMap((key, k) => localeEntries(`/${key}`, { lastModified: locale => stamps[k]?.[served.indexOf(locale)] }));
224
+ };
225
+
184
226
  /**
185
227
  * robots.txt. A deployment that is not production disallows everything. Otherwise the private
186
228
  * paths are disallowed under every locale segment, since a bare `/admin` matches nothing there.
@@ -203,16 +245,19 @@ export function defineSite<L extends string = string>(config: SiteConfig<L>) {
203
245
  /** The business, under a stable `@id` that page-scoped nodes point at. */
204
246
  const organizationNode = (company: OrganizationInfo, options: OrganizationOptions = {}): SchemaNode => {
205
247
  const country = options.country ?? "NO";
248
+ const type = typeof options.type === "string" ? options.type || undefined : options.type?.length ? [...options.type] : undefined;
249
+ const phone = company.phone?.trim();
250
+ const email = company.email?.trim();
206
251
  return {
207
- "@type": options.type ?? "Organization",
252
+ "@type": type ?? "Organization",
208
253
  "@id": ids.organization,
209
254
  name: company.name,
210
255
  ...(company.legalName && company.legalName !== company.name ? { legalName: company.legalName } : {}),
211
256
  url: absoluteUrl(),
212
257
  image: absoluteUrl(options.image ?? "/icon.png"),
213
258
  logo: absoluteUrl(options.logo ?? "/logo.png"),
214
- telephone: company.phone,
215
- email: company.email,
259
+ ...(phone ? { telephone: phone } : {}),
260
+ ...(email ? { email } : {}),
216
261
  address: {
217
262
  "@type": "PostalAddress",
218
263
  streetAddress: company.streetAddress || company.address,
@@ -224,6 +269,7 @@ export function defineSite<L extends string = string>(config: SiteConfig<L>) {
224
269
  areaServed: country,
225
270
  ...(company.vatRegistered && company.orgNumber ? { vatID: `NO${company.orgNumber.replace(/\s/g, "")}MVA` } : {}),
226
271
  ...(company.orgNumber ? { taxID: company.orgNumber } : {}),
272
+ ...(options.sameAs?.length ? { sameAs: [...options.sameAs] } : {}),
227
273
  };
228
274
  };
229
275
 
@@ -268,6 +314,8 @@ export function defineSite<L extends string = string>(config: SiteConfig<L>) {
268
314
  pageMetadata,
269
315
  siteMetadata,
270
316
  localeEntries,
317
+ forEachLocale,
318
+ legalEntries,
271
319
  robots,
272
320
  siteId,
273
321
  ids,
package/src/styles.css CHANGED
@@ -88,6 +88,8 @@
88
88
  --color-admin-tab-hover: var(--color-gray-900);
89
89
  --color-admin-tab-active: var(--color-gray-900);
90
90
  --color-admin-tab-indicator: var(--color-primary);
91
+ --color-chart-grid: var(--color-gray-200);
92
+ --color-chart-label: var(--color-gray-400);
91
93
 
92
94
  --radius-button: var(--radius-lg);
93
95
  --color-button-primary-bg: var(--color-primary);
@@ -0,0 +1,113 @@
1
+ // Test doubles for the modules every app's tests mock. Import them inside the vi.mock factory, which
2
+ // vitest hoists above the file's own imports:
3
+ //
4
+ // vi.mock("next-auth/react", async () => (await import("@sjolystinnovation/app-kit/testing")).nextAuthMock());
5
+ //
6
+ // The test then imports the same module instance to set the state and read the spies, and calls
7
+ // resetTestMocks before each test, since the state lives for the whole file.
8
+ import type { ReactNode } from "react";
9
+ import type { Session } from "next-auth";
10
+ import { vi } from "vitest";
11
+
12
+ type SessionStatus = "loading" | "authenticated" | "unauthenticated";
13
+
14
+ /** What `useSession` returns, and the next-auth calls as spies. `update` is the one `useSession` returns. */
15
+ export const auth = {
16
+ state: { data: null, status: "unauthenticated" } as { data: Session | null; status: SessionStatus },
17
+ signIn: vi.fn(),
18
+ signOut: vi.fn(),
19
+ getSession: vi.fn(),
20
+ update: vi.fn(),
21
+ };
22
+
23
+ /** A signed-in session with `roles`. Defaults to an admin. */
24
+ export function makeSession({ roles = ["Admin"], ...overrides }: Partial<Session> & { roles?: string[] } = {}): Session {
25
+ return {
26
+ user: { id: "1", name: "admin", email: "a@b.no", roles },
27
+ accessToken: "token",
28
+ expires: "",
29
+ ...overrides,
30
+ };
31
+ }
32
+
33
+ /** Signs in with `session`, signs out with null, or leaves the session loading. */
34
+ export function setSession(session: Session | null | "loading"): void {
35
+ auth.state =
36
+ session === "loading"
37
+ ? { data: null, status: "loading" }
38
+ : session === null
39
+ ? { data: null, status: "unauthenticated" }
40
+ : { data: session, status: "authenticated" };
41
+ }
42
+
43
+ /** The `next-auth/react` module, reading `auth`. */
44
+ export function nextAuthMock() {
45
+ return {
46
+ useSession: () => ({ ...auth.state, update: (...args: unknown[]) => auth.update(...args) }),
47
+ signIn: (...args: unknown[]) => auth.signIn(...args),
48
+ signOut: (...args: unknown[]) => auth.signOut(...args),
49
+ getSession: (...args: unknown[]) => auth.getSession(...args),
50
+ SessionProvider: ({ children }: { children?: ReactNode }) => children,
51
+ };
52
+ }
53
+
54
+ /** The current URL and the router's calls as spies. */
55
+ export const navigation = {
56
+ pathname: "/",
57
+ searchParams: new URLSearchParams(),
58
+ router: { push: vi.fn(), replace: vi.fn(), refresh: vi.fn(), back: vi.fn(), forward: vi.fn(), prefetch: vi.fn() },
59
+ };
60
+
61
+ /** The `next/navigation` hooks, reading `navigation`. */
62
+ export function nextNavigationMock() {
63
+ return {
64
+ useRouter: () => navigation.router,
65
+ usePathname: () => navigation.pathname,
66
+ useSearchParams: () => navigation.searchParams,
67
+ };
68
+ }
69
+
70
+ /** The toast calls as spies. */
71
+ export const toastSpies = {
72
+ success: vi.fn(),
73
+ error: vi.fn(),
74
+ info: vi.fn(),
75
+ warning: vi.fn(),
76
+ message: vi.fn(),
77
+ loading: vi.fn(),
78
+ promise: vi.fn(),
79
+ dismiss: vi.fn(),
80
+ };
81
+
82
+ /** The `sonner` module, or `@sjolystinnovation/app-kit/toast`. A bare `toast(text)` goes to `toastSpies.message`. */
83
+ export function toastMock() {
84
+ const toast = Object.assign((...args: unknown[]) => toastSpies.message(...args), {
85
+ success: (...args: unknown[]) => toastSpies.success(...args),
86
+ error: (...args: unknown[]) => toastSpies.error(...args),
87
+ info: (...args: unknown[]) => toastSpies.info(...args),
88
+ warning: (...args: unknown[]) => toastSpies.warning(...args),
89
+ message: (...args: unknown[]) => toastSpies.message(...args),
90
+ loading: (...args: unknown[]) => toastSpies.loading(...args),
91
+ promise: (...args: unknown[]) => toastSpies.promise(...args),
92
+ dismiss: (...args: unknown[]) => toastSpies.dismiss(...args),
93
+ });
94
+ return { toast, Toaster: () => null };
95
+ }
96
+
97
+ /** The HTTP calls of an app's axios instance as spies. */
98
+ export const axiosSpies = { get: vi.fn(), post: vi.fn(), put: vi.fn(), patch: vi.fn(), delete: vi.fn() };
99
+
100
+ /** A module whose default export is an axios instance, such as the app's `services/axiosInstance`. */
101
+ export function axiosInstanceMock() {
102
+ return { default: axiosSpies };
103
+ }
104
+
105
+ /** Signs out, goes back to `/`, and clears every spy's calls and answers. */
106
+ export function resetTestMocks(): void {
107
+ setSession(null);
108
+ navigation.pathname = "/";
109
+ navigation.searchParams = new URLSearchParams();
110
+ for (const spy of [auth.signIn, auth.signOut, auth.getSession, auth.update, ...Object.values(navigation.router), ...Object.values(toastSpies), ...Object.values(axiosSpies)]) {
111
+ spy.mockReset();
112
+ }
113
+ }