@bygghammar/cms-platform 0.90.0-m7.9 → 0.91.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 (88) hide show
  1. package/CHANGELOG.md +241 -0
  2. package/README.md +23 -1
  3. package/docs/GOOGLE-SHEETS.md +145 -0
  4. package/docs/LAYOUT-BLOCKS.md +125 -0
  5. package/docs/MEDIA-FOLDERS.md +90 -0
  6. package/docs/NEW-SITE.md +35 -0
  7. package/docs/PAGES.md +452 -0
  8. package/docs/SCHEDULING.md +130 -0
  9. package/docs/TYPOGRAPHY.md +86 -0
  10. package/migrations/20260904090000_c54603f8-a155-40f8-a86c-54434aa5c0ae.sql +33 -0
  11. package/migrations/20260904140000_9f2a7c41-06d5-4d6b-8b4e-1c7a5d3e9b20.sql +383 -0
  12. package/migrations/20260904160000_0d565000-2839-4738-aacc-537d7090398f.sql +751 -0
  13. package/package.json +5 -1
  14. package/src/backoffice/DomainsView.tsx +152 -25
  15. package/src/backoffice/FormEditorView.tsx +10 -0
  16. package/src/backoffice/FormSettingsView.tsx +8 -0
  17. package/src/backoffice/MediaLibraryView.tsx +239 -21
  18. package/src/backoffice/NavigationView.tsx +43 -0
  19. package/src/backoffice/PageBuilderView.tsx +193 -36
  20. package/src/backoffice/PageEditorView.tsx +293 -57
  21. package/src/backoffice/PagesListView.tsx +294 -19
  22. package/src/backoffice/ScheduleFields.tsx +151 -0
  23. package/src/backoffice/SectionFormView.tsx +58 -0
  24. package/src/backoffice/StatusBadge.tsx +174 -0
  25. package/src/backoffice/TrackingView.tsx +247 -208
  26. package/src/backoffice/TypographyPanel.tsx +308 -0
  27. package/src/backoffice/forms/GoogleSheetsHelp.tsx +220 -0
  28. package/src/backoffice/inspectors/blocks.tsx +149 -0
  29. package/src/backoffice/inspectors/fields.tsx +81 -0
  30. package/src/backoffice/inspectors/index.tsx +3 -0
  31. package/src/backoffice/media/ConfirmDialog.tsx +64 -4
  32. package/src/backoffice/routes/host.ts +31 -0
  33. package/src/backoffice/routes/index.tsx +140 -15
  34. package/src/backoffice/routes/media-forms.tsx +5 -0
  35. package/src/backoffice/seo/PageSeoPanelView.tsx +31 -6
  36. package/src/backoffice/seo/page-seo-form.ts +11 -1
  37. package/src/backoffice/shell/domain-guard.ts +27 -14
  38. package/src/backoffice/shell/index.ts +5 -0
  39. package/src/backoffice/shell/use-resolved-config.tsx +67 -0
  40. package/src/backoffice/tracking-drafts.ts +150 -0
  41. package/src/blocks/block-children.tsx +40 -0
  42. package/src/blocks/block-tree.ts +378 -0
  43. package/src/blocks/components/IconGridBlock.tsx +76 -51
  44. package/src/blocks/components/LayoutBlock.tsx +100 -0
  45. package/src/blocks/countdown-time.ts +17 -87
  46. package/src/blocks/registry.tsx +21 -0
  47. package/src/blocks/types.ts +40 -0
  48. package/src/cms/page-publication.ts +377 -0
  49. package/src/cms/page-status.ts +152 -0
  50. package/src/cms/timezone.ts +181 -0
  51. package/src/cms/use-publication-boundary.ts +84 -0
  52. package/src/cms/use-schedule-clock.ts +43 -0
  53. package/src/cms/visibility-schedule.ts +267 -0
  54. package/src/config/modules.ts +34 -10
  55. package/src/config/site-config.ts +32 -0
  56. package/src/config/typography.ts +143 -0
  57. package/src/http/cache-policy.ts +49 -2
  58. package/src/media/folders.ts +183 -0
  59. package/src/media/use-media-upload.ts +21 -4
  60. package/src/navigation/index.ts +34 -2
  61. package/src/platform/contract.ts +52 -1
  62. package/src/runtime/server-entry.ts +8 -2
  63. package/src/server/content-sections-core.ts +16 -0
  64. package/src/server/domains-core.ts +124 -107
  65. package/src/server/forms-delivery-core.ts +61 -10
  66. package/src/server/forms-submissions-core.ts +47 -0
  67. package/src/server/google-service-account.ts +325 -0
  68. package/src/server/google-sheets-core.ts +463 -0
  69. package/src/server/http/sitemap.ts +32 -8
  70. package/src/server/injections-core.ts +230 -49
  71. package/src/server/media-library-core.ts +27 -6
  72. package/src/server/pages-core.ts +680 -184
  73. package/src/server/published-cache.ts +25 -4
  74. package/src/server/resolve-globals.ts +6 -1
  75. package/src/server/schema-drift.ts +4 -0
  76. package/src/server/schemas.ts +15 -0
  77. package/src/site/PageRenderer.tsx +45 -12
  78. package/src/site/SiteFooter.tsx +44 -3
  79. package/src/site/SiteHeader.tsx +23 -2
  80. package/src/site/google-fonts.ts +191 -0
  81. package/src/site/host.ts +172 -0
  82. package/src/site/locale-domains.ts +500 -0
  83. package/src/site/routes/public-page.tsx +12 -1
  84. package/src/site/routes.ts +206 -38
  85. package/src/site/site-url.ts +80 -17
  86. package/src/site/typography.tsx +75 -0
  87. package/src/tracking/injections.ts +52 -9
  88. package/src/ui/MobileCarousel.tsx +289 -0
@@ -0,0 +1,308 @@
1
+ /**
2
+ * Menu and footer typography, edited next to the thing it restyles.
3
+ *
4
+ * Each slot is a separate panel with its own draft and its own save, so the
5
+ * menu and the footer never share a choice. The panel starts on "the site's
6
+ * own font": an upgraded site renders exactly what it rendered before, and
7
+ * nothing here selects a family until an editor picks one.
8
+ *
9
+ * The picker is backed by the static catalog in `site/google-fonts`, so no
10
+ * Google API key is needed in the browser and no catalog is fetched on render.
11
+ * Any family Google serves can still be typed in by hand.
12
+ */
13
+
14
+ import { useEffect, useId, useMemo, useState } from "react";
15
+ import { RotateCcw, Save } from "lucide-react";
16
+ import { toast } from "sonner";
17
+
18
+ import {
19
+ DEFAULT_FONT_WEIGHTS,
20
+ resolveFontChoice,
21
+ sanitizeFontFamily,
22
+ THEME_FONT_CHOICE,
23
+ TYPOGRAPHY_SLOTS,
24
+ type FontChoice,
25
+ type ThemeFontToken,
26
+ type TypographySlot,
27
+ } from "../config/typography";
28
+ import {
29
+ GOOGLE_FONT_CATALOG,
30
+ searchGoogleFonts,
31
+ supportedFontWeights,
32
+ } from "../site/google-fonts";
33
+ import { fontStyle, SiteFontLinks } from "../site/typography";
34
+
35
+ /** Weights offered as toggles. Anything else can still be stored by a host. */
36
+ const WEIGHT_OPTIONS = [300, 400, 500, 600, 700] as const;
37
+
38
+ const FALLBACK_LABELS: Record<ThemeFontToken, string> = {
39
+ sans: "Sans (--font-sans)",
40
+ serif: "Serif (--font-serif)",
41
+ };
42
+
43
+ const SAMPLE = "The quick brown fox jumps over the lazy dog";
44
+
45
+ const inputClass =
46
+ "w-full border border-ink/15 bg-white px-3 py-2 text-sm text-ink focus:border-sea focus:outline-none";
47
+ const labelClass = "block text-xs uppercase tracking-wider text-ink/60 mb-1";
48
+
49
+ function sameChoice(a: FontChoice, b: FontChoice): boolean {
50
+ return (
51
+ (a.source ?? "theme") === (b.source ?? "theme") &&
52
+ (a.family ?? "") === (b.family ?? "") &&
53
+ (a.fallback ?? "") === (b.fallback ?? "") &&
54
+ (a.weights ?? []).join(",") === (b.weights ?? []).join(",")
55
+ );
56
+ }
57
+
58
+ export type TypographyPanelProps = {
59
+ /** Which part of the site this panel restyles. */
60
+ slot: TypographySlot;
61
+ /** The choice currently in effect, or undefined for the theme font. */
62
+ value: FontChoice | undefined;
63
+ /** Persist this slot's choice. The caller merges it with the other slot. */
64
+ onSave: (next: FontChoice) => Promise<unknown> | void;
65
+ /** Locked by another editor, or otherwise not editable right now. */
66
+ disabled?: boolean;
67
+ };
68
+
69
+ /**
70
+ * One slot's font: the theme font (the default) or a Google family, with a
71
+ * live preview of what was chosen. Saving affects this slot only.
72
+ */
73
+ export function TypographyPanel({ slot, value, onSave, disabled }: TypographyPanelProps) {
74
+ const id = useId();
75
+ const meta = TYPOGRAPHY_SLOTS[slot];
76
+ const saved = useMemo<FontChoice>(() => value ?? { ...THEME_FONT_CHOICE }, [value]);
77
+ const [draft, setDraft] = useState<FontChoice>(saved);
78
+ const [busy, setBusy] = useState(false);
79
+
80
+ useEffect(() => {
81
+ setDraft(saved);
82
+ }, [saved]);
83
+
84
+ const isGoogle = draft.source === "google";
85
+ const family = draft.family ?? "";
86
+ const cleanFamily = sanitizeFontFamily(family);
87
+ const fallback = draft.fallback ?? meta.fallback;
88
+ const weights = draft.weights?.length ? draft.weights : [...DEFAULT_FONT_WEIGHTS];
89
+ // Some families ship one weight only. Offering the others would let an editor
90
+ // tick a box that changes nothing, so they are shown disabled with a reason.
91
+ const availableWeights = supportedFontWeights(cleanFamily);
92
+ const suggestions = useMemo(() => searchGoogleFonts(family, 12), [family]);
93
+ const known = (GOOGLE_FONT_CATALOG as readonly string[]).includes(cleanFamily);
94
+ const dirty = !sameChoice(draft, saved);
95
+ const problem = isGoogle && !cleanFamily ? "Choose a font family, or keep the theme font." : null;
96
+
97
+ // Preview only what will actually be applied, through the same resolver the
98
+ // public site uses — so what is previewed cannot drift from what renders.
99
+ const preview = resolveFontChoice(
100
+ isGoogle && cleanFamily ? { ...draft, family: cleanFamily, fallback } : { source: "theme" },
101
+ slot,
102
+ );
103
+
104
+ const patch = (next: Partial<FontChoice>) => setDraft({ ...draft, ...next });
105
+
106
+ const hasWeight = (weight: number) => !availableWeights || availableWeights.includes(weight);
107
+
108
+ const toggleWeight = (weight: number) => {
109
+ if (!hasWeight(weight)) return;
110
+ const next = weights.includes(weight)
111
+ ? weights.filter((w) => w !== weight)
112
+ : [...weights, weight].sort((a, b) => a - b);
113
+ // At least one weight has to be loaded for the family to render at all.
114
+ patch({ weights: next.length ? next : [400] });
115
+ };
116
+
117
+ const save = async (choice: FontChoice) => {
118
+ setBusy(true);
119
+ try {
120
+ await onSave(choice);
121
+ toast.success(`${meta.label} font saved`);
122
+ } catch (e) {
123
+ toast.error(e instanceof Error ? e.message : "Could not save the font");
124
+ } finally {
125
+ setBusy(false);
126
+ }
127
+ };
128
+
129
+ return (
130
+ <fieldset className="border border-ink/10 bg-ice/20 px-4 py-4">
131
+ <legend className="px-1 text-[0.65rem] uppercase tracking-[0.2em] text-ink/55">
132
+ {meta.label} font
133
+ </legend>
134
+
135
+ <p className="text-xs text-ink/60">
136
+ The {meta.label.toLowerCase()} keeps the site&rsquo;s own font until you choose otherwise.
137
+ This setting changes the {meta.label.toLowerCase()} only — body text and the rest of the
138
+ site are untouched.
139
+ </p>
140
+
141
+ <div className="mt-3 flex flex-wrap gap-4">
142
+ <label className="flex items-center gap-2 text-sm text-ink/80">
143
+ <input
144
+ type="radio"
145
+ name={`${id}-source`}
146
+ checked={!isGoogle}
147
+ disabled={disabled}
148
+ onChange={() => setDraft({ source: "theme" })}
149
+ className="size-4 accent-sea"
150
+ />
151
+ Site theme font (no change)
152
+ </label>
153
+ <label className="flex items-center gap-2 text-sm text-ink/80">
154
+ <input
155
+ type="radio"
156
+ name={`${id}-source`}
157
+ checked={isGoogle}
158
+ disabled={disabled}
159
+ onChange={() => patch({ source: "google" })}
160
+ className="size-4 accent-sea"
161
+ />
162
+ Google font
163
+ </label>
164
+ </div>
165
+
166
+ {isGoogle ? (
167
+ <div className="mt-4 space-y-4">
168
+ <div className="grid gap-3 sm:grid-cols-[2fr_1fr]">
169
+ <label className="block">
170
+ <span className={labelClass}>Font family</span>
171
+ <input
172
+ id={`${id}-family`}
173
+ list={`${id}-catalog`}
174
+ value={family}
175
+ disabled={disabled}
176
+ autoComplete="off"
177
+ placeholder="Start typing, e.g. Lora"
178
+ onChange={(e) => patch({ family: e.target.value })}
179
+ onBlur={() => patch({ family: cleanFamily })}
180
+ aria-invalid={problem ? true : undefined}
181
+ aria-describedby={`${id}-family-hint`}
182
+ className={inputClass}
183
+ />
184
+ <datalist id={`${id}-catalog`}>
185
+ {suggestions.map((option) => (
186
+ <option key={option} value={option} />
187
+ ))}
188
+ </datalist>
189
+ </label>
190
+ <label className="block">
191
+ <span className={labelClass}>Fallback</span>
192
+ <select
193
+ value={fallback}
194
+ disabled={disabled}
195
+ onChange={(e) => patch({ fallback: e.target.value as ThemeFontToken })}
196
+ className={inputClass}
197
+ >
198
+ {(Object.keys(FALLBACK_LABELS) as ThemeFontToken[]).map((token) => (
199
+ <option key={token} value={token}>
200
+ {FALLBACK_LABELS[token]}
201
+ </option>
202
+ ))}
203
+ </select>
204
+ </label>
205
+ </div>
206
+
207
+ <p id={`${id}-family-hint`} className="text-xs text-ink/55">
208
+ {problem ? (
209
+ <span className="text-red-600">{problem}</span>
210
+ ) : cleanFamily && !known ? (
211
+ <>
212
+ &ldquo;{cleanFamily}&rdquo; is not in the suggestion list. It is still requested from
213
+ Google, so check the spelling against fonts.google.com.
214
+ </>
215
+ ) : (
216
+ <>
217
+ The list is a shortcut, not a limit — any family Google serves can be typed in. The
218
+ fallback is used while the font loads and if it ever fails to.
219
+ </>
220
+ )}
221
+ </p>
222
+
223
+ <div>
224
+ <span className={labelClass}>Weights to load</span>
225
+ <div className="flex flex-wrap gap-3">
226
+ {WEIGHT_OPTIONS.map((weight) => (
227
+ <label
228
+ key={weight}
229
+ title={hasWeight(weight) ? undefined : `${cleanFamily} has no ${weight} weight.`}
230
+ className={[
231
+ "flex items-center gap-1.5 text-sm",
232
+ hasWeight(weight) ? "text-ink/75" : "text-ink/35",
233
+ ].join(" ")}
234
+ >
235
+ <input
236
+ type="checkbox"
237
+ checked={hasWeight(weight) && weights.includes(weight)}
238
+ disabled={disabled || !hasWeight(weight)}
239
+ onChange={() => toggleWeight(weight)}
240
+ className="size-4 accent-sea"
241
+ />
242
+ {weight}
243
+ </label>
244
+ ))}
245
+ </div>
246
+ <p className="mt-1 text-xs text-ink/50">
247
+ Only the weights ticked here are downloaded by visitors.
248
+ {availableWeights ? (
249
+ <>
250
+ {" "}
251
+ {cleanFamily} is published in {availableWeights.join(" and ")} only; the rest are
252
+ greyed out.
253
+ </>
254
+ ) : null}
255
+ </p>
256
+ </div>
257
+ </div>
258
+ ) : null}
259
+
260
+ {/* Loaded for the preview only when a family is actually chosen. React 19
261
+ hoists and de-duplicates the stylesheet link by href. */}
262
+ <SiteFontLinks
263
+ requests={preview.googleFamily ? [{ family: preview.googleFamily, weights }] : []}
264
+ />
265
+ <div className="mt-4 border border-ink/10 bg-white px-4 py-3">
266
+ <span className="block text-[0.65rem] uppercase tracking-[0.2em] text-ink/45">Preview</span>
267
+ <p className="mt-1 text-lg text-ink" style={fontStyle(preview)}>
268
+ {SAMPLE}
269
+ </p>
270
+ <p className="mt-1 text-xs text-ink/50">
271
+ {preview.fontFamily
272
+ ? `${cleanFamily} — loaded from Google Fonts`
273
+ : "The site's own theme font — nothing extra is loaded"}
274
+ </p>
275
+ </div>
276
+
277
+ <div className="mt-4 flex flex-wrap items-center gap-3">
278
+ <button
279
+ type="button"
280
+ onClick={() =>
281
+ save(
282
+ isGoogle && cleanFamily
283
+ ? { source: "google", family: cleanFamily, fallback, weights }
284
+ : { source: "theme" },
285
+ )
286
+ }
287
+ disabled={disabled || busy || !dirty || Boolean(problem)}
288
+ className="inline-flex items-center gap-2 bg-ink px-4 py-2 text-xs uppercase tracking-wider text-paper hover:bg-ink/85 disabled:opacity-50"
289
+ >
290
+ <Save className="size-4" />
291
+ {busy ? "Saving…" : `Save ${meta.label.toLowerCase()} font`}
292
+ </button>
293
+ {saved.source === "google" ? (
294
+ <button
295
+ type="button"
296
+ onClick={() => save({ source: "theme" })}
297
+ disabled={disabled || busy}
298
+ className="inline-flex items-center gap-2 border border-ink/20 px-4 py-2 text-xs uppercase tracking-wider text-ink/70 hover:border-ink disabled:opacity-50"
299
+ >
300
+ <RotateCcw className="size-3.5" />
301
+ Back to the theme font
302
+ </button>
303
+ ) : null}
304
+ {dirty ? <span className="text-xs text-sea">unsaved</span> : null}
305
+ </div>
306
+ </fieldset>
307
+ );
308
+ }
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Shared Google Sheets admin help and connection test.
3
+ *
4
+ * One component for both the per-form panel and the shared form settings, so
5
+ * the instructions cannot drift apart. Nothing here ever receives a credential:
6
+ * the server returns only the service account's email address (which is what
7
+ * the editor has to share the spreadsheet with) and a classified result.
8
+ */
9
+
10
+ import { useState } from "react";
11
+ import { Check, Copy, Loader2, PlugZap } from "lucide-react";
12
+ import { toast } from "sonner";
13
+
14
+ /** Mirrors `SheetsErrorCode` in `server/google-sheets-core`. */
15
+ export type SheetsTestOutcome = {
16
+ ok: boolean;
17
+ code?: string;
18
+ error?: string | null;
19
+ serviceAccountEmail?: string | null;
20
+ spreadsheetTitle?: string | null;
21
+ worksheetTitle?: string | null;
22
+ worksheets?: string[];
23
+ canWrite?: boolean | null;
24
+ };
25
+
26
+ export type TestSheetFn = (input: {
27
+ data: { formKey?: string; spreadsheetId?: string; tab?: string };
28
+ }) => Promise<SheetsTestOutcome>;
29
+
30
+ /** What an editor should actually do about each failure. */
31
+ const NEXT_STEP: Record<string, string> = {
32
+ "credentials-missing":
33
+ "A developer sets the service account variables on the server; nothing can be fixed from here.",
34
+ "credentials-invalid":
35
+ "The stored service account key is not accepted by Google. A developer needs to re-copy it from the JSON key file.",
36
+ "invalid-spreadsheet-id":
37
+ "Open the spreadsheet and copy the long ID from the URL, between /d/ and /edit.",
38
+ "spreadsheet-not-found": "Check the Spreadsheet ID — no spreadsheet with that ID exists.",
39
+ "permission-denied": "Share the spreadsheet with the address below and give it Editor access.",
40
+ "worksheet-not-found": "Check the tab name at the bottom of the spreadsheet.",
41
+ "write-unconfirmed":
42
+ "Share the spreadsheet with the address below as an Editor, then test again. If it is already shared, a developer can enable the Google Drive API for this project so the check can answer.",
43
+ "api-unavailable": "Nothing is wrong with the settings. Try again in a moment.",
44
+ };
45
+
46
+ function CopyButton({ value, label }: { value: string; label: string }) {
47
+ const [copied, setCopied] = useState(false);
48
+ return (
49
+ <button
50
+ type="button"
51
+ onClick={async () => {
52
+ try {
53
+ await navigator.clipboard.writeText(value);
54
+ setCopied(true);
55
+ window.setTimeout(() => setCopied(false), 2000);
56
+ } catch {
57
+ toast.error("Copy failed — select the address and copy it manually.");
58
+ }
59
+ }}
60
+ className="inline-flex items-center gap-1.5 border border-ink/15 px-2 py-1 text-[0.65rem] uppercase tracking-widest text-ink/60 hover:border-sea hover:text-ink"
61
+ >
62
+ {copied ? <Check className="size-3 text-sea" /> : <Copy className="size-3" />}
63
+ {copied ? "Copied" : label}
64
+ </button>
65
+ );
66
+ }
67
+
68
+ export type GoogleSheetsHelpProps = {
69
+ /** Runs the server-side check. Omitted when the host has not wired it up. */
70
+ testSheet?: TestSheetFn;
71
+ /** Form this panel belongs to; omitted on the shared-defaults screen. */
72
+ formKey?: string;
73
+ /** Currently entered values, so the test covers unsaved edits. */
74
+ spreadsheetId: string;
75
+ tab: string;
76
+ };
77
+
78
+ /**
79
+ * Instructions plus a "Test connection" button that names exactly what is
80
+ * wrong: missing credentials, unknown spreadsheet, missing share, missing tab,
81
+ * or Google being unavailable.
82
+ */
83
+ export function GoogleSheetsHelp({
84
+ testSheet,
85
+ formKey,
86
+ spreadsheetId,
87
+ tab,
88
+ }: GoogleSheetsHelpProps) {
89
+ const [busy, setBusy] = useState(false);
90
+ const [result, setResult] = useState<SheetsTestOutcome | null>(null);
91
+
92
+ const onTest = async () => {
93
+ if (!testSheet) return;
94
+ setBusy(true);
95
+ setResult(null);
96
+ try {
97
+ const res = await testSheet({
98
+ data: {
99
+ ...(formKey ? { formKey } : {}),
100
+ spreadsheetId: spreadsheetId.trim(),
101
+ tab: tab.trim(),
102
+ },
103
+ });
104
+ setResult(res);
105
+ if (res.ok) toast.success("Google Sheets connection works");
106
+ else toast.error(res.error ?? "Connection test failed");
107
+ } catch (e) {
108
+ const error = e instanceof Error ? e.message : "Connection test failed";
109
+ setResult({ ok: false, error });
110
+ toast.error(error);
111
+ } finally {
112
+ setBusy(false);
113
+ }
114
+ };
115
+
116
+ const account = result?.serviceAccountEmail ?? null;
117
+
118
+ return (
119
+ <div className="space-y-3">
120
+ {testSheet ? (
121
+ <button
122
+ type="button"
123
+ onClick={onTest}
124
+ disabled={busy}
125
+ className="inline-flex items-center gap-2 border border-ink/15 px-4 py-2 text-xs uppercase tracking-[0.2em] hover:border-sea disabled:opacity-50"
126
+ >
127
+ {busy ? <Loader2 className="size-4 animate-spin" /> : <PlugZap className="size-4" />}
128
+ {busy ? "Testing…" : "Test connection"}
129
+ </button>
130
+ ) : null}
131
+
132
+ {result ? (
133
+ /*
134
+ * Three outcomes, three appearances. "The sheet is there but we could
135
+ * not confirm write access" is not a success with a footnote — it is
136
+ * the state where the next submission may be the one that fails, so it
137
+ * gets its own colour and its own headline rather than a caveat under
138
+ * a green "Connection works."
139
+ */
140
+ <div
141
+ role="status"
142
+ aria-live="polite"
143
+ className={[
144
+ "border px-3 py-2 text-xs",
145
+ result.ok
146
+ ? "border-sea/40 bg-ice/40 text-ink"
147
+ : result.code === "write-unconfirmed"
148
+ ? "border-brass/50 bg-brass/10 text-ink"
149
+ : "border-red-500/40 bg-red-50 text-ink",
150
+ ].join(" ")}
151
+ >
152
+ <p className="font-medium">
153
+ {result.ok
154
+ ? "Connection works."
155
+ : result.code === "write-unconfirmed"
156
+ ? "Write access unconfirmed."
157
+ : "Connection failed."}{" "}
158
+ {result.ok
159
+ ? `Writing to “${result.spreadsheetTitle}” · tab “${result.worksheetTitle}”.`
160
+ : result.error}
161
+ </p>
162
+ {!result.ok && result.code && NEXT_STEP[result.code] ? (
163
+ <p className="mt-1 text-ink/70">{NEXT_STEP[result.code]}</p>
164
+ ) : null}
165
+ {result.code === "write-unconfirmed" ? (
166
+ <p className="mt-1 text-ink/70">
167
+ The spreadsheet and tab are correct. If the account only has Viewer access, the first
168
+ submission will fail — the submission itself is still stored either way.
169
+ </p>
170
+ ) : null}
171
+ {!result.ok && result.worksheets?.length ? (
172
+ <p className="mt-1 text-ink/70">Tabs in this spreadsheet: {result.worksheets.join(", ")}.</p>
173
+ ) : null}
174
+ </div>
175
+ ) : null}
176
+
177
+ <details className="border border-ink/10 bg-ice/30 px-3 py-2 text-xs text-ink/70">
178
+ <summary className="cursor-pointer text-[0.65rem] uppercase tracking-[0.2em] text-ink/55">
179
+ How this connection works
180
+ </summary>
181
+ <div className="mt-2 space-y-2">
182
+ <p>
183
+ <strong className="text-ink">The spreadsheet does not have to be public.</strong> This
184
+ site signs in to Google as its own service account, so you share the sheet with that
185
+ account exactly like you would with a colleague. Nobody else gains access, and no key is
186
+ ever sent to a browser.
187
+ </p>
188
+ {account ? (
189
+ <p className="flex flex-wrap items-center gap-2">
190
+ <span>Share the spreadsheet with</span>
191
+ <code className="border border-ink/15 bg-white px-1.5 py-0.5 text-[0.7rem]">
192
+ {account}
193
+ </code>
194
+ <CopyButton value={account} label="Copy address" />
195
+ <span>and give it Editor access.</span>
196
+ </p>
197
+ ) : (
198
+ <p>
199
+ Run the connection test to see which address to share the spreadsheet with. If the
200
+ server has no service account configured yet, the test says so.
201
+ </p>
202
+ )}
203
+ <p>
204
+ <strong className="text-ink">Spreadsheet ID</strong> is the long code in the sheet&rsquo;s
205
+ web address, between <code>/d/</code> and <code>/edit</code>. You can paste the whole
206
+ address here — the ID is picked out of it.
207
+ </p>
208
+ <p>
209
+ <strong className="text-ink">Worksheet / tab name</strong> is the tab label at the bottom
210
+ of the spreadsheet, for example <code>Sheet1</code>. It must match exactly.
211
+ </p>
212
+ <p>
213
+ The test only reads the spreadsheet&rsquo;s name and tabs, so it never leaves an empty
214
+ row behind.
215
+ </p>
216
+ </div>
217
+ </details>
218
+ </div>
219
+ );
220
+ }