@bygghammar/cms-platform 0.80.1 → 0.90.0-m7.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +489 -0
- package/README.md +16 -2
- package/docs/EDIT-LOCKS.md +213 -0
- package/docs/POPUPS.md +102 -0
- package/migrations/20260902090000_dc072b25-8467-46ae-aef3-0ccdf40aa7eb.sql +274 -0
- package/package.json +31 -3
- package/scripts/create-cms-site.mjs +5 -4
- package/scripts/sync-migrations.mjs +27 -6
- package/src/backoffice/ContactDetailsView.tsx +19 -5
- package/src/backoffice/DomainsView.tsx +152 -25
- package/src/backoffice/EditLock.tsx +513 -0
- package/src/backoffice/EmailSettingsView.tsx +99 -0
- package/src/backoffice/FormEditorView.tsx +531 -512
- package/src/backoffice/FormSettingsView.tsx +16 -4
- package/src/backoffice/GlobalSectionBuilderView.tsx +42 -11
- package/src/backoffice/NavigationView.tsx +35 -11
- package/src/backoffice/PageBuilderView.tsx +51 -16
- package/src/backoffice/RichTextEditor.tsx +50 -14
- package/src/backoffice/SectionEditorView.tsx +18 -6
- package/src/backoffice/SectionFormView.tsx +184 -31
- package/src/backoffice/SiteSettingsView.tsx +14 -15
- package/src/backoffice/TrackingView.tsx +247 -208
- package/src/backoffice/forms/VersionHistory.tsx +4 -2
- package/src/backoffice/inspectors/blocks.tsx +22 -1
- package/src/backoffice/inspectors/fields.tsx +21 -0
- package/src/backoffice/routes/host.ts +58 -7
- package/src/backoffice/routes/index.tsx +117 -15
- package/src/backoffice/routes/pages.tsx +10 -6
- package/src/backoffice/seo/PageSeoPanelView.tsx +215 -197
- package/src/backoffice/shell/domain-guard.ts +27 -14
- package/src/backoffice/shell/index.ts +5 -0
- package/src/backoffice/shell/nav-model.ts +32 -23
- package/src/backoffice/shell/use-resolved-config.tsx +67 -0
- package/src/backoffice/tracking-drafts.ts +150 -0
- package/src/blocks/components/CtaBannerBlock.tsx +18 -12
- package/src/blocks/components/HeroFullBlock.tsx +25 -12
- package/src/blocks/components/MediaSplitBlock.tsx +91 -41
- package/src/blocks/components/PriceTableBlock.tsx +123 -89
- package/src/blocks/components/RichTextBlock.tsx +26 -4
- package/src/blocks/index.ts +8 -0
- package/src/blocks/optional-content.ts +82 -0
- package/src/blocks/registry.tsx +1 -0
- package/src/blocks/types.ts +6 -0
- package/src/cms/edit-lock.ts +491 -0
- package/src/config/modules.ts +208 -0
- package/src/config/site-config.ts +9 -31
- package/src/forms/FormRenderer.tsx +62 -3
- package/src/forms/submission-result.ts +35 -0
- package/src/forms/success-listeners.ts +46 -0
- package/src/host/index.tsx +27 -1
- package/src/modules/index.ts +28 -0
- package/src/modules/public-runtime.tsx +159 -0
- package/src/navigation/index.ts +29 -0
- package/src/popups/admin.tsx +123 -0
- package/src/popups/cms-bridges.ts +240 -0
- package/src/popups/destinations.tsx +147 -0
- package/src/popups/host.ts +91 -0
- package/src/popups/media-bridge.tsx +84 -0
- package/src/popups/register.ts +23 -0
- package/src/popups/runtime.tsx +128 -0
- package/src/runtime/server-entry.ts +8 -2
- package/src/server/content-sections-core.ts +22 -5
- package/src/server/domains-core.ts +124 -107
- package/src/server/edit-lock-adapter.ts +91 -0
- package/src/server/edit-lock-supabase-adapter.ts +135 -0
- package/src/server/edit-locks-core.ts +286 -0
- package/src/server/forms-core.ts +37 -2
- package/src/server/injections-core.ts +230 -49
- package/src/server/page-seo-save-core.ts +27 -0
- package/src/server/pages-core.ts +108 -11
- package/src/server/popup-optin-trigger.ts +111 -0
- package/src/server/popup-optin-webhook-core.ts +363 -0
- package/src/server/provider-secrets.ts +100 -4
- package/src/server/schemas.ts +74 -9
- package/src/server/webhook-url-guard.ts +199 -0
- package/src/site/SiteHeader.tsx +11 -5
- package/src/site/host.ts +172 -0
- package/src/site/locale-domains.ts +500 -0
- package/src/site/routes.ts +206 -38
- package/src/site/site-url.ts +80 -17
- package/src/tracking/injections.ts +52 -9
- package/src/types/css-modules.d.ts +6 -0
- package/src/ui/Reveal.tsx +74 -9
|
@@ -2,6 +2,13 @@ import { useEffect, useState } from "react";
|
|
|
2
2
|
import { toast } from "sonner";
|
|
3
3
|
import { Plus, Save, Trash2, Copy } from "lucide-react";
|
|
4
4
|
|
|
5
|
+
import {
|
|
6
|
+
markSaved,
|
|
7
|
+
mergeServerProfiles,
|
|
8
|
+
newRow,
|
|
9
|
+
type DraftRow,
|
|
10
|
+
} from "./tracking-drafts";
|
|
11
|
+
|
|
5
12
|
export type InjectionScope = "global" | "locale" | "domain";
|
|
6
13
|
|
|
7
14
|
/** One injection profile row, structurally matching the host's own type. */
|
|
@@ -27,7 +34,17 @@ export type TrackingDeps = {
|
|
|
27
34
|
profiles: InjectionProfileLike[];
|
|
28
35
|
domains: { domain: string }[];
|
|
29
36
|
locales: readonly string[];
|
|
30
|
-
|
|
37
|
+
/**
|
|
38
|
+
* Persist one profile and return the row's server id.
|
|
39
|
+
*
|
|
40
|
+
* The id is the contract, not a convenience: a profile created here has none
|
|
41
|
+
* until the server assigns one, and a save that comes back without it leaves
|
|
42
|
+
* the row unable to say which profile it is. Saving again would then insert a
|
|
43
|
+
* second copy rather than update the first.
|
|
44
|
+
*/
|
|
45
|
+
saveInjection: (
|
|
46
|
+
input: { data: Omit<InjectionProfileLike, "id"> & { id?: string } },
|
|
47
|
+
) => Promise<{ id: string }>;
|
|
31
48
|
deleteInjection: (input: { data: { id: string } }) => Promise<unknown>;
|
|
32
49
|
refresh: () => Promise<unknown> | void;
|
|
33
50
|
};
|
|
@@ -103,20 +120,26 @@ function Code({
|
|
|
103
120
|
export function TrackingView({ deps }: { deps: TrackingDeps }) {
|
|
104
121
|
const { profiles, domains, locales, saveInjection: save, deleteInjection: remove, refresh } = deps;
|
|
105
122
|
|
|
106
|
-
const [
|
|
123
|
+
const [rows, setRows] = useState<DraftRow<InjectionProfileLike>[]>([]);
|
|
107
124
|
const [busy, setBusy] = useState<string | null>(null);
|
|
108
125
|
|
|
126
|
+
// Fold the server list into the rows being edited rather than replacing
|
|
127
|
+
// them. Replacing is what discarded unsaved work every time an unrelated
|
|
128
|
+
// save, refetch or parent render produced a new list.
|
|
109
129
|
useEffect(() => {
|
|
110
|
-
|
|
130
|
+
setRows((current) => mergeServerProfiles(current, profiles));
|
|
111
131
|
}, [profiles]);
|
|
112
132
|
|
|
113
133
|
const patch = (i: number, next: Partial<InjectionProfileLike>) =>
|
|
114
|
-
|
|
134
|
+
setRows((d) =>
|
|
135
|
+
d.map((row, idx) => (idx === i ? { ...row, value: { ...row.value, ...next } } : row)),
|
|
136
|
+
);
|
|
115
137
|
|
|
116
|
-
const onSave = async (
|
|
117
|
-
|
|
138
|
+
const onSave = async (row: DraftRow<InjectionProfileLike>) => {
|
|
139
|
+
const p = row.value;
|
|
140
|
+
setBusy(row.key);
|
|
118
141
|
try {
|
|
119
|
-
await save({
|
|
142
|
+
const result = await save({
|
|
120
143
|
data: {
|
|
121
144
|
id: p.id || undefined,
|
|
122
145
|
name: p.name,
|
|
@@ -134,6 +157,17 @@ export function TrackingView({ deps }: { deps: TrackingDeps }) {
|
|
|
134
157
|
sort_order: p.sort_order,
|
|
135
158
|
},
|
|
136
159
|
});
|
|
160
|
+
// Adopt the id a newly created profile was just given, so the reload
|
|
161
|
+
// recognises this row instead of listing the same profile twice.
|
|
162
|
+
const savedId = result?.id;
|
|
163
|
+
if (!p.id && !savedId) {
|
|
164
|
+
// Without an id this row cannot be updated, only inserted again. It
|
|
165
|
+
// stays dirty and unsaved so the next attempt is a retry rather than a
|
|
166
|
+
// duplicate profile nobody asked for.
|
|
167
|
+
toast.error("Saved without an id — reload and try again");
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
setRows((d) => markSaved(d, row.key, p, savedId));
|
|
137
171
|
toast.success("Saved — live within 30 seconds");
|
|
138
172
|
void refresh();
|
|
139
173
|
} catch (e) {
|
|
@@ -143,14 +177,16 @@ export function TrackingView({ deps }: { deps: TrackingDeps }) {
|
|
|
143
177
|
}
|
|
144
178
|
};
|
|
145
179
|
|
|
146
|
-
const onDelete = async (
|
|
180
|
+
const onDelete = async (row: DraftRow<InjectionProfileLike>) => {
|
|
181
|
+
const p = row.value;
|
|
147
182
|
if (!p.id) {
|
|
148
|
-
|
|
183
|
+
setRows((d) => d.filter((x) => x.key !== row.key));
|
|
149
184
|
return;
|
|
150
185
|
}
|
|
151
186
|
if (!confirm(`Delete "${p.name}"?`)) return;
|
|
152
187
|
try {
|
|
153
188
|
await remove({ data: { id: p.id } });
|
|
189
|
+
setRows((d) => d.filter((x) => x.key !== row.key));
|
|
154
190
|
toast.success("Deleted");
|
|
155
191
|
void refresh();
|
|
156
192
|
} catch (e) {
|
|
@@ -169,233 +205,236 @@ export function TrackingView({ deps }: { deps: TrackingDeps }) {
|
|
|
169
205
|
</p>
|
|
170
206
|
|
|
171
207
|
<div className="mt-8 space-y-8">
|
|
172
|
-
{
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
onChange={(e) => patch(i, { name: e.target.value })}
|
|
179
|
-
className="border border-transparent px-1 py-1 font-serif text-lg text-ink hover:border-ink/15 focus:border-sea focus:outline-none"
|
|
180
|
-
/>
|
|
181
|
-
<label className="inline-flex items-center gap-2 text-sm text-ink">
|
|
208
|
+
{rows.map((row, i) => {
|
|
209
|
+
const p = row.value;
|
|
210
|
+
return (
|
|
211
|
+
<section key={row.key} className="border border-ink/10 bg-white">
|
|
212
|
+
<header className="flex flex-wrap items-center justify-between gap-3 border-b border-ink/10 px-5 py-4">
|
|
213
|
+
<div className="flex min-w-0 flex-wrap items-center gap-3">
|
|
182
214
|
<input
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
215
|
+
value={p.name}
|
|
216
|
+
onChange={(e) => patch(i, { name: e.target.value })}
|
|
217
|
+
className="border border-transparent px-1 py-1 font-serif text-lg text-ink hover:border-ink/15 focus:border-sea focus:outline-none"
|
|
186
218
|
/>
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
...d,
|
|
195
|
-
{ ...p, id: "", name: `${p.name} (copy)`, enabled: false },
|
|
196
|
-
])
|
|
197
|
-
}
|
|
198
|
-
className="inline-flex items-center gap-1 border border-ink/20 px-3 py-1.5 text-sm text-ink hover:border-sea"
|
|
199
|
-
>
|
|
200
|
-
<Copy className="size-3.5" /> Duplicate
|
|
201
|
-
</button>
|
|
202
|
-
<button
|
|
203
|
-
onClick={() => onDelete(p, i)}
|
|
204
|
-
className="inline-flex items-center gap-1 border border-ink/20 px-3 py-1.5 text-sm text-red-600 hover:border-red-400"
|
|
205
|
-
>
|
|
206
|
-
<Trash2 className="size-3.5" /> Delete
|
|
207
|
-
</button>
|
|
208
|
-
<button
|
|
209
|
-
onClick={() => onSave(p)}
|
|
210
|
-
disabled={busy !== null}
|
|
211
|
-
className="inline-flex items-center gap-2 bg-ink px-4 py-1.5 text-sm text-white disabled:opacity-40"
|
|
212
|
-
>
|
|
213
|
-
<Save className="size-3.5" /> Save
|
|
214
|
-
</button>
|
|
215
|
-
</div>
|
|
216
|
-
</header>
|
|
217
|
-
|
|
218
|
-
<div className="grid gap-6 p-5 lg:grid-cols-2">
|
|
219
|
-
<div className="space-y-4">
|
|
220
|
-
<div className="grid gap-3 sm:grid-cols-2">
|
|
221
|
-
<label className="block">
|
|
222
|
-
<Label>Applies to</Label>
|
|
223
|
-
<select
|
|
224
|
-
value={p.scope_type}
|
|
225
|
-
onChange={(e) =>
|
|
226
|
-
patch(i, {
|
|
227
|
-
scope_type: e.target.value as InjectionScope,
|
|
228
|
-
scope_value: "",
|
|
229
|
-
})
|
|
230
|
-
}
|
|
231
|
-
className="w-full border border-ink/20 px-3 py-2 text-sm text-ink focus:outline-none focus:border-sea"
|
|
232
|
-
>
|
|
233
|
-
<option value="global">All sites</option>
|
|
234
|
-
<option value="locale">One language</option>
|
|
235
|
-
<option value="domain">One domain</option>
|
|
236
|
-
</select>
|
|
219
|
+
<label className="inline-flex items-center gap-2 text-sm text-ink">
|
|
220
|
+
<input
|
|
221
|
+
type="checkbox"
|
|
222
|
+
checked={p.enabled}
|
|
223
|
+
onChange={(e) => patch(i, { enabled: e.target.checked })}
|
|
224
|
+
/>
|
|
225
|
+
Enabled
|
|
237
226
|
</label>
|
|
238
|
-
|
|
227
|
+
</div>
|
|
228
|
+
<div className="flex items-center gap-2">
|
|
229
|
+
<button
|
|
230
|
+
onClick={() =>
|
|
231
|
+
setRows((d) => [
|
|
232
|
+
...d,
|
|
233
|
+
newRow({ ...p, id: "", name: `${p.name} (copy)`, enabled: false }),
|
|
234
|
+
])
|
|
235
|
+
}
|
|
236
|
+
className="inline-flex items-center gap-1 border border-ink/20 px-3 py-1.5 text-sm text-ink hover:border-sea"
|
|
237
|
+
>
|
|
238
|
+
<Copy className="size-3.5" /> Duplicate
|
|
239
|
+
</button>
|
|
240
|
+
<button
|
|
241
|
+
onClick={() => onDelete(row)}
|
|
242
|
+
className="inline-flex items-center gap-1 border border-ink/20 px-3 py-1.5 text-sm text-red-600 hover:border-red-400"
|
|
243
|
+
>
|
|
244
|
+
<Trash2 className="size-3.5" /> Delete
|
|
245
|
+
</button>
|
|
246
|
+
<button
|
|
247
|
+
onClick={() => onSave(row)}
|
|
248
|
+
disabled={busy !== null}
|
|
249
|
+
className="inline-flex items-center gap-2 bg-ink px-4 py-1.5 text-sm text-white disabled:opacity-40"
|
|
250
|
+
>
|
|
251
|
+
<Save className="size-3.5" /> Save
|
|
252
|
+
</button>
|
|
253
|
+
</div>
|
|
254
|
+
</header>
|
|
255
|
+
|
|
256
|
+
<div className="grid gap-6 p-5 lg:grid-cols-2">
|
|
257
|
+
<div className="space-y-4">
|
|
258
|
+
<div className="grid gap-3 sm:grid-cols-2">
|
|
239
259
|
<label className="block">
|
|
240
|
-
<Label>
|
|
260
|
+
<Label>Applies to</Label>
|
|
241
261
|
<select
|
|
242
|
-
value={p.
|
|
243
|
-
onChange={(e) =>
|
|
262
|
+
value={p.scope_type}
|
|
263
|
+
onChange={(e) =>
|
|
264
|
+
patch(i, {
|
|
265
|
+
scope_type: e.target.value as InjectionScope,
|
|
266
|
+
scope_value: "",
|
|
267
|
+
})
|
|
268
|
+
}
|
|
244
269
|
className="w-full border border-ink/20 px-3 py-2 text-sm text-ink focus:outline-none focus:border-sea"
|
|
245
270
|
>
|
|
246
|
-
<option value="">
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
{l.toUpperCase()}
|
|
250
|
-
</option>
|
|
251
|
-
))}
|
|
271
|
+
<option value="global">All sites</option>
|
|
272
|
+
<option value="locale">One language</option>
|
|
273
|
+
<option value="domain">One domain</option>
|
|
252
274
|
</select>
|
|
253
275
|
</label>
|
|
254
|
-
|
|
255
|
-
|
|
276
|
+
{p.scope_type === "locale" ? (
|
|
277
|
+
<label className="block">
|
|
278
|
+
<Label>Language</Label>
|
|
279
|
+
<select
|
|
280
|
+
value={p.scope_value}
|
|
281
|
+
onChange={(e) => patch(i, { scope_value: e.target.value })}
|
|
282
|
+
className="w-full border border-ink/20 px-3 py-2 text-sm text-ink focus:outline-none focus:border-sea"
|
|
283
|
+
>
|
|
284
|
+
<option value="">Select…</option>
|
|
285
|
+
{locales.map((l) => (
|
|
286
|
+
<option key={l} value={l}>
|
|
287
|
+
{l.toUpperCase()}
|
|
288
|
+
</option>
|
|
289
|
+
))}
|
|
290
|
+
</select>
|
|
291
|
+
</label>
|
|
292
|
+
) : null}
|
|
293
|
+
{p.scope_type === "domain" ? (
|
|
294
|
+
<label className="block">
|
|
295
|
+
<Label>Domain</Label>
|
|
296
|
+
<input
|
|
297
|
+
value={p.scope_value}
|
|
298
|
+
onChange={(e) => patch(i, { scope_value: e.target.value })}
|
|
299
|
+
list={`domains-${i}`}
|
|
300
|
+
placeholder="dev.voyara.se"
|
|
301
|
+
className="w-full border border-ink/20 px-3 py-2 text-sm text-ink focus:outline-none focus:border-sea"
|
|
302
|
+
/>
|
|
303
|
+
<datalist id={`domains-${i}`}>
|
|
304
|
+
{domains.map((d) => (
|
|
305
|
+
<option key={d.domain} value={d.domain} />
|
|
306
|
+
))}
|
|
307
|
+
</datalist>
|
|
308
|
+
</label>
|
|
309
|
+
) : null}
|
|
310
|
+
</div>
|
|
311
|
+
|
|
312
|
+
<div className="grid gap-3 sm:grid-cols-2">
|
|
313
|
+
<label className="block">
|
|
314
|
+
<Label>Google Tag Manager ID</Label>
|
|
315
|
+
<Input
|
|
316
|
+
value={p.gtm_id}
|
|
317
|
+
onChange={(v) => patch(i, { gtm_id: v })}
|
|
318
|
+
placeholder="GTM-XXXXXXX"
|
|
319
|
+
/>
|
|
320
|
+
</label>
|
|
256
321
|
<label className="block">
|
|
257
|
-
<Label>
|
|
258
|
-
<
|
|
259
|
-
value={p.
|
|
260
|
-
onChange={(
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
322
|
+
<Label>Google Analytics 4 ID</Label>
|
|
323
|
+
<Input
|
|
324
|
+
value={p.ga4_id}
|
|
325
|
+
onChange={(v) => patch(i, { ga4_id: v })}
|
|
326
|
+
placeholder="G-XXXXXXXXXX"
|
|
327
|
+
/>
|
|
328
|
+
</label>
|
|
329
|
+
<label className="block">
|
|
330
|
+
<Label>Meta (Facebook / Instagram) Pixel</Label>
|
|
331
|
+
<Input
|
|
332
|
+
value={p.meta_pixel_id}
|
|
333
|
+
onChange={(v) => patch(i, { meta_pixel_id: v })}
|
|
334
|
+
placeholder="1234567890"
|
|
335
|
+
/>
|
|
336
|
+
</label>
|
|
337
|
+
<label className="block">
|
|
338
|
+
<Label>TikTok Pixel</Label>
|
|
339
|
+
<Input
|
|
340
|
+
value={p.tiktok_pixel_id}
|
|
341
|
+
onChange={(v) => patch(i, { tiktok_pixel_id: v })}
|
|
342
|
+
placeholder="CXXXXXXXXXXXXXXXX"
|
|
264
343
|
/>
|
|
265
|
-
<datalist id={`domains-${i}`}>
|
|
266
|
-
{domains.map((d) => (
|
|
267
|
-
<option key={d.domain} value={d.domain} />
|
|
268
|
-
))}
|
|
269
|
-
</datalist>
|
|
270
344
|
</label>
|
|
271
|
-
|
|
345
|
+
</div>
|
|
346
|
+
|
|
347
|
+
<div>
|
|
348
|
+
<Label>Extra meta tags (verification codes)</Label>
|
|
349
|
+
<div className="space-y-2">
|
|
350
|
+
{p.extra_meta.map((m, mi) => (
|
|
351
|
+
<div key={mi} className="flex gap-2">
|
|
352
|
+
<input
|
|
353
|
+
value={m.name}
|
|
354
|
+
onChange={(e) =>
|
|
355
|
+
patch(i, {
|
|
356
|
+
extra_meta: p.extra_meta.map((x, xi) =>
|
|
357
|
+
xi === mi ? { ...x, name: e.target.value } : x,
|
|
358
|
+
),
|
|
359
|
+
})
|
|
360
|
+
}
|
|
361
|
+
placeholder="google-site-verification"
|
|
362
|
+
className="w-1/2 border border-ink/20 px-3 py-2 text-sm focus:outline-none focus:border-sea"
|
|
363
|
+
/>
|
|
364
|
+
<input
|
|
365
|
+
value={m.content}
|
|
366
|
+
onChange={(e) =>
|
|
367
|
+
patch(i, {
|
|
368
|
+
extra_meta: p.extra_meta.map((x, xi) =>
|
|
369
|
+
xi === mi ? { ...x, content: e.target.value } : x,
|
|
370
|
+
),
|
|
371
|
+
})
|
|
372
|
+
}
|
|
373
|
+
placeholder="value"
|
|
374
|
+
className="w-1/2 border border-ink/20 px-3 py-2 text-sm focus:outline-none focus:border-sea"
|
|
375
|
+
/>
|
|
376
|
+
<button
|
|
377
|
+
onClick={() =>
|
|
378
|
+
patch(i, {
|
|
379
|
+
extra_meta: p.extra_meta.filter((_, xi) => xi !== mi),
|
|
380
|
+
})
|
|
381
|
+
}
|
|
382
|
+
className="p-2 text-red-500 hover:text-red-700"
|
|
383
|
+
>
|
|
384
|
+
<Trash2 className="size-3.5" />
|
|
385
|
+
</button>
|
|
386
|
+
</div>
|
|
387
|
+
))}
|
|
388
|
+
<button
|
|
389
|
+
onClick={() =>
|
|
390
|
+
patch(i, { extra_meta: [...p.extra_meta, { name: "", content: "" }] })
|
|
391
|
+
}
|
|
392
|
+
className="inline-flex items-center gap-2 border border-ink/20 px-3 py-1.5 text-sm text-ink hover:border-sea"
|
|
393
|
+
>
|
|
394
|
+
<Plus className="size-4" /> Add meta tag
|
|
395
|
+
</button>
|
|
396
|
+
</div>
|
|
397
|
+
</div>
|
|
272
398
|
</div>
|
|
273
399
|
|
|
274
|
-
<div className="
|
|
400
|
+
<div className="space-y-4">
|
|
275
401
|
<label className="block">
|
|
276
|
-
<Label>
|
|
277
|
-
<
|
|
278
|
-
value={p.
|
|
279
|
-
onChange={(v) => patch(i, {
|
|
280
|
-
placeholder="
|
|
402
|
+
<Label>Inside <head></Label>
|
|
403
|
+
<Code
|
|
404
|
+
value={p.head_html}
|
|
405
|
+
onChange={(v) => patch(i, { head_html: v })}
|
|
406
|
+
placeholder="<!-- scripts, meta tags, verification -->"
|
|
281
407
|
/>
|
|
282
408
|
</label>
|
|
283
409
|
<label className="block">
|
|
284
|
-
<Label>
|
|
285
|
-
<
|
|
286
|
-
value={p.
|
|
287
|
-
onChange={(v) => patch(i, {
|
|
288
|
-
placeholder="
|
|
410
|
+
<Label>After <body> tag</Label>
|
|
411
|
+
<Code
|
|
412
|
+
value={p.body_start_html}
|
|
413
|
+
onChange={(v) => patch(i, { body_start_html: v })}
|
|
414
|
+
placeholder="<!-- noscript pixels, chat widgets -->"
|
|
289
415
|
/>
|
|
290
416
|
</label>
|
|
291
417
|
<label className="block">
|
|
292
|
-
<Label>
|
|
293
|
-
<
|
|
294
|
-
value={p.
|
|
295
|
-
onChange={(v) => patch(i, {
|
|
296
|
-
placeholder="
|
|
418
|
+
<Label>Before </body> tag (footer)</Label>
|
|
419
|
+
<Code
|
|
420
|
+
value={p.body_end_html}
|
|
421
|
+
onChange={(v) => patch(i, { body_end_html: v })}
|
|
422
|
+
placeholder="<!-- deferred scripts -->"
|
|
297
423
|
/>
|
|
298
424
|
</label>
|
|
299
|
-
<
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
onChange={(v) => patch(i, { tiktok_pixel_id: v })}
|
|
304
|
-
placeholder="CXXXXXXXXXXXXXXXX"
|
|
305
|
-
/>
|
|
306
|
-
</label>
|
|
307
|
-
</div>
|
|
308
|
-
|
|
309
|
-
<div>
|
|
310
|
-
<Label>Extra meta tags (verification codes)</Label>
|
|
311
|
-
<div className="space-y-2">
|
|
312
|
-
{p.extra_meta.map((m, mi) => (
|
|
313
|
-
<div key={mi} className="flex gap-2">
|
|
314
|
-
<input
|
|
315
|
-
value={m.name}
|
|
316
|
-
onChange={(e) =>
|
|
317
|
-
patch(i, {
|
|
318
|
-
extra_meta: p.extra_meta.map((x, xi) =>
|
|
319
|
-
xi === mi ? { ...x, name: e.target.value } : x,
|
|
320
|
-
),
|
|
321
|
-
})
|
|
322
|
-
}
|
|
323
|
-
placeholder="google-site-verification"
|
|
324
|
-
className="w-1/2 border border-ink/20 px-3 py-2 text-sm focus:outline-none focus:border-sea"
|
|
325
|
-
/>
|
|
326
|
-
<input
|
|
327
|
-
value={m.content}
|
|
328
|
-
onChange={(e) =>
|
|
329
|
-
patch(i, {
|
|
330
|
-
extra_meta: p.extra_meta.map((x, xi) =>
|
|
331
|
-
xi === mi ? { ...x, content: e.target.value } : x,
|
|
332
|
-
),
|
|
333
|
-
})
|
|
334
|
-
}
|
|
335
|
-
placeholder="value"
|
|
336
|
-
className="w-1/2 border border-ink/20 px-3 py-2 text-sm focus:outline-none focus:border-sea"
|
|
337
|
-
/>
|
|
338
|
-
<button
|
|
339
|
-
onClick={() =>
|
|
340
|
-
patch(i, {
|
|
341
|
-
extra_meta: p.extra_meta.filter((_, xi) => xi !== mi),
|
|
342
|
-
})
|
|
343
|
-
}
|
|
344
|
-
className="p-2 text-red-500 hover:text-red-700"
|
|
345
|
-
>
|
|
346
|
-
<Trash2 className="size-3.5" />
|
|
347
|
-
</button>
|
|
348
|
-
</div>
|
|
349
|
-
))}
|
|
350
|
-
<button
|
|
351
|
-
onClick={() =>
|
|
352
|
-
patch(i, { extra_meta: [...p.extra_meta, { name: "", content: "" }] })
|
|
353
|
-
}
|
|
354
|
-
className="inline-flex items-center gap-2 border border-ink/20 px-3 py-1.5 text-sm text-ink hover:border-sea"
|
|
355
|
-
>
|
|
356
|
-
<Plus className="size-4" /> Add meta tag
|
|
357
|
-
</button>
|
|
358
|
-
</div>
|
|
425
|
+
<p className="text-xs text-ink/50">
|
|
426
|
+
Tracking IDs above generate their official snippets automatically — you only need
|
|
427
|
+
raw code for anything not covered.
|
|
428
|
+
</p>
|
|
359
429
|
</div>
|
|
360
430
|
</div>
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
<Label>Inside <head></Label>
|
|
365
|
-
<Code
|
|
366
|
-
value={p.head_html}
|
|
367
|
-
onChange={(v) => patch(i, { head_html: v })}
|
|
368
|
-
placeholder="<!-- scripts, meta tags, verification -->"
|
|
369
|
-
/>
|
|
370
|
-
</label>
|
|
371
|
-
<label className="block">
|
|
372
|
-
<Label>After <body> tag</Label>
|
|
373
|
-
<Code
|
|
374
|
-
value={p.body_start_html}
|
|
375
|
-
onChange={(v) => patch(i, { body_start_html: v })}
|
|
376
|
-
placeholder="<!-- noscript pixels, chat widgets -->"
|
|
377
|
-
/>
|
|
378
|
-
</label>
|
|
379
|
-
<label className="block">
|
|
380
|
-
<Label>Before </body> tag (footer)</Label>
|
|
381
|
-
<Code
|
|
382
|
-
value={p.body_end_html}
|
|
383
|
-
onChange={(v) => patch(i, { body_end_html: v })}
|
|
384
|
-
placeholder="<!-- deferred scripts -->"
|
|
385
|
-
/>
|
|
386
|
-
</label>
|
|
387
|
-
<p className="text-xs text-ink/50">
|
|
388
|
-
Tracking IDs above generate their official snippets automatically — you only need
|
|
389
|
-
raw code for anything not covered.
|
|
390
|
-
</p>
|
|
391
|
-
</div>
|
|
392
|
-
</div>
|
|
393
|
-
</section>
|
|
394
|
-
))}
|
|
431
|
+
</section>
|
|
432
|
+
);
|
|
433
|
+
})}
|
|
395
434
|
</div>
|
|
396
435
|
|
|
397
436
|
<button
|
|
398
|
-
onClick={() =>
|
|
437
|
+
onClick={() => setRows((d) => [...d, newRow(blankProfile())])}
|
|
399
438
|
className="mt-8 inline-flex items-center gap-2 border border-ink/20 px-5 py-2.5 text-sm text-ink hover:border-sea"
|
|
400
439
|
>
|
|
401
440
|
<Plus className="size-4" /> New profile
|
|
@@ -31,14 +31,16 @@ export type FormVersionRow = {
|
|
|
31
31
|
|
|
32
32
|
export function VersionHistory({
|
|
33
33
|
formKey,
|
|
34
|
+
editSessionId,
|
|
34
35
|
listVersions,
|
|
35
36
|
restoreVersion,
|
|
36
37
|
renderPreview,
|
|
37
38
|
}: {
|
|
38
39
|
formKey: string;
|
|
40
|
+
editSessionId?: string;
|
|
39
41
|
listVersions: (input: { data: { formKey: string } }) => Promise<FormVersionRow[]>;
|
|
40
42
|
restoreVersion: (input: {
|
|
41
|
-
data: { versionId: string };
|
|
43
|
+
data: { versionId: string; editSessionId?: string };
|
|
42
44
|
}) => Promise<{ carriedFields: string[] }>;
|
|
43
45
|
/** Host renders the preview so the platform stays free of site form UI. */
|
|
44
46
|
renderPreview: (version: FormVersionRow) => React.ReactNode;
|
|
@@ -60,7 +62,7 @@ export function VersionHistory({
|
|
|
60
62
|
if (!window.confirm(`Roll this form back to version ${no}? Submissions are untouched.`)) return;
|
|
61
63
|
setBusy(true);
|
|
62
64
|
try {
|
|
63
|
-
const res = await restore({ data: { versionId: id } });
|
|
65
|
+
const res = await restore({ data: { versionId: id, editSessionId } });
|
|
64
66
|
await qc.invalidateQueries({ queryKey: ["backoffice-forms"] });
|
|
65
67
|
await qc.invalidateQueries({ queryKey: ["form-versions", formKey] });
|
|
66
68
|
toast.success(
|
|
@@ -60,11 +60,20 @@ import type {
|
|
|
60
60
|
} from "../../blocks/types";
|
|
61
61
|
import { INTRO_IMAGE_ASPECTS } from "../../blocks/types";
|
|
62
62
|
import { BLOCK_ICON_NAMES, resolveBlockIcon } from "../../blocks/icons";
|
|
63
|
-
import {
|
|
63
|
+
import {
|
|
64
|
+
CtaHint,
|
|
65
|
+
InspectorSection,
|
|
66
|
+
LocalizedField,
|
|
67
|
+
TextField,
|
|
68
|
+
MediaField,
|
|
69
|
+
MediaMultiField,
|
|
70
|
+
} from "./fields";
|
|
64
71
|
import { RichTextEditor } from "../../host";
|
|
72
|
+
import { useEditLockBlocked } from "../EditLock";
|
|
65
73
|
import { Plus, Trash2, ArrowUp, ArrowDown } from "lucide-react";
|
|
66
74
|
import type { LocalizedString, Locale } from "../../i18n";
|
|
67
75
|
import { SUPPORTED_LOCALES } from "../../i18n";
|
|
76
|
+
import { normalizeRevealMode, type RevealMode } from "../../ui/Reveal";
|
|
68
77
|
|
|
69
78
|
export type BlockPatchProps = {
|
|
70
79
|
block: BlockInstance;
|
|
@@ -684,6 +693,7 @@ function ProseInspector({ block, patch }: BlockPatchProps) {
|
|
|
684
693
|
function RichTextInspector({ block, patch }: BlockPatchProps) {
|
|
685
694
|
const p = (block.props ?? {}) as RichTextProps;
|
|
686
695
|
const update = (next: RichTextProps) => patch(next as unknown as BlockProps);
|
|
696
|
+
const blocked = useEditLockBlocked();
|
|
687
697
|
const [loc, setLoc] = useState<Locale>(SUPPORTED_LOCALES[0]);
|
|
688
698
|
const html = ((p.html ?? {}) as Record<string, string>)[loc] ?? "";
|
|
689
699
|
|
|
@@ -735,6 +745,7 @@ function RichTextInspector({ block, patch }: BlockPatchProps) {
|
|
|
735
745
|
<RichTextEditor
|
|
736
746
|
key={loc}
|
|
737
747
|
value={html}
|
|
748
|
+
readOnly={blocked}
|
|
738
749
|
onChange={(next) =>
|
|
739
750
|
update({
|
|
740
751
|
...p,
|
|
@@ -771,6 +782,14 @@ function RichTextInspector({ block, patch }: BlockPatchProps) {
|
|
|
771
782
|
{ value: "1", label: "One column" },
|
|
772
783
|
{ value: "2", label: "Two columns (desktop)" },
|
|
773
784
|
], (v) => update({ ...p, columns: Number(v) as 1 | 2 }))}
|
|
785
|
+
{select("Appearance", normalizeRevealMode(p.reveal), [
|
|
786
|
+
{ value: "immediate", label: "Immediate" },
|
|
787
|
+
{ value: "fade", label: "Fade in" },
|
|
788
|
+
] as { value: RevealMode; label: string }[], (v) => update({ ...p, reveal: v }))}
|
|
789
|
+
<p className="text-[0.7rem] text-ink/50">
|
|
790
|
+
Fade in reveals the block once, as it scrolls into view. Readers who ask their
|
|
791
|
+
system for reduced motion always see it immediately.
|
|
792
|
+
</p>
|
|
774
793
|
<label className="flex items-center gap-2 text-sm text-ink">
|
|
775
794
|
<input
|
|
776
795
|
type="checkbox"
|
|
@@ -848,6 +867,7 @@ function HeroFullInspector({ block, patch }: BlockPatchProps) {
|
|
|
848
867
|
onChange={(v) => update({ ...p, primaryCta: { ...p.primaryCta, href: v } })}
|
|
849
868
|
placeholder="/boka"
|
|
850
869
|
/>
|
|
870
|
+
<CtaHint label={p.primaryCta?.label} href={p.primaryCta?.href} />
|
|
851
871
|
<LocalizedField
|
|
852
872
|
label="Secondary label"
|
|
853
873
|
value={p.secondaryCta?.label}
|
|
@@ -858,6 +878,7 @@ function HeroFullInspector({ block, patch }: BlockPatchProps) {
|
|
|
858
878
|
value={p.secondaryCta?.href}
|
|
859
879
|
onChange={(v) => update({ ...p, secondaryCta: { ...p.secondaryCta, href: v } })}
|
|
860
880
|
/>
|
|
881
|
+
<CtaHint label={p.secondaryCta?.label} href={p.secondaryCta?.href} />
|
|
861
882
|
</InspectorSection>
|
|
862
883
|
|
|
863
884
|
<InspectorSection title="Background media">
|
|
@@ -4,6 +4,7 @@ import { toast } from "sonner";
|
|
|
4
4
|
import { X, Image as ImageIcon, Upload, Loader2 } from "lucide-react";
|
|
5
5
|
|
|
6
6
|
import type { LocalizedString } from "../../i18n";
|
|
7
|
+
import { hasAnyText } from "../../blocks/optional-content";
|
|
7
8
|
import { SUPPORTED_LOCALES, LOCALE_LABELS, type Locale } from "../../i18n";
|
|
8
9
|
|
|
9
10
|
|
|
@@ -17,6 +18,26 @@ import {
|
|
|
17
18
|
|
|
18
19
|
type LocValue = Partial<LocalizedString> | undefined;
|
|
19
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Explains, next to a pair of call-to-action fields, why a half-filled button
|
|
23
|
+
* does not appear on the page.
|
|
24
|
+
*
|
|
25
|
+
* The renderer requires both a label and a link — half of one is not a button
|
|
26
|
+
* — and an editor who has typed only a link has no other way to find that out.
|
|
27
|
+
*/
|
|
28
|
+
export function CtaHint({ label, href }: { label: LocValue; href?: string | null }) {
|
|
29
|
+
const hasLabel = hasAnyText(label);
|
|
30
|
+
const hasHref = typeof href === "string" && href.trim() !== "";
|
|
31
|
+
const missing = hasLabel && !hasHref ? "link" : hasHref && !hasLabel ? "label" : null;
|
|
32
|
+
return (
|
|
33
|
+
<p className={`text-[0.7rem] ${missing ? "text-amber-700" : "text-ink/50"}`}>
|
|
34
|
+
{missing
|
|
35
|
+
? `Hidden on the page — add a ${missing} to show this button.`
|
|
36
|
+
: "A button appears only when it has both a label and a link."}
|
|
37
|
+
</p>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
20
41
|
export function LocalizedField({
|
|
21
42
|
label,
|
|
22
43
|
value,
|