@sjolystinnovation/app-kit 0.22.0 → 1.0.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.
Files changed (52) hide show
  1. package/README.md +1281 -773
  2. package/package.json +21 -4
  3. package/src/admin/AdminUsersPanel.tsx +85 -35
  4. package/src/admin/CompanySettingsForm.tsx +33 -13
  5. package/src/admin/EmailStatsPanel.tsx +76 -0
  6. package/src/admin/LegalPagesEditor.tsx +41 -14
  7. package/src/admin/adminUsersPanelStrings.ts +67 -0
  8. package/src/admin/companySettingsFormStrings.ts +26 -0
  9. package/src/admin/emailStatsPanelStrings.ts +29 -0
  10. package/src/admin/index.ts +14 -5
  11. package/src/admin/legalPagesEditorStrings.ts +25 -0
  12. package/src/api/apiErrorCode.ts +28 -0
  13. package/src/api/apiFieldErrors.ts +82 -11
  14. package/src/api/failureBody.ts +10 -0
  15. package/src/api/fieldErrorText.ts +32 -0
  16. package/src/api/index.ts +5 -1
  17. package/src/api/legalPage.ts +10 -0
  18. package/src/api/request.ts +13 -3
  19. package/src/api/services.ts +42 -12
  20. package/src/auth/createAuthOptions.ts +9 -3
  21. package/src/branding/BrandingManager.tsx +31 -36
  22. package/src/branding/brandingManagerStrings.ts +49 -0
  23. package/src/branding/index.ts +3 -3
  24. package/src/legal/LegalPageView.tsx +2 -17
  25. package/src/legal/index.ts +4 -1
  26. package/src/legal/keys.ts +10 -0
  27. package/src/legal/legalPageViewStrings.ts +17 -0
  28. package/src/locales/nb.ts +330 -0
  29. package/src/sections/SectionRenderer.tsx +275 -0
  30. package/src/sections/SectionsEditor.tsx +151 -0
  31. package/src/sections/SmartLink.tsx +42 -0
  32. package/src/sections/editors.tsx +311 -0
  33. package/src/sections/factory.ts +26 -0
  34. package/src/sections/index.ts +69 -0
  35. package/src/sections/sectionsEditorStrings.ts +108 -0
  36. package/src/sections/types.ts +107 -0
  37. package/src/seo/index.ts +1 -0
  38. package/src/seo/site.ts +5 -5
  39. package/src/session/expiry.ts +3 -1
  40. package/src/session/index.ts +0 -1
  41. package/src/session/react/CredentialsForm.tsx +8 -2
  42. package/src/session/react/credentialsFormStrings.ts +4 -1
  43. package/src/session/react/index.ts +1 -1
  44. package/src/styles.css +2 -0
  45. package/src/testing/index.ts +1 -9
  46. package/src/ui/ErrorState.tsx +1 -15
  47. package/src/ui/NotFoundState.tsx +1 -13
  48. package/src/ui/ResetPasswordFlow.tsx +55 -12
  49. package/src/ui/errorStateStrings.ts +16 -0
  50. package/src/ui/index.ts +10 -6
  51. package/src/ui/notFoundStateStrings.ts +14 -0
  52. package/src/ui/resetPasswordFlowStrings.ts +44 -0
@@ -1,45 +1,17 @@
1
1
  "use client";
2
2
 
3
3
  import { ArrowUpTrayIcon, TrashIcon } from "@heroicons/react/24/outline";
4
- import { useEffect, useRef, useState } from "react";
4
+ import { useEffect, useId, useRef, useState } from "react";
5
+ import { fieldErrorTexts } from "../api/fieldErrorText";
5
6
  import { toast } from "../toast";
7
+ import { FieldError } from "../ui/field";
6
8
  import { withDefaults, type TextOverrides } from "../ui/withDefaults";
7
9
  import { useBranding } from "./BrandingProvider";
10
+ import { defaultBrandingManagerStrings, type BrandingManagerStrings, type BrandingValidationStrings } from "./brandingManagerStrings";
8
11
  import { toDataUrl } from "./types";
9
12
 
10
- /** The branding form's text. Pass the app's own language. The defaults are English. */
11
- export interface BrandingManagerStrings {
12
- title: string;
13
- logo: string;
14
- logoHint: string;
15
- icon: string;
16
- iconHint: string;
17
- none: string;
18
- choose: string;
19
- remove: string;
20
- save: string;
21
- saving: string;
22
- saved: string;
23
- saveFailed: string;
24
- }
25
-
26
13
  const IMAGE_TYPES = "image/png,image/jpeg,image/webp,image/gif,image/x-icon,.ico";
27
14
 
28
- export const defaultBrandingManagerStrings: BrandingManagerStrings = {
29
- title: "Branding",
30
- logo: "Logo",
31
- logoHint: "Shown in the navbar and footer. PNG, JPEG, WebP, or GIF, max 2 MB.",
32
- icon: "Icon (favicon)",
33
- iconHint: "Browser tab icon. Square PNG or ICO, max 2 MB.",
34
- none: "none",
35
- choose: "Choose",
36
- remove: "Remove",
37
- save: "Save branding",
38
- saving: "Saving…",
39
- saved: "Branding saved",
40
- saveFailed: "Failed to save branding",
41
- };
42
-
43
15
  /** Props for `BrandingManager`. */
44
16
  export interface BrandingManagerProps {
45
17
  /**
@@ -47,7 +19,12 @@ export interface BrandingManagerProps {
47
19
  * to `true`. Throw from it to show the error's message, such as one from `formatApiError`.
48
20
  */
49
21
  save: (form: FormData) => Promise<unknown>;
50
- strings?: TextOverrides<BrandingManagerStrings> | undefined;
22
+ /** The form's text. `validation` may name only some codes, the rest keep their defaults. */
23
+ strings?:
24
+ | (TextOverrides<Omit<BrandingManagerStrings, "validation">> & {
25
+ validation?: TextOverrides<BrandingValidationStrings> | undefined;
26
+ })
27
+ | undefined;
51
28
  }
52
29
 
53
30
  /** A preview is an object URL for the chosen file or a data URL of a raster image. Anything else shows none. */
@@ -61,11 +38,15 @@ interface SlotProps {
61
38
  file: File | null;
62
39
  onPick: (file: File | null) => void;
63
40
  onClear: () => void;
41
+ /** Why the API refused this slot's file in the last save, if it did. */
42
+ error: string | undefined;
64
43
  text: BrandingManagerStrings;
65
44
  }
66
45
 
67
- function Slot({ label, hint, currentUrl, file, onPick, onClear, text }: SlotProps) {
46
+ function Slot({ label, hint, currentUrl, file, onPick, onClear, error, text }: SlotProps) {
68
47
  const inputRef = useRef<HTMLInputElement>(null);
48
+ const errorId = `${useId()}-error`;
49
+ const describedBy = error ? errorId : undefined;
69
50
  const [objectUrl, setObjectUrl] = useState<string | null>(null);
70
51
 
71
52
  // An object URL is an outside resource with a lifetime, which is what an effect is for. Creating
@@ -100,12 +81,15 @@ function Slot({ label, hint, currentUrl, file, onPick, onClear, text }: SlotProp
100
81
  // The types the API accepts, so the picker does not offer an SVG it would refuse.
101
82
  accept={IMAGE_TYPES}
102
83
  aria-label={label}
84
+ aria-invalid={error ? true : undefined}
85
+ aria-describedby={describedBy}
103
86
  className="hidden"
104
87
  onChange={e => onPick(e.target.files?.[0] ?? null)}
105
88
  />
106
89
  <button
107
90
  type="button"
108
91
  onClick={() => inputRef.current?.click()}
92
+ aria-describedby={describedBy}
109
93
  className="inline-flex items-center gap-1.5 rounded-lg bg-gray-100 text-gray-700 font-medium px-3 py-2 text-sm hover:bg-gray-200"
110
94
  >
111
95
  <ArrowUpTrayIcon className="h-4 w-4" /> {text.choose}
@@ -117,19 +101,24 @@ function Slot({ label, hint, currentUrl, file, onPick, onClear, text }: SlotProp
117
101
  )}
118
102
  </div>
119
103
  <p className="mt-1 text-xs text-gray-500">{hint}</p>
104
+ <FieldError id={errorId} error={error} />
120
105
  </div>
121
106
  );
122
107
  }
123
108
 
124
109
  /** The admin form for uploading or removing the logo and the favicon. Needs a `BrandingProvider` above it. */
125
110
  export function BrandingManager({ save, strings }: BrandingManagerProps) {
126
- const text = withDefaults(defaultBrandingManagerStrings, strings);
111
+ const { validation: validationOverrides, ...textOverrides } = strings ?? {};
112
+ const text = withDefaults(defaultBrandingManagerStrings, textOverrides);
113
+ const validation = withDefaults(defaultBrandingManagerStrings.validation, validationOverrides);
127
114
  const { branding, refresh } = useBranding();
128
115
  const [logo, setLogo] = useState<File | null>(null);
129
116
  const [icon, setIcon] = useState<File | null>(null);
130
117
  const [removeLogo, setRemoveLogo] = useState(false);
131
118
  const [removeIcon, setRemoveIcon] = useState(false);
132
119
  const [saving, setSaving] = useState(false);
120
+ // The texts for the files the API refused in the last save, keyed by `logo` and `icon`.
121
+ const [refusals, setRefusals] = useState<Record<string, string[]>>({});
133
122
 
134
123
  const currentLogo = removeLogo ? null : toDataUrl(branding.logoData, branding.logoContentType);
135
124
  const currentIcon = removeIcon ? null : toDataUrl(branding.iconData, branding.iconContentType);
@@ -147,10 +136,14 @@ export function BrandingManager({ save, strings }: BrandingManagerProps) {
147
136
  setIcon(null);
148
137
  setRemoveLogo(false);
149
138
  setRemoveIcon(false);
139
+ setRefusals({});
150
140
  refresh();
151
141
  toast.success(text.saved);
152
142
  } catch (err) {
153
- toast.error(err instanceof Error && err.message ? err.message : text.saveFailed);
143
+ const texts = fieldErrorTexts(err, validation);
144
+ setRefusals(texts);
145
+ const first = Object.values(texts)[0]?.[0];
146
+ toast.error(first ?? (err instanceof Error && err.message ? err.message : text.saveFailed));
154
147
  } finally {
155
148
  setSaving(false);
156
149
  }
@@ -166,6 +159,7 @@ export function BrandingManager({ save, strings }: BrandingManagerProps) {
166
159
  file={logo}
167
160
  onPick={f => { setLogo(f); setRemoveLogo(false); }}
168
161
  onClear={() => { setLogo(null); setRemoveLogo(true); }}
162
+ error={refusals.logo?.join(" ")}
169
163
  text={text}
170
164
  />
171
165
  <Slot
@@ -175,6 +169,7 @@ export function BrandingManager({ save, strings }: BrandingManagerProps) {
175
169
  file={icon}
176
170
  onPick={f => { setIcon(f); setRemoveIcon(false); }}
177
171
  onClear={() => { setIcon(null); setRemoveIcon(true); }}
172
+ error={refusals.icon?.join(" ")}
178
173
  text={text}
179
174
  />
180
175
  <button
@@ -0,0 +1,49 @@
1
+ // Plain values, with no "use client", so a server component can import them.
2
+
3
+ /**
4
+ * The branding form's text for each refusal code of SjolystInnovation.AppKit.SiteSettings that a
5
+ * chosen file can get. The form shows the API's own message for a code that is not here.
6
+ */
7
+ export interface BrandingValidationStrings {
8
+ /** The file is not a PNG, JPEG, WebP, GIF, or ICO image. */
9
+ invalidImage: string;
10
+ /** The file is larger than 2 MB. */
11
+ imageTooLarge: string;
12
+ }
13
+
14
+ /** The branding form's text. Pass the app's own language. The defaults are English. */
15
+ export interface BrandingManagerStrings {
16
+ title: string;
17
+ logo: string;
18
+ logoHint: string;
19
+ icon: string;
20
+ iconHint: string;
21
+ none: string;
22
+ choose: string;
23
+ remove: string;
24
+ save: string;
25
+ saving: string;
26
+ saved: string;
27
+ saveFailed: string;
28
+ /** The refusal texts, by code. Any code left out keeps its default. */
29
+ validation: BrandingValidationStrings;
30
+ }
31
+
32
+ export const defaultBrandingManagerStrings: BrandingManagerStrings = {
33
+ title: "Branding",
34
+ logo: "Logo",
35
+ logoHint: "Shown in the navbar and footer. PNG, JPEG, WebP, or GIF, max 2 MB.",
36
+ icon: "Icon (favicon)",
37
+ iconHint: "Browser tab icon. Square PNG or ICO, max 2 MB.",
38
+ none: "none",
39
+ choose: "Choose",
40
+ remove: "Remove",
41
+ save: "Save branding",
42
+ saving: "Saving…",
43
+ saved: "Branding saved",
44
+ saveFailed: "Failed to save branding",
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.",
48
+ },
49
+ };
@@ -1,9 +1,9 @@
1
+ export { BrandingManager, type BrandingManagerProps } from "./BrandingManager";
1
2
  export {
2
- BrandingManager,
3
3
  defaultBrandingManagerStrings,
4
- type BrandingManagerProps,
5
4
  type BrandingManagerStrings,
6
- } from "./BrandingManager";
5
+ type BrandingValidationStrings,
6
+ } from "./brandingManagerStrings";
7
7
  export {
8
8
  BrandingProvider,
9
9
  useBranding,
@@ -1,24 +1,9 @@
1
1
  import Link from "next/link";
2
- import type { LegalPage } from "../api/services";
2
+ import type { LegalPage } from "../api/legalPage";
3
3
  import { Markdown } from "../markdown";
4
4
  import { parseTimestamp } from "../seo/sitemap";
5
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
- /** The text before the date the page last changed. */
10
- updated: string;
11
- /** The text shown in place of the content when nobody has written the page yet. */
12
- missing: string;
13
- /** The text of 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
- };
6
+ import { defaultLegalPageViewStrings, type LegalPageViewStrings } from "./legalPageViewStrings";
22
7
 
23
8
  /** Props for `LegalPageView`. */
24
9
  export interface LegalPageViewProps {
@@ -1 +1,4 @@
1
- export { LegalPageView, defaultLegalPageViewStrings, type LegalPageViewProps, type LegalPageViewStrings } from "./LegalPageView";
1
+ export type { LegalPage } from "../api/legalPage";
2
+ export { LEGAL_PAGE_KEYS, type DefaultLegalPageKey } from "./keys";
3
+ export { LegalPageView, type LegalPageViewProps } from "./LegalPageView";
4
+ export { defaultLegalPageViewStrings, type LegalPageViewStrings } from "./legalPageViewStrings";
@@ -0,0 +1,10 @@
1
+ // Plain values with no imports, so the api, seo, admin, and legal entry points can share them.
2
+
3
+ /**
4
+ * The legal pages a site has when it picks none, the same default as the `Keys` option of
5
+ * SjolystInnovation.AppKit.SiteSettings's `MapAppKitLegalPages`.
6
+ */
7
+ export const LEGAL_PAGE_KEYS = ["terms", "privacy", "cookies"] as const;
8
+
9
+ /** A key of `LEGAL_PAGE_KEYS`. The legal page types and calls default to it. */
10
+ export type DefaultLegalPageKey = (typeof LEGAL_PAGE_KEYS)[number];
@@ -0,0 +1,17 @@
1
+ // Plain values, with no "use client", so a server component can import them.
2
+
3
+ /** The legal page's text around the content. Pass the app's own language. The defaults are English. */
4
+ export interface LegalPageViewStrings {
5
+ /** The text before the date the page last changed. */
6
+ updated: string;
7
+ /** The text shown in place of the content when nobody has written the page yet. */
8
+ missing: string;
9
+ /** The text of the link back to the front page. */
10
+ toFrontPage: string;
11
+ }
12
+
13
+ export const defaultLegalPageViewStrings: LegalPageViewStrings = {
14
+ updated: "Last updated",
15
+ missing: "This content has not been written yet.",
16
+ toFrontPage: "Back to the front page",
17
+ };
@@ -0,0 +1,330 @@
1
+ // Norwegian (bokmål) text for every app-kit component that takes `strings`. Plain values with type
2
+ // only imports, so this entry point needs no peer dependency and a server component can import it.
3
+ //
4
+ // The wording follows Norwegian public services. A field error names the field or the problem first,
5
+ // says what is wrong and how to fix it, as Skatteetaten does, and uses Altinn's phrasing such as
6
+ // "Du må fylle ut" and "Bruk {max} eller færre tegn".
7
+
8
+ import type { AdminUsersPanelStrings } from "../admin/adminUsersPanelStrings";
9
+ import type { CompanySettingsFormStrings } from "../admin/companySettingsFormStrings";
10
+ import type { EmailStatsPanelStrings } from "../admin/emailStatsPanelStrings";
11
+ import type { LegalPagesEditorStrings } from "../admin/legalPagesEditorStrings";
12
+ import type { StatHistoryChartStrings } from "../admin/statHistoryChartStrings";
13
+ import type { BrandingManagerStrings } from "../branding/brandingManagerStrings";
14
+ import type { DefaultLegalPageKey } from "../legal/keys";
15
+ import type { LegalPageViewStrings } from "../legal/legalPageViewStrings";
16
+ import type { SectionsEditorStrings } from "../sections/sectionsEditorStrings";
17
+ import type { SharedSectionType } from "../sections/types";
18
+ import type { CredentialsFormStrings } from "../session/react/credentialsFormStrings";
19
+ import type { LoginPageStrings } from "../session/react/loginPageStrings";
20
+ import type { SessionExpiryGuardStrings } from "../session/react/sessionExpiryGuardStrings";
21
+ import type { ErrorStateStrings } from "../ui/errorStateStrings";
22
+ import type { NotFoundStateStrings } from "../ui/notFoundStateStrings";
23
+ import type { ResetPasswordFlowStrings } from "../ui/resetPasswordFlowStrings";
24
+ import { PASSWORD_MIN_LENGTH, type PasswordStrings } from "../validation/passwordRules";
25
+
26
+ const required = "Du må fylle ut dette feltet.";
27
+ const tooLong = "Bruk {max} eller færre tegn.";
28
+ const passwordRules = `Bruk minst ${PASSWORD_MIN_LENGTH} tegn, med minst én liten og én stor bokstav fra A til Z og ett siffer fra 0 til 9.`;
29
+
30
+ /** For `passwordSchema` and the `passwordStrings` of `ResetPasswordFlow`. */
31
+ export const nbPasswordStrings: PasswordStrings = {
32
+ tooShort: `Passordet er for kort. Bruk minst ${PASSWORD_MIN_LENGTH} tegn.`,
33
+ lowercase: "Passordet mangler en liten bokstav. Ta med en bokstav fra a til z.",
34
+ uppercase: "Passordet mangler en stor bokstav. Ta med en bokstav fra A til Z.",
35
+ digit: "Passordet mangler et siffer. Ta med et siffer fra 0 til 9.",
36
+ };
37
+
38
+ /** For `CredentialsForm`, and the `formStrings` of `LoginPage` and `SessionExpiryGuard`. */
39
+ export const nbCredentialsFormStrings: CredentialsFormStrings = {
40
+ email: "E-post",
41
+ password: "Passord",
42
+ signIn: "Logg inn",
43
+ signingIn: "Logger inn…",
44
+ invalidCredentials: "Feil e-post eller passord.",
45
+ lockedOut: "Du har prøvd å logge inn for mange ganger, så innloggingen er låst en stund. Prøv igjen senere.",
46
+ signInUnavailable: "Innlogging er ikke tilgjengelig akkurat nå. Prøv igjen om litt.",
47
+ somethingWentWrong: "Noe gikk galt. Prøv igjen.",
48
+ showPassword: "Vis passord",
49
+ hidePassword: "Skjul passord",
50
+ };
51
+
52
+ /** For `LoginPage`. */
53
+ export const nbLoginPageStrings: LoginPageStrings = {
54
+ title: "Logg inn",
55
+ forgotPassword: "Glemt passord?",
56
+ registerPrompt: "Har du ikke konto?",
57
+ register: "Opprett konto",
58
+ };
59
+
60
+ /** For `SessionExpiryGuard`. */
61
+ export const nbSessionExpiryGuardStrings: SessionExpiryGuardStrings = {
62
+ expiredTitle: "Økten er utløpt",
63
+ expiredBody: "Økten din er utløpt. Logg inn på nytt for å lagre.",
64
+ expiringSoon: "Økten utløper om {minutes} min. Logg inn på nytt for å fortsette uten avbrudd.",
65
+ reSignIn: "Logg inn på nytt",
66
+ notRestored: "Vi fikk ikke fornyet økten. Prøv igjen.",
67
+ wrongUser: "Denne siden tilhører en annen bruker. Logg inn med samme konto.",
68
+ close: "Lukk",
69
+ };
70
+
71
+ /** For `ResetPasswordFlow`. Pass `nbPasswordStrings` as its `passwordStrings` too. */
72
+ export const nbResetPasswordFlowStrings: ResetPasswordFlowStrings = {
73
+ title: "Sett nytt passord",
74
+ signInPrompt: "Husker du passordet?",
75
+ signIn: "Logg inn",
76
+ backToSignIn: "Logg inn →",
77
+ intro: "Skriv inn e-postadressen din, så sender vi en kode.",
78
+ codeSent: "Sjekk e-posten din. Vi har sendt deg en kode.",
79
+ email: "E-post",
80
+ code: "Kode fra e-post",
81
+ newPassword: "Nytt passord",
82
+ passwordHint: passwordRules,
83
+ requestCode: "Send kode",
84
+ requestingCode: "Sender…",
85
+ setPassword: "Lagre passord",
86
+ settingPassword: "Lagrer…",
87
+ done: "Passordet er endret. Du kan logge inn nå.",
88
+ passwordInvalid: `Passordet oppfyller ikke kravene. ${passwordRules}`,
89
+ somethingWentWrong: "Noe gikk galt. Prøv igjen.",
90
+ validation: {
91
+ required,
92
+ invalidEmail: "E-postadressen er ugyldig. Skriv den slik: navn@domene.no.",
93
+ passwordTooShort: "Passordet er for kort. Bruk minst {max} tegn.",
94
+ passwordRequiresDigit: "Passordet mangler et siffer. Ta med et siffer fra 0 til 9.",
95
+ passwordRequiresLower: "Passordet mangler en liten bokstav. Ta med en bokstav fra a til z.",
96
+ passwordRequiresUpper: "Passordet mangler en stor bokstav. Ta med en bokstav fra A til Z.",
97
+ passwordRequiresNonAlphanumeric: "Passordet mangler et spesialtegn. Ta med et tegn som ikke er en bokstav eller et siffer, for eksempel ! eller #.",
98
+ },
99
+ errors: {
100
+ invalidResetCode: "Koden er feil eller har gått ut. Sjekk koden i e-posten, eller be om en ny kode.",
101
+ tooManyResetAttempts: "Du har skrevet feil kode for mange ganger. Be om en ny kode.",
102
+ },
103
+ };
104
+
105
+ /** For `ErrorState`. */
106
+ export const nbErrorStateStrings: ErrorStateStrings = {
107
+ title: "Midlertidig utilgjengelig",
108
+ body: "Vi får ikke kontakt med tjenesten akkurat nå. Prøv igjen om litt.",
109
+ tryAgain: "Prøv igjen",
110
+ home: "Til forsiden",
111
+ };
112
+
113
+ /** For `NotFoundState`. */
114
+ export const nbNotFoundStateStrings: NotFoundStateStrings = {
115
+ code: "404",
116
+ body: "Siden finnes ikke, eller den er flyttet.",
117
+ home: "Til forsiden",
118
+ };
119
+
120
+ /** For `CompanySettingsForm`. */
121
+ export const nbCompanySettingsFormStrings: CompanySettingsFormStrings = {
122
+ contactRecipient: "Mottaker for henvendelser",
123
+ contactRecipientHint: "Meldinger fra kontaktskjemaet sendes til denne adressen.",
124
+ companyName: "Firmanavn",
125
+ legalName: "Registrert navn",
126
+ legalNameHint: "Navnet i Enhetsregisteret. La feltet stå tomt for å bruke firmanavnet.",
127
+ orgNumber: "Organisasjonsnummer",
128
+ vatRegistered: "Registrert i Merverdiavgiftsregisteret",
129
+ vatHint: "Krever organisasjonsnummer. Når dette er slått på, vises nummeret med «MVA».",
130
+ streetAddress: "Gateadresse",
131
+ postalCode: "Postnummer",
132
+ addressLocality: "Poststed",
133
+ addressRegion: "Fylke",
134
+ addressHint: "Postnummer og poststed holdes atskilt slik at søkemotorer kan lese adressen. Fylke er valgfritt.",
135
+ publicEmail: "E-post som vises på nettsiden",
136
+ publicPhone: "Telefon som vises på nettsiden",
137
+ loading: "Laster…",
138
+ save: "Lagre",
139
+ saving: "Lagrer…",
140
+ loadFailed: "Kunne ikke laste innstillingene",
141
+ saved: "Innstillingene er lagret",
142
+ saveFailed: "Kunne ikke lagre innstillingene",
143
+ validation: {
144
+ required,
145
+ tooLong,
146
+ invalidEmail: "E-postadressen er ugyldig. Skriv den slik: navn@domene.no.",
147
+ orgNumberRequiredForVat: "Du må fylle ut organisasjonsnummer når virksomheten er registrert i Merverdiavgiftsregisteret.",
148
+ invalidOrgNumber: "Organisasjonsnummeret er ugyldig. Et organisasjonsnummer består av 9 siffer.",
149
+ invalidPostalCode: "Postnummeret er ugyldig. Et postnummer består av 4 siffer.",
150
+ placeRequired: "Du må fylle ut poststed når du har fylt ut postnummer.",
151
+ },
152
+ };
153
+
154
+ /** For `AdminUsersPanel`. */
155
+ export const nbAdminUsersPanelStrings: AdminUsersPanelStrings = {
156
+ users: "Brukere",
157
+ showDeleted: "Vis slettede",
158
+ inviteUser: "Inviter bruker",
159
+ firstName: "Fornavn",
160
+ lastName: "Etternavn",
161
+ email: "E-post",
162
+ role: "Rolle",
163
+ sendInvite: "Send invitasjon",
164
+ sending: "Sender…",
165
+ inviteHint: "Brukeren får en e-post med en kode og setter passordet selv på siden for glemt passord.",
166
+ loading: "Laster…",
167
+ deleted: "slettet",
168
+ delete: "Slett",
169
+ remove: "Fjern",
170
+ noRoles: "Ingen roller",
171
+ addRole: "Legg til rolle",
172
+ add: "Legg til",
173
+ cancel: "Avbryt",
174
+ usersLoadFailed: "Kunne ikke laste brukerne",
175
+ rolesLoadFailed: "Kunne ikke laste rollene",
176
+ roleAssigned: "{name} har fått rollen {role}",
177
+ roleAssignFailed: "Kunne ikke tildele rollen",
178
+ roleRemoved: "{role} er fjernet fra {name}",
179
+ roleRemoveFailed: "Kunne ikke fjerne rollen",
180
+ confirmDelete: "Slette {name}? Navn, e-post og passord blir fjernet, og brukeren kan ikke logge inn igjen.",
181
+ userDeleted: "Brukeren er slettet",
182
+ userDeleteFailed: "Kunne ikke slette brukeren",
183
+ inviteIncomplete: "Du må fylle ut navn og e-post.",
184
+ inviteSent: "Invitasjonen er sendt. Brukeren får en kode for å sette passord.",
185
+ inviteFailed: "Kunne ikke sende invitasjonen",
186
+ validation: {
187
+ required,
188
+ tooLong,
189
+ invalidEmail: "E-postadressen er ugyldig. Skriv den slik: navn@domene.no.",
190
+ unknownRole: "Rollen finnes ikke. Velg en rolle fra listen.",
191
+ emailTaken: "E-postadressen har allerede en konto. Bruk en annen e-postadresse.",
192
+ },
193
+ errors: {
194
+ unknownRole: "Rollen finnes ikke. Velg en rolle fra listen.",
195
+ ownAdminRole: "Du kan ikke fjerne din egen administratorrolle. Be en annen administrator om å gjøre det.",
196
+ lastAdmin: "Den siste administratoren må beholde administratorrollen. Gi rollen til noen andre først.",
197
+ roleAssignFailed: "Rollen ble ikke tildelt. Prøv igjen.",
198
+ roleRemoveFailed: "Rollen ble ikke fjernet. Prøv igjen.",
199
+ emailTaken: "E-postadressen har allerede en konto. Bruk en annen e-postadresse.",
200
+ },
201
+ deleteErrors: {
202
+ lastAdmin: "Den siste administratoren kan ikke slettes. Gi administratorrollen til noen andre først.",
203
+ accountChangeConflict: "Noen andre endret brukerkontoene samtidig. Prøv igjen.",
204
+ },
205
+ };
206
+
207
+ /** For `LegalPagesEditor`. */
208
+ export const nbLegalPagesEditorStrings: LegalPagesEditorStrings = {
209
+ heading: "Juridiske sider",
210
+ title: "Tittel",
211
+ bodyMarkdown: "Innhold (Markdown)",
212
+ notFilled: "Ikke fylt ut",
213
+ loading: "Laster…",
214
+ save: "Lagre",
215
+ saving: "Lagrer…",
216
+ loadFailed: "Kunne ikke laste sidene",
217
+ required: "Du må fylle ut tittel og innhold.",
218
+ saved: "Siden er lagret",
219
+ saveFailed: "Kunne ikke lagre siden",
220
+ validation: { required, tooLong },
221
+ };
222
+
223
+ /** The tab labels of the default legal pages, for the `keyLabels` of `LegalPagesEditor`. */
224
+ export const nbLegalPageKeyLabels: Record<DefaultLegalPageKey, string> = {
225
+ terms: "Vilkår",
226
+ privacy: "Personvern",
227
+ cookies: "Informasjonskapsler",
228
+ };
229
+
230
+ /** For `StatHistoryChart`. */
231
+ export const nbStatHistoryChartStrings: StatHistoryChartStrings = {
232
+ empty: "Ingen historikk ennå. Se innom i morgen.",
233
+ chart: "Statistikk over tid",
234
+ };
235
+
236
+ /** For `EmailStatsPanel`. */
237
+ export const nbEmailStatsPanelStrings: EmailStatsPanelStrings = {
238
+ title: "E-post (siste {days} dager)",
239
+ sent: "Sendt",
240
+ delivered: "Levert",
241
+ bounces: "Returnert",
242
+ blocked: "Blokkert eller spam",
243
+ loading: "Laster…",
244
+ unavailable: "E-poststatistikken er ikke tilgjengelig akkurat nå. Prøv igjen senere.",
245
+ };
246
+
247
+ /** For `BrandingManager`. */
248
+ export const nbBrandingManagerStrings: BrandingManagerStrings = {
249
+ title: "Logo og ikon",
250
+ logo: "Logo",
251
+ logoHint: "Vises i menylinjen og bunnteksten. PNG, JPEG, WebP eller GIF, maks 2 MB.",
252
+ icon: "Ikon (favicon)",
253
+ iconHint: "Ikonet i nettleserfanen. Kvadratisk PNG eller ICO, maks 2 MB.",
254
+ none: "ingen",
255
+ choose: "Velg fil",
256
+ remove: "Fjern",
257
+ save: "Lagre logo og ikon",
258
+ saving: "Lagrer…",
259
+ saved: "Logo og ikon er lagret",
260
+ saveFailed: "Kunne ikke lagre logo og ikon",
261
+ validation: {
262
+ invalidImage: "Ugyldig filformat. Last opp en bildefil.",
263
+ imageTooLarge: "Filen er for stor. Last opp en fil på 2 MB eller mindre.",
264
+ },
265
+ };
266
+
267
+ /** For `LegalPageView`. */
268
+ export const nbLegalPageViewStrings: LegalPageViewStrings = {
269
+ updated: "Sist oppdatert",
270
+ missing: "Dette innholdet er ikke lagt inn ennå.",
271
+ toFrontPage: "Til forsiden",
272
+ };
273
+
274
+ /** For `SectionsEditor` and the section editors. */
275
+ export const nbSectionsEditorStrings: SectionsEditorStrings = {
276
+ hide: "Skjul",
277
+ show: "Vis",
278
+ moveUp: "Flytt opp",
279
+ moveDown: "Flytt ned",
280
+ duplicate: "Dupliser",
281
+ remove: "Slett",
282
+ addSection: "Legg til seksjon",
283
+ heading: "Overskrift",
284
+ headingOptional: "Overskrift (valgfri)",
285
+ subheading: "Undertekst",
286
+ text: "Tekst",
287
+ body: "Brødtekst (Markdown)",
288
+ primaryButtonLabel: "Tekst på knapp 1",
289
+ primaryButtonHref: "Lenke på knapp 1",
290
+ secondaryButtonLabel: "Tekst på knapp 2",
291
+ secondaryButtonHref: "Lenke på knapp 2",
292
+ buttonLabel: "Tekst på knappen",
293
+ buttonHref: "Lenke på knappen",
294
+ bullets: "Punkter",
295
+ addBullet: "Legg til punkt",
296
+ count: "Antall som vises",
297
+ stats: "Tall",
298
+ source: "Kilde",
299
+ sourceStatic: "Fast tall",
300
+ value: "Verdi",
301
+ label: "Tekst",
302
+ addStat: "Legg til tall",
303
+ layout: "Oppsett",
304
+ layoutStandard: "Standard (midtstilt)",
305
+ layoutFull: "Full bredde",
306
+ layoutLeft: "Bilde til venstre, tekst til høyre",
307
+ layoutRight: "Bilde til høyre, tekst til venstre",
308
+ layoutOverlay: "Tekst over bildet",
309
+ layoutOverlayFull: "Tekst over bildet (full bredde)",
310
+ altText: "Alternativ tekst",
311
+ caption: "Bildetekst (valgfri)",
312
+ image: "Bilde",
313
+ uploadImage: "Last opp bilde",
314
+ replaceImage: "Bytt bilde",
315
+ uploading: "Laster opp…",
316
+ removeImage: "Fjern",
317
+ uploadFailed: "Kunne ikke laste opp bildet",
318
+ };
319
+
320
+ /** For the `typeLabels` of `SectionsEditor`. */
321
+ export const nbSectionTypeLabels: Record<SharedSectionType, string> = {
322
+ hero: "Toppfelt",
323
+ feature: "Punktliste",
324
+ text: "Tekst",
325
+ feed: "Siste oppføringer",
326
+ contact: "Kontaktskjema",
327
+ cta: "Handlingsfelt",
328
+ stats: "Tallrad",
329
+ image: "Bilde",
330
+ };