@bygghammar/cms-platform 0.27.0 → 0.28.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,436 @@
|
|
|
1
|
+
import type React from "react";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import { toast } from "sonner";
|
|
4
|
+
import { ArrowLeft, Save } from "lucide-react";
|
|
5
|
+
import type { FormDefaults } from "../forms/field-sets";
|
|
6
|
+
import { InspectorSection, LocalizedField, TextField } from "./inspectors/fields";
|
|
7
|
+
import { SmartTagTextField, SmartTagLocalizedField } from "./forms/SmartTags";
|
|
8
|
+
import { POLICY_WORDS, normalizePolicyUrl, renderLinkedText } from "../forms/rich-text";
|
|
9
|
+
|
|
10
|
+
/** Host-provided data access for the shared form settings screen. */
|
|
11
|
+
export type FormSettingsDeps = {
|
|
12
|
+
defaults: FormDefaults | undefined;
|
|
13
|
+
isLoading: boolean;
|
|
14
|
+
saveDefaults: (input: { data: Record<string, unknown> }) => Promise<unknown>;
|
|
15
|
+
purgeOldSubmissions: (input: {
|
|
16
|
+
data: { dryRun: boolean };
|
|
17
|
+
}) => Promise<{ matched: number; deleted: number; cutoff: string }>;
|
|
18
|
+
/** Locale previewed under the consent text; defaults to the first key present. */
|
|
19
|
+
previewLocale?: string;
|
|
20
|
+
/** Host router link back to the forms index. */
|
|
21
|
+
BackLink: (props: { className?: string; children: React.ReactNode }) => React.ReactElement;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** Settings every form inherits; a form only overrides what it needs. */
|
|
25
|
+
export function FormSettingsView({ deps }: { deps: FormSettingsDeps }) {
|
|
26
|
+
const [s, setS] = useState<FormDefaults | null>(null);
|
|
27
|
+
const [busy, setBusy] = useState(false);
|
|
28
|
+
const [purgeInfo, setPurgeInfo] = useState<{
|
|
29
|
+
matched: number;
|
|
30
|
+
deleted: number;
|
|
31
|
+
cutoff: string;
|
|
32
|
+
} | null>(null);
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (deps.defaults) setS(deps.defaults);
|
|
36
|
+
}, [deps.defaults]);
|
|
37
|
+
|
|
38
|
+
if (deps.isLoading || !s) return <div className="p-8 text-sm text-ink/50">Loading…</div>;
|
|
39
|
+
|
|
40
|
+
const patch = (p: Partial<FormDefaults>) => setS({ ...s, ...p });
|
|
41
|
+
|
|
42
|
+
const onSave = async () => {
|
|
43
|
+
if (s.thankyou_mode === "redirect" && !s.thankyou_redirect.trim()) {
|
|
44
|
+
toast.error("Add a redirect URL, or switch back to the thank-you message.");
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
setBusy(true);
|
|
48
|
+
try {
|
|
49
|
+
await deps.saveDefaults({
|
|
50
|
+
data: {
|
|
51
|
+
notify_to: s.notify_to,
|
|
52
|
+
notify_enabled: s.notify_enabled,
|
|
53
|
+
notify_subject: s.notify_subject,
|
|
54
|
+
notify_body: s.notify_body,
|
|
55
|
+
notify_from_name: s.notify_from_name,
|
|
56
|
+
notify_from_email: s.notify_from_email,
|
|
57
|
+
notify_reply_to: s.notify_reply_to,
|
|
58
|
+
|
|
59
|
+
autoreply_enabled: s.autoreply_enabled,
|
|
60
|
+
autoreply_subject: s.autoreply_subject,
|
|
61
|
+
autoreply_body: s.autoreply_body,
|
|
62
|
+
spam_honeypot: s.spam_honeypot,
|
|
63
|
+
spam_min_seconds: s.spam_min_seconds,
|
|
64
|
+
spam_max_per_hour: s.spam_max_per_hour,
|
|
65
|
+
thankyou_mode: s.thankyou_mode,
|
|
66
|
+
thankyou_message: s.thankyou_message,
|
|
67
|
+
thankyou_redirect: s.thankyou_redirect,
|
|
68
|
+
consent_text: s.consent_text,
|
|
69
|
+
privacy_policy_url: s.privacy_policy_url ?? "",
|
|
70
|
+
retention_days: s.retention_days,
|
|
71
|
+
sheet_id: s.sheet_id,
|
|
72
|
+
sheet_tab: s.sheet_tab,
|
|
73
|
+
sheet_live_append: s.sheet_live_append,
|
|
74
|
+
save_partials: s.save_partials,
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
toast.success("Shared settings saved");
|
|
78
|
+
} catch (e) {
|
|
79
|
+
toast.error(e instanceof Error ? e.message : "Could not save");
|
|
80
|
+
} finally {
|
|
81
|
+
setBusy(false);
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const consent = (s.consent_text ?? {}) as Record<string, string>;
|
|
86
|
+
const previewLocale = deps.previewLocale ?? Object.keys(consent)[0] ?? "sv";
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<div className="p-8 md:p-12 w-full">
|
|
90
|
+
<div className="flex flex-wrap items-center justify-between gap-4">
|
|
91
|
+
<div>
|
|
92
|
+
<deps.BackLink className="inline-flex items-center gap-2 text-[0.7rem] uppercase tracking-[0.22em] text-ink/55 hover:text-sea">
|
|
93
|
+
<ArrowLeft className="size-3.5" />
|
|
94
|
+
Back to forms
|
|
95
|
+
</deps.BackLink>
|
|
96
|
+
<h1 className="mt-3 font-serif text-2xl text-ink">Shared form settings</h1>
|
|
97
|
+
<p className="mt-2 text-sm text-ink/60">
|
|
98
|
+
Every form inherits these values. A form only overrides what it needs.
|
|
99
|
+
</p>
|
|
100
|
+
</div>
|
|
101
|
+
<button
|
|
102
|
+
onClick={onSave}
|
|
103
|
+
disabled={busy}
|
|
104
|
+
className="inline-flex items-center gap-2 bg-ink px-5 py-2.5 text-xs uppercase tracking-[0.2em] text-paper hover:bg-sea hover:text-ink disabled:opacity-50"
|
|
105
|
+
>
|
|
106
|
+
<Save className="size-4" />
|
|
107
|
+
Save
|
|
108
|
+
</button>
|
|
109
|
+
</div>
|
|
110
|
+
|
|
111
|
+
<div className="mt-8 grid gap-6 lg:grid-cols-2">
|
|
112
|
+
<div className="border border-ink/10 bg-white p-6 space-y-4">
|
|
113
|
+
<InspectorSection title="Incomplete submissions">
|
|
114
|
+
<label className="flex items-center gap-3 text-sm text-ink/70">
|
|
115
|
+
<input
|
|
116
|
+
type="checkbox"
|
|
117
|
+
checked={s.save_partials !== false}
|
|
118
|
+
onChange={(e) => patch({ save_partials: e.target.checked })}
|
|
119
|
+
className="size-4 accent-sea"
|
|
120
|
+
/>
|
|
121
|
+
Save part-filled forms (drafts) by default on every form
|
|
122
|
+
</label>
|
|
123
|
+
<p className="text-xs text-ink/45">
|
|
124
|
+
Drafts are stored apart from real submissions and never overwrite them. A form can
|
|
125
|
+
override this in its own settings.
|
|
126
|
+
</p>
|
|
127
|
+
</InspectorSection>
|
|
128
|
+
|
|
129
|
+
<InspectorSection title="Notifications">
|
|
130
|
+
<label className="flex items-center gap-3 text-sm text-ink/70">
|
|
131
|
+
<input
|
|
132
|
+
type="checkbox"
|
|
133
|
+
checked={s.notify_enabled !== false}
|
|
134
|
+
onChange={(e) => patch({ notify_enabled: e.target.checked })}
|
|
135
|
+
className="size-4 accent-sea"
|
|
136
|
+
/>
|
|
137
|
+
Email us when a form is submitted
|
|
138
|
+
</label>
|
|
139
|
+
<TextField
|
|
140
|
+
label="Default recipients (comma separated)"
|
|
141
|
+
value={s.notify_to}
|
|
142
|
+
onChange={(v) => patch({ notify_to: v })}
|
|
143
|
+
/>
|
|
144
|
+
<div className="grid gap-3 sm:grid-cols-2">
|
|
145
|
+
<SmartTagTextField
|
|
146
|
+
label="Sender name (optional)"
|
|
147
|
+
value={s.notify_from_name ?? ""}
|
|
148
|
+
fields={[]}
|
|
149
|
+
onChange={(v) => patch({ notify_from_name: v })}
|
|
150
|
+
/>
|
|
151
|
+
<TextField
|
|
152
|
+
label="Sender address (optional)"
|
|
153
|
+
value={s.notify_from_email ?? ""}
|
|
154
|
+
onChange={(v) => patch({ notify_from_email: v })}
|
|
155
|
+
/>
|
|
156
|
+
</div>
|
|
157
|
+
<SmartTagTextField
|
|
158
|
+
label="Reply-to (optional, defaults to the sender's address)"
|
|
159
|
+
value={s.notify_reply_to ?? ""}
|
|
160
|
+
fields={[]}
|
|
161
|
+
placeholder="{{email}}"
|
|
162
|
+
onChange={(v) => patch({ notify_reply_to: v })}
|
|
163
|
+
/>
|
|
164
|
+
<SmartTagLocalizedField
|
|
165
|
+
label="Subject (optional)"
|
|
166
|
+
value={s.notify_subject}
|
|
167
|
+
fields={[]}
|
|
168
|
+
onChange={(v) => patch({ notify_subject: v })}
|
|
169
|
+
/>
|
|
170
|
+
<SmartTagLocalizedField
|
|
171
|
+
label="Body (optional — leave empty for the standard answer table)"
|
|
172
|
+
value={s.notify_body}
|
|
173
|
+
fields={[]}
|
|
174
|
+
multiline
|
|
175
|
+
onChange={(v) => patch({ notify_body: v })}
|
|
176
|
+
/>
|
|
177
|
+
<p className="text-xs text-ink/50">
|
|
178
|
+
Use {"{{"}field_key{"}}"} for an answer, {"{{"}all_fields{"}}"} for the full table,
|
|
179
|
+
plus {"{{"}form{"}}"}, {"{{"}locale{"}}"}, {"{{"}page_url{"}}"}, {"{{"}ip{"}}"},{" "}
|
|
180
|
+
{"{{"}user_agent{"}}"} and {"{{"}admin_url{"}}"}. Sender name and reply-to accept the
|
|
181
|
+
same tags — {"{{"}email{"}}"} replies straight to the person who submitted. Sent
|
|
182
|
+
through the default provider configured in Admin → Email settings.
|
|
183
|
+
</p>
|
|
184
|
+
</InspectorSection>
|
|
185
|
+
|
|
186
|
+
<InspectorSection title="Spam protection">
|
|
187
|
+
<label className="flex items-center gap-3 text-sm text-ink/70">
|
|
188
|
+
<input
|
|
189
|
+
type="checkbox"
|
|
190
|
+
checked={s.spam_honeypot}
|
|
191
|
+
onChange={(e) => patch({ spam_honeypot: e.target.checked })}
|
|
192
|
+
className="size-4 accent-sea"
|
|
193
|
+
/>
|
|
194
|
+
Honeypot field
|
|
195
|
+
</label>
|
|
196
|
+
<p className="text-xs text-ink/45">
|
|
197
|
+
Applies to every form — there is no per-form override.
|
|
198
|
+
</p>
|
|
199
|
+
<TextField
|
|
200
|
+
label="Minimum seconds before submit"
|
|
201
|
+
value={String(s.spam_min_seconds)}
|
|
202
|
+
onChange={(v) => patch({ spam_min_seconds: Number(v) || 0 })}
|
|
203
|
+
/>
|
|
204
|
+
<TextField
|
|
205
|
+
label="Max submissions per hour (per form)"
|
|
206
|
+
value={String(s.spam_max_per_hour)}
|
|
207
|
+
onChange={(v) => patch({ spam_max_per_hour: Number(v) || 0 })}
|
|
208
|
+
/>
|
|
209
|
+
<p className="text-xs text-ink/45">
|
|
210
|
+
Counted per visitor IP. When the IP cannot be determined the limit is skipped.
|
|
211
|
+
</p>
|
|
212
|
+
</InspectorSection>
|
|
213
|
+
|
|
214
|
+
<InspectorSection title="Autoreply to the sender">
|
|
215
|
+
<label className="flex items-center gap-3 text-sm text-ink/70">
|
|
216
|
+
<input
|
|
217
|
+
type="checkbox"
|
|
218
|
+
checked={s.autoreply_enabled}
|
|
219
|
+
onChange={(e) => patch({ autoreply_enabled: e.target.checked })}
|
|
220
|
+
className="size-4 accent-sea"
|
|
221
|
+
/>
|
|
222
|
+
Send an automatic reply when an email address was submitted
|
|
223
|
+
</label>
|
|
224
|
+
<SmartTagLocalizedField
|
|
225
|
+
label="Subject"
|
|
226
|
+
value={s.autoreply_subject}
|
|
227
|
+
fields={[]}
|
|
228
|
+
onChange={(v) => patch({ autoreply_subject: v })}
|
|
229
|
+
/>
|
|
230
|
+
<SmartTagLocalizedField
|
|
231
|
+
label="Body"
|
|
232
|
+
value={s.autoreply_body}
|
|
233
|
+
fields={[]}
|
|
234
|
+
multiline
|
|
235
|
+
onChange={(v) => patch({ autoreply_body: v })}
|
|
236
|
+
/>
|
|
237
|
+
<p className="text-xs text-ink/50">
|
|
238
|
+
Use {"{{"}field_key{"}}"} to insert an answer, for example {"{{"}name{"}}"}, or {"{{"}
|
|
239
|
+
all_fields{"}}"} for the full answer table.
|
|
240
|
+
</p>
|
|
241
|
+
</InspectorSection>
|
|
242
|
+
|
|
243
|
+
<InspectorSection title="Data retention">
|
|
244
|
+
<TextField
|
|
245
|
+
label="Keep submissions for (days, 0 = forever)"
|
|
246
|
+
value={String(s.retention_days)}
|
|
247
|
+
onChange={(v) => patch({ retention_days: Number(v) || 0 })}
|
|
248
|
+
/>
|
|
249
|
+
<p className="text-xs text-ink/50">
|
|
250
|
+
Submissions are append-only. Nothing is ever removed automatically by site updates —
|
|
251
|
+
purging is always a manual, confirmed action.
|
|
252
|
+
</p>
|
|
253
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
254
|
+
<button
|
|
255
|
+
onClick={async () => {
|
|
256
|
+
try {
|
|
257
|
+
const res = await deps.purgeOldSubmissions({ data: { dryRun: true } });
|
|
258
|
+
setPurgeInfo(res);
|
|
259
|
+
toast.info(
|
|
260
|
+
`${res.matched} submission(s) are older than ${s.retention_days} days.`,
|
|
261
|
+
);
|
|
262
|
+
} catch (e) {
|
|
263
|
+
toast.error(e instanceof Error ? e.message : "Could not check");
|
|
264
|
+
}
|
|
265
|
+
}}
|
|
266
|
+
className="border border-ink/15 px-4 py-2 text-xs uppercase tracking-[0.2em] hover:border-sea"
|
|
267
|
+
>
|
|
268
|
+
Check what would be purged
|
|
269
|
+
</button>
|
|
270
|
+
<button
|
|
271
|
+
disabled={!purgeInfo || purgeInfo.matched === 0}
|
|
272
|
+
onClick={async () => {
|
|
273
|
+
if (
|
|
274
|
+
!window.confirm(
|
|
275
|
+
`Permanently delete ${purgeInfo?.matched} submission(s) older than ${s.retention_days} days? This cannot be undone.`,
|
|
276
|
+
)
|
|
277
|
+
)
|
|
278
|
+
return;
|
|
279
|
+
try {
|
|
280
|
+
const res = await deps.purgeOldSubmissions({ data: { dryRun: false } });
|
|
281
|
+
setPurgeInfo(null);
|
|
282
|
+
toast.success(`Deleted ${res.deleted} submission(s)`);
|
|
283
|
+
} catch (e) {
|
|
284
|
+
toast.error(e instanceof Error ? e.message : "Purge failed");
|
|
285
|
+
}
|
|
286
|
+
}}
|
|
287
|
+
className="border border-red-300 px-4 py-2 text-xs uppercase tracking-[0.2em] text-red-600 hover:bg-red-50 disabled:opacity-40"
|
|
288
|
+
>
|
|
289
|
+
Purge now
|
|
290
|
+
</button>
|
|
291
|
+
</div>
|
|
292
|
+
{purgeInfo ? (
|
|
293
|
+
<p className="text-xs text-ink/50">
|
|
294
|
+
{purgeInfo.matched} match(es) before{" "}
|
|
295
|
+
{new Date(purgeInfo.cutoff).toLocaleDateString("sv-SE")}.
|
|
296
|
+
</p>
|
|
297
|
+
) : null}
|
|
298
|
+
</InspectorSection>
|
|
299
|
+
</div>
|
|
300
|
+
|
|
301
|
+
<div className="border border-ink/10 bg-white p-6 space-y-4">
|
|
302
|
+
<InspectorSection title="GDPR consent">
|
|
303
|
+
<LocalizedField
|
|
304
|
+
label="Consent text"
|
|
305
|
+
value={s.consent_text}
|
|
306
|
+
multiline
|
|
307
|
+
onChange={(v) => patch({ consent_text: v })}
|
|
308
|
+
/>
|
|
309
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
310
|
+
<button
|
|
311
|
+
type="button"
|
|
312
|
+
onClick={() => {
|
|
313
|
+
const next = { ...consent };
|
|
314
|
+
let changed = false;
|
|
315
|
+
for (const loc of Object.keys(next)) {
|
|
316
|
+
const text = next[loc] ?? "";
|
|
317
|
+
if (/\[[^\]\n]+\]\([^)\s]+\)/.test(text)) continue;
|
|
318
|
+
const m = POLICY_WORDS.exec(text);
|
|
319
|
+
if (!m) continue;
|
|
320
|
+
next[loc] =
|
|
321
|
+
text.slice(0, m.index) +
|
|
322
|
+
`[${m[0]}](privacy)` +
|
|
323
|
+
text.slice(m.index + m[0].length);
|
|
324
|
+
changed = true;
|
|
325
|
+
}
|
|
326
|
+
if (!changed) {
|
|
327
|
+
toast.info('No policy wording found to link — add e.g. "integritetspolicyn".');
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
patch({ consent_text: next });
|
|
331
|
+
toast.success("Privacy policy link inserted");
|
|
332
|
+
}}
|
|
333
|
+
className="border border-ink/15 px-4 py-2 text-xs uppercase tracking-[0.2em] hover:border-sea"
|
|
334
|
+
>
|
|
335
|
+
Insert privacy policy link
|
|
336
|
+
</button>
|
|
337
|
+
</div>
|
|
338
|
+
<p className="text-xs text-ink/50">
|
|
339
|
+
Add a link with <code>[integritetspolicyn](privacy)</code> — it points at the privacy
|
|
340
|
+
policy URL below. You can also use a full path or address, e.g.{" "}
|
|
341
|
+
<code>[privacy policy](/integritetspolicy)</code>. If there is no link at all, the
|
|
342
|
+
words “integritetspolicyn” / “privacy policy” are linked
|
|
343
|
+
automatically.
|
|
344
|
+
</p>
|
|
345
|
+
<TextField
|
|
346
|
+
label="Privacy policy URL"
|
|
347
|
+
value={s.privacy_policy_url ?? ""}
|
|
348
|
+
onChange={(v) => patch({ privacy_policy_url: v })}
|
|
349
|
+
placeholder="/integritetspolicy"
|
|
350
|
+
/>
|
|
351
|
+
<p className="text-xs text-ink/50">
|
|
352
|
+
Link target:{" "}
|
|
353
|
+
{normalizePolicyUrl(s.privacy_policy_url) ? (
|
|
354
|
+
<code>{normalizePolicyUrl(s.privacy_policy_url)}</code>
|
|
355
|
+
) : (
|
|
356
|
+
<span className="text-red-600">
|
|
357
|
+
not set — consent links will render as plain text
|
|
358
|
+
</span>
|
|
359
|
+
)}
|
|
360
|
+
</p>
|
|
361
|
+
<div className="border border-ink/10 bg-paper/60 p-3">
|
|
362
|
+
<p className="text-[0.65rem] uppercase tracking-[0.2em] text-ink/45">Preview</p>
|
|
363
|
+
<p className="mt-2 text-sm text-ink/75">
|
|
364
|
+
{renderLinkedText(consent[previewLocale] ?? "", {
|
|
365
|
+
privacyUrl: s.privacy_policy_url ?? "",
|
|
366
|
+
})}
|
|
367
|
+
</p>
|
|
368
|
+
</div>
|
|
369
|
+
</InspectorSection>
|
|
370
|
+
|
|
371
|
+
<InspectorSection title="After submit">
|
|
372
|
+
<label className="block">
|
|
373
|
+
<span className="text-[0.65rem] uppercase tracking-[0.2em] text-ink/55">Mode</span>
|
|
374
|
+
<select
|
|
375
|
+
value={s.thankyou_mode}
|
|
376
|
+
onChange={(e) => patch({ thankyou_mode: e.target.value })}
|
|
377
|
+
className="mt-2 w-full border border-ink/20 bg-white px-3 py-2 text-sm"
|
|
378
|
+
>
|
|
379
|
+
<option value="inline">Show thank-you message</option>
|
|
380
|
+
<option value="redirect">Redirect to a page</option>
|
|
381
|
+
</select>
|
|
382
|
+
</label>
|
|
383
|
+
<LocalizedField
|
|
384
|
+
label="Thank-you message"
|
|
385
|
+
value={s.thankyou_message}
|
|
386
|
+
multiline
|
|
387
|
+
onChange={(v) => patch({ thankyou_message: v })}
|
|
388
|
+
/>
|
|
389
|
+
<TextField
|
|
390
|
+
label="Redirect URL"
|
|
391
|
+
value={s.thankyou_redirect}
|
|
392
|
+
onChange={(v) => patch({ thankyou_redirect: v })}
|
|
393
|
+
/>
|
|
394
|
+
{s.thankyou_mode === "redirect" && !s.thankyou_redirect.trim() ? (
|
|
395
|
+
<p className="text-xs text-red-600">
|
|
396
|
+
Redirect mode needs a URL — without one the thank-you message is shown instead.
|
|
397
|
+
</p>
|
|
398
|
+
) : null}
|
|
399
|
+
</InspectorSection>
|
|
400
|
+
|
|
401
|
+
<InspectorSection title="Google Sheets">
|
|
402
|
+
<TextField
|
|
403
|
+
label="Default spreadsheet ID"
|
|
404
|
+
value={s.sheet_id}
|
|
405
|
+
onChange={(v) => patch({ sheet_id: v })}
|
|
406
|
+
/>
|
|
407
|
+
<TextField
|
|
408
|
+
label="Default tab name"
|
|
409
|
+
value={s.sheet_tab}
|
|
410
|
+
onChange={(v) => patch({ sheet_tab: v })}
|
|
411
|
+
/>
|
|
412
|
+
<label className="flex items-center gap-3 text-sm text-ink/70">
|
|
413
|
+
<input
|
|
414
|
+
type="checkbox"
|
|
415
|
+
checked={s.sheet_live_append}
|
|
416
|
+
onChange={(e) => patch({ sheet_live_append: e.target.checked })}
|
|
417
|
+
className="size-4 accent-sea"
|
|
418
|
+
/>
|
|
419
|
+
Append every new submission live
|
|
420
|
+
</label>
|
|
421
|
+
{s.sheet_live_append && !s.sheet_id.trim() ? (
|
|
422
|
+
<p className="text-xs text-red-600">
|
|
423
|
+
Live append does nothing until a default spreadsheet ID is set (or one is set per
|
|
424
|
+
form).
|
|
425
|
+
</p>
|
|
426
|
+
) : null}
|
|
427
|
+
<p className="text-xs text-ink/50">
|
|
428
|
+
Live sync requires the Google Sheets connection. CSV export always works from the
|
|
429
|
+
submissions inbox.
|
|
430
|
+
</p>
|
|
431
|
+
</InspectorSection>
|
|
432
|
+
</div>
|
|
433
|
+
</div>
|
|
434
|
+
</div>
|
|
435
|
+
);
|
|
436
|
+
}
|