@sjolystinnovation/app-kit 0.19.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.
@@ -0,0 +1,73 @@
1
+ // Plain values, with no "use client", so a server component can import them.
2
+
3
+ /**
4
+ * The users panel's text. Pass the app's own language. The defaults are English. `{name}` and `{role}`
5
+ * are filled in where a text names them.
6
+ */
7
+ export interface AdminUsersPanelStrings {
8
+ users: string;
9
+ showDeleted: string;
10
+ inviteUser: string;
11
+ firstName: string;
12
+ lastName: string;
13
+ email: string;
14
+ role: string;
15
+ sendInvite: string;
16
+ sending: string;
17
+ inviteHint: string;
18
+ loading: string;
19
+ /** The tag after a deleted user's name. */
20
+ deleted: string;
21
+ delete: string;
22
+ /** Before the role name on the button that takes a role away. */
23
+ remove: string;
24
+ noRoles: string;
25
+ addRole: string;
26
+ add: string;
27
+ cancel: string;
28
+ usersLoadFailed: string;
29
+ rolesLoadFailed: string;
30
+ roleAssigned: string;
31
+ roleAssignFailed: string;
32
+ roleRemoved: string;
33
+ roleRemoveFailed: string;
34
+ confirmDelete: string;
35
+ userDeleted: string;
36
+ userDeleteFailed: string;
37
+ inviteIncomplete: string;
38
+ inviteSent: string;
39
+ inviteFailed: string;
40
+ }
41
+
42
+ export const defaultAdminUsersPanelStrings: AdminUsersPanelStrings = {
43
+ users: "Users",
44
+ showDeleted: "Show deleted",
45
+ inviteUser: "Invite user",
46
+ firstName: "First name",
47
+ lastName: "Last name",
48
+ email: "Email",
49
+ role: "Role",
50
+ sendInvite: "Send invitation",
51
+ sending: "Sending…",
52
+ inviteHint: "The user gets an email with a code and sets a password on the forgotten password page.",
53
+ loading: "Loading…",
54
+ deleted: "deleted",
55
+ delete: "Delete",
56
+ remove: "Remove",
57
+ noRoles: "No roles",
58
+ addRole: "Add role",
59
+ add: "Add",
60
+ cancel: "Cancel",
61
+ usersLoadFailed: "Failed to load users",
62
+ rolesLoadFailed: "Failed to load roles",
63
+ roleAssigned: "Added {role} to {name}",
64
+ roleAssignFailed: "Failed to assign role",
65
+ roleRemoved: "Removed {role} from {name}",
66
+ roleRemoveFailed: "Failed to remove role",
67
+ confirmDelete: "Delete {name}? Their name, email and password are removed, and they cannot sign in again.",
68
+ userDeleted: "User deleted",
69
+ userDeleteFailed: "Failed to delete user",
70
+ inviteIncomplete: "Fill in the name and email",
71
+ inviteSent: "Invitation sent. The user gets a code to set a password.",
72
+ inviteFailed: "Failed to send the invitation",
73
+ };
@@ -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
+ };
@@ -0,0 +1,20 @@
1
+ export { CompanySettingsForm } from "./CompanySettingsForm";
2
+ export type { CompanySettingsFormProps } from "./CompanySettingsForm";
3
+ export { defaultCompanySettingsFormStrings } from "./companySettingsFormStrings";
4
+ export type { CompanySettingsFormStrings } from "./companySettingsFormStrings";
5
+ export { AdminShell } from "./AdminShell";
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";
11
+ export { AdminUsersPanel } from "./AdminUsersPanel";
12
+ export type { AdminUsersPanelProps } from "./AdminUsersPanel";
13
+ export { defaultAdminUsersPanelStrings } from "./adminUsersPanelStrings";
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";
@@ -0,0 +1,24 @@
1
+ // The session fields this package's auth options put on next-auth's session and token. An app loads
2
+ // them with `import "@sjolystinnovation/app-kit/next-auth";` in one of its own .d.ts files.
3
+ import "next-auth";
4
+ import "next-auth/jwt";
5
+
6
+ declare module "next-auth" {
7
+ interface Session {
8
+ user: { id: string; name: string; email: string; roles: string[] };
9
+ accessToken: string;
10
+ error?: string | undefined;
11
+ absoluteExpiresAt?: number;
12
+ }
13
+ }
14
+
15
+ declare module "next-auth/jwt" {
16
+ interface JWT {
17
+ accessToken?: string;
18
+ refreshToken?: string;
19
+ refreshAt?: number;
20
+ loginAt?: number;
21
+ user?: { id: string; name: string; email: string; roles: string[] };
22
+ error?: string | undefined;
23
+ }
24
+ }
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
@@ -82,6 +82,15 @@
82
82
  --color-auth-link-hover: var(--color-gray-900);
83
83
  --font-weight-auth-link: var(--font-weight-semibold);
84
84
 
85
+ --color-admin-title: var(--color-gray-900);
86
+ --color-admin-border: var(--color-gray-200);
87
+ --color-admin-tab: var(--color-gray-500);
88
+ --color-admin-tab-hover: var(--color-gray-900);
89
+ --color-admin-tab-active: var(--color-gray-900);
90
+ --color-admin-tab-indicator: var(--color-primary);
91
+ --color-chart-grid: var(--color-gray-200);
92
+ --color-chart-label: var(--color-gray-400);
93
+
85
94
  --radius-button: var(--radius-lg);
86
95
  --color-button-primary-bg: var(--color-primary);
87
96
  --color-button-primary-text: var(--color-primary-foreground);
@@ -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
+ }