@prenta/admin 2.7.0 → 2.8.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 (163) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/design-system/tokens.css +17 -0
  3. package/design-system/tokens.json +35 -1
  4. package/design-system/tokens.scss +15 -0
  5. package/design-system/tokens.tailwind.cjs +14 -0
  6. package/dist/AdminRoot.d.ts.map +1 -1
  7. package/dist/AdminRoot.js +2 -2
  8. package/dist/AdminRoot.js.map +1 -1
  9. package/dist/__tests__/lib/batch-wizard.test.d.ts +2 -0
  10. package/dist/__tests__/lib/batch-wizard.test.d.ts.map +1 -0
  11. package/dist/__tests__/lib/batch-wizard.test.js +144 -0
  12. package/dist/__tests__/lib/batch-wizard.test.js.map +1 -0
  13. package/dist/__tests__/lib/job-drawer.test.js +2 -0
  14. package/dist/__tests__/lib/job-drawer.test.js.map +1 -1
  15. package/dist/__tests__/lib/job-view.test.js +100 -3
  16. package/dist/__tests__/lib/job-view.test.js.map +1 -1
  17. package/dist/__tests__/lib/jobs-service.test.js +56 -2
  18. package/dist/__tests__/lib/jobs-service.test.js.map +1 -1
  19. package/dist/__tests__/views/appearance-chrome.render.test.d.ts +2 -0
  20. package/dist/__tests__/views/appearance-chrome.render.test.d.ts.map +1 -0
  21. package/dist/__tests__/views/appearance-chrome.render.test.js +688 -0
  22. package/dist/__tests__/views/appearance-chrome.render.test.js.map +1 -0
  23. package/dist/__tests__/views/header-footer-form.test.d.ts +2 -0
  24. package/dist/__tests__/views/header-footer-form.test.d.ts.map +1 -0
  25. package/dist/__tests__/views/header-footer-form.test.js +266 -0
  26. package/dist/__tests__/views/header-footer-form.test.js.map +1 -0
  27. package/dist/__tests__/views/jobs-drawer.render.test.js +442 -2
  28. package/dist/__tests__/views/jobs-drawer.render.test.js.map +1 -1
  29. package/dist/__tests__/views/jobs-list.render.test.js +7 -5
  30. package/dist/__tests__/views/jobs-list.render.test.js.map +1 -1
  31. package/dist/__tests__/views/jobs-wizard.render.test.d.ts +2 -0
  32. package/dist/__tests__/views/jobs-wizard.render.test.d.ts.map +1 -0
  33. package/dist/__tests__/views/jobs-wizard.render.test.js +588 -0
  34. package/dist/__tests__/views/jobs-wizard.render.test.js.map +1 -0
  35. package/dist/lib/api.d.ts +2 -0
  36. package/dist/lib/api.d.ts.map +1 -1
  37. package/dist/lib/api.js +8 -1
  38. package/dist/lib/api.js.map +1 -1
  39. package/dist/lib/batch-wizard.d.ts +84 -0
  40. package/dist/lib/batch-wizard.d.ts.map +1 -0
  41. package/dist/lib/batch-wizard.js +122 -0
  42. package/dist/lib/batch-wizard.js.map +1 -0
  43. package/dist/lib/chrome-service.d.ts +69 -0
  44. package/dist/lib/chrome-service.d.ts.map +1 -0
  45. package/dist/lib/chrome-service.js +108 -0
  46. package/dist/lib/chrome-service.js.map +1 -0
  47. package/dist/lib/job-view.d.ts +43 -0
  48. package/dist/lib/job-view.d.ts.map +1 -1
  49. package/dist/lib/job-view.js +57 -1
  50. package/dist/lib/job-view.js.map +1 -1
  51. package/dist/lib/jobs-service.d.ts +19 -1
  52. package/dist/lib/jobs-service.d.ts.map +1 -1
  53. package/dist/lib/jobs-service.js +41 -0
  54. package/dist/lib/jobs-service.js.map +1 -1
  55. package/dist/prenta-admin.css +1 -1
  56. package/dist/views/AppearanceChrome.d.ts +22 -4
  57. package/dist/views/AppearanceChrome.d.ts.map +1 -1
  58. package/dist/views/AppearanceChrome.js +451 -15
  59. package/dist/views/AppearanceChrome.js.map +1 -1
  60. package/dist/views/Jobs/CancelJobDialog.d.ts +7 -1
  61. package/dist/views/Jobs/CancelJobDialog.d.ts.map +1 -1
  62. package/dist/views/Jobs/CancelJobDialog.js +13 -3
  63. package/dist/views/Jobs/CancelJobDialog.js.map +1 -1
  64. package/dist/views/Jobs/DrawerReview.d.ts +7 -0
  65. package/dist/views/Jobs/DrawerReview.d.ts.map +1 -1
  66. package/dist/views/Jobs/DrawerReview.js +1 -1
  67. package/dist/views/Jobs/DrawerReview.js.map +1 -1
  68. package/dist/views/Jobs/JobDrawer.d.ts +23 -3
  69. package/dist/views/Jobs/JobDrawer.d.ts.map +1 -1
  70. package/dist/views/Jobs/JobDrawer.js +30 -5
  71. package/dist/views/Jobs/JobDrawer.js.map +1 -1
  72. package/dist/views/Jobs/JobsTable.d.ts +4 -2
  73. package/dist/views/Jobs/JobsTable.d.ts.map +1 -1
  74. package/dist/views/Jobs/JobsTable.js +6 -5
  75. package/dist/views/Jobs/JobsTable.js.map +1 -1
  76. package/dist/views/Jobs/NewBatchJob.d.ts +34 -0
  77. package/dist/views/Jobs/NewBatchJob.d.ts.map +1 -0
  78. package/dist/views/Jobs/NewBatchJob.js +282 -0
  79. package/dist/views/Jobs/NewBatchJob.js.map +1 -0
  80. package/dist/views/Jobs/WorkerStrip.d.ts +3 -1
  81. package/dist/views/Jobs/WorkerStrip.d.ts.map +1 -1
  82. package/dist/views/Jobs/WorkerStrip.js +2 -2
  83. package/dist/views/Jobs/WorkerStrip.js.map +1 -1
  84. package/dist/views/Jobs/focus.d.ts +6 -0
  85. package/dist/views/Jobs/focus.d.ts.map +1 -1
  86. package/dist/views/Jobs/focus.js +10 -0
  87. package/dist/views/Jobs/focus.js.map +1 -1
  88. package/dist/views/Jobs.d.ts +12 -3
  89. package/dist/views/Jobs.d.ts.map +1 -1
  90. package/dist/views/Jobs.js +109 -31
  91. package/dist/views/Jobs.js.map +1 -1
  92. package/dist/views/header-footer/AnnouncementTab.d.ts +15 -0
  93. package/dist/views/header-footer/AnnouncementTab.d.ts.map +1 -0
  94. package/dist/views/header-footer/AnnouncementTab.js +43 -0
  95. package/dist/views/header-footer/AnnouncementTab.js.map +1 -0
  96. package/dist/views/header-footer/ChromeField.d.ts +25 -0
  97. package/dist/views/header-footer/ChromeField.d.ts.map +1 -0
  98. package/dist/views/header-footer/ChromeField.js +99 -0
  99. package/dist/views/header-footer/ChromeField.js.map +1 -0
  100. package/dist/views/header-footer/ChromePreview.d.ts +31 -0
  101. package/dist/views/header-footer/ChromePreview.d.ts.map +1 -0
  102. package/dist/views/header-footer/ChromePreview.js +195 -0
  103. package/dist/views/header-footer/ChromePreview.js.map +1 -0
  104. package/dist/views/header-footer/DeveloperTab.d.ts +17 -0
  105. package/dist/views/header-footer/DeveloperTab.d.ts.map +1 -0
  106. package/dist/views/header-footer/DeveloperTab.js +98 -0
  107. package/dist/views/header-footer/DeveloperTab.js.map +1 -0
  108. package/dist/views/header-footer/RegionTab.d.ts +27 -0
  109. package/dist/views/header-footer/RegionTab.d.ts.map +1 -0
  110. package/dist/views/header-footer/RegionTab.js +80 -0
  111. package/dist/views/header-footer/RegionTab.js.map +1 -0
  112. package/dist/views/header-footer/chrome-form.d.ts +137 -0
  113. package/dist/views/header-footer/chrome-form.d.ts.map +1 -0
  114. package/dist/views/header-footer/chrome-form.js +518 -0
  115. package/dist/views/header-footer/chrome-form.js.map +1 -0
  116. package/dist/views/header-footer/code-samples.d.ts +30 -0
  117. package/dist/views/header-footer/code-samples.d.ts.map +1 -0
  118. package/dist/views/header-footer/code-samples.js +127 -0
  119. package/dist/views/header-footer/code-samples.js.map +1 -0
  120. package/dist/views/header-footer/controls.d.ts +57 -0
  121. package/dist/views/header-footer/controls.d.ts.map +1 -0
  122. package/dist/views/header-footer/controls.js +43 -0
  123. package/dist/views/header-footer/controls.js.map +1 -0
  124. package/package.json +3 -3
  125. package/src/AdminRoot.tsx +16 -2
  126. package/src/__tests__/lib/batch-wizard.test.ts +199 -0
  127. package/src/__tests__/lib/job-drawer.test.ts +2 -0
  128. package/src/__tests__/lib/job-view.test.ts +123 -2
  129. package/src/__tests__/lib/jobs-service.test.ts +72 -3
  130. package/src/__tests__/views/appearance-chrome.render.test.tsx +818 -0
  131. package/src/__tests__/views/header-footer-form.test.ts +333 -0
  132. package/src/__tests__/views/jobs-drawer.render.test.tsx +508 -2
  133. package/src/__tests__/views/jobs-list.render.test.tsx +8 -5
  134. package/src/__tests__/views/jobs-wizard.render.test.tsx +707 -0
  135. package/src/lib/api.ts +12 -1
  136. package/src/lib/batch-wizard.ts +188 -0
  137. package/src/lib/chrome-service.ts +200 -0
  138. package/src/lib/job-view.ts +95 -1
  139. package/src/lib/jobs-service.ts +56 -1
  140. package/src/styles/prenta-tokens.css +17 -0
  141. package/src/styles/theme.css +15 -0
  142. package/src/views/AppearanceChrome.tsx +704 -46
  143. package/src/views/Jobs/CancelJobDialog.tsx +20 -2
  144. package/src/views/Jobs/DrawerReview.tsx +1 -1
  145. package/src/views/Jobs/JobDrawer.tsx +83 -3
  146. package/src/views/Jobs/JobsTable.tsx +11 -4
  147. package/src/views/Jobs/NewBatchJob.tsx +857 -0
  148. package/src/views/Jobs/WorkerStrip.tsx +16 -11
  149. package/src/views/Jobs/focus.ts +11 -0
  150. package/src/views/Jobs.tsx +163 -49
  151. package/src/views/header-footer/AnnouncementTab.tsx +231 -0
  152. package/src/views/header-footer/ChromeField.tsx +358 -0
  153. package/src/views/header-footer/ChromePreview.tsx +497 -0
  154. package/src/views/header-footer/DeveloperTab.tsx +331 -0
  155. package/src/views/header-footer/RegionTab.tsx +398 -0
  156. package/src/views/header-footer/chrome-form.ts +603 -0
  157. package/src/views/header-footer/code-samples.ts +146 -0
  158. package/src/views/header-footer/controls.tsx +166 -0
  159. package/dist/views/Jobs/placeholders.d.ts +0 -6
  160. package/dist/views/Jobs/placeholders.d.ts.map +0 -1
  161. package/dist/views/Jobs/placeholders.js +0 -20
  162. package/dist/views/Jobs/placeholders.js.map +0 -1
  163. package/src/views/Jobs/placeholders.tsx +0 -66
@@ -0,0 +1,497 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react'
4
+ import type { FieldDef, RegionSchema } from '@prenta/core'
5
+
6
+ import type { ChromeMenuItem, ChromeMenuOption } from '../../lib/chrome-service.js'
7
+ import {
8
+ fieldEntries,
9
+ type AnnouncementForm,
10
+ type PreviewChrome,
11
+ type RegionForm,
12
+ } from './chrome-form.js'
13
+ import { CARD } from './controls.js'
14
+
15
+ type Rec = Record<string, unknown>
16
+ type Viewport = 'desk' | 'mob'
17
+
18
+ function rec(value: unknown): Rec {
19
+ return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Rec) : {}
20
+ }
21
+
22
+ function text(value: unknown): string {
23
+ return typeof value === 'string' ? value : ''
24
+ }
25
+
26
+ /** The message a Preview URL page receives, and the one it sends back when it's ready. */
27
+ const PREVIEW_MESSAGE = 'prenta:chrome-preview'
28
+ const PREVIEW_READY_MESSAGE = 'prenta:chrome-preview-ready'
29
+
30
+ function randomToken(): string {
31
+ const bytes = new Uint8Array(12)
32
+ globalThis.crypto?.getRandomValues?.(bytes)
33
+ return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('')
34
+ }
35
+
36
+ /** The Preview URL's origin, or null when it isn't an http(s) URL. */
37
+ function previewOrigin(url: string | null): string | null {
38
+ if (!url || !/^https?:\/\//i.test(url)) return null
39
+ try {
40
+ return new URL(url).origin
41
+ } catch {
42
+ return null
43
+ }
44
+ }
45
+
46
+ /**
47
+ * The frame's address: the Preview URL with `draft={token}` in its query.
48
+ * Set through `URL`, so a fragment stays last and a `draft` already in the
49
+ * Preview URL is replaced rather than read first.
50
+ */
51
+ function previewFrameSrc(url: string, token: string): string {
52
+ const src = new URL(url)
53
+ src.searchParams.set('draft', token)
54
+ return src.toString()
55
+ }
56
+
57
+ // ─── The built-in mock (appearance-hf.jsx `Preview`) ────────────────────
58
+ //
59
+ // The mock draws the kit's example site from whatever the schema declares,
60
+ // picking fields by type (the region's menu, its call-to-action link, a
61
+ // `tel` text…), so a field the mock can't place is simply not drawn.
62
+
63
+ interface Found<T> {
64
+ key: string
65
+ def: T
66
+ value: unknown
67
+ }
68
+
69
+ function entries(schema: RegionSchema | undefined, form: RegionForm | undefined) {
70
+ if (!schema || !form) return []
71
+ return fieldEntries(schema).map(([key, def]) => ({ key, def, value: form.fields[key] }))
72
+ }
73
+
74
+ /** The first field of a type that passes `test`, in schema order. */
75
+ function find<T extends FieldDef['type']>(
76
+ list: Array<Found<FieldDef>>,
77
+ type: T,
78
+ test: (def: Extract<FieldDef, { type: T }>, key: string) => boolean = () => true,
79
+ ): Found<Extract<FieldDef, { type: T }>> | undefined {
80
+ const item = list.find(
81
+ (entry) =>
82
+ entry.def.type === type && test(entry.def as Extract<FieldDef, { type: T }>, entry.key),
83
+ )
84
+ return item as Found<Extract<FieldDef, { type: T }>> | undefined
85
+ }
86
+
87
+ /** A toggled value's content when it's on; a plain value as it is. */
88
+ function onValue(def: { optional?: boolean }, value: unknown): unknown {
89
+ if (!def.optional) return value
90
+ const toggle = rec(value)
91
+ return toggle.on === true ? toggle : null
92
+ }
93
+
94
+ function menuOf(menus: ChromeMenuOption[], slug: unknown): ChromeMenuOption | undefined {
95
+ return typeof slug === 'string' && slug ? menus.find((menu) => menu.slug === slug) : undefined
96
+ }
97
+
98
+ function topItems(menu: ChromeMenuOption | undefined): ChromeMenuItem[] {
99
+ return menu ? menu.items : []
100
+ }
101
+
102
+ const TONE_BACKGROUND: Record<string, string> = {
103
+ brand: 'var(--acc)',
104
+ dark: 'var(--site-txt)',
105
+ alert: 'var(--site-alert)',
106
+ }
107
+
108
+ function MockSite({
109
+ header,
110
+ footer,
111
+ announcement,
112
+ menus,
113
+ siteTitle,
114
+ viewport,
115
+ year,
116
+ }: {
117
+ header?: { schema: RegionSchema; form: RegionForm }
118
+ footer?: { schema: RegionSchema; form: RegionForm }
119
+ announcement: AnnouncementForm | null
120
+ menus: ChromeMenuOption[]
121
+ siteTitle: string
122
+ viewport: Viewport
123
+ year: string
124
+ }) {
125
+ const mob = viewport === 'mob'
126
+ const h = entries(header?.schema, header?.form)
127
+ const f = entries(footer?.schema, footer?.form)
128
+
129
+ // Header
130
+ const mainMenu = find(h, 'menu', (def) => !def.list && !def.optional)
131
+ const topMenu = find(
132
+ h,
133
+ 'menu',
134
+ (def, key) => !def.list && !!def.optional && key !== mainMenu?.key,
135
+ )
136
+ const cta = find(h, 'link')
137
+ const phone = find(h, 'text', (def) => def.format === 'tel')
138
+ const search = find(h, 'boolean', (_def, key) => key === 'search')
139
+ const headerVariant = header?.form.variant ?? ''
140
+ const minimal = headerVariant === 'minimal'
141
+ const centered = /center/.test(headerVariant)
142
+ const nav = topItems(
143
+ menuOf(menus, mainMenu ? text(mainMenu.value) || mainMenu.def.default : undefined),
144
+ ).filter((item) => !item.button)
145
+ const top = topMenu ? onValue(topMenu.def, topMenu.value) : null
146
+ const topLinks = top ? topItems(menuOf(menus, rec(top).value)) : []
147
+ const ctaValue = cta ? rec(onValue(cta.def, cta.value)) : {}
148
+ const ctaLabel = text(ctaValue.label)
149
+ const phoneValue = phone
150
+ ? phone.def.optional
151
+ ? text(rec(onValue(phone.def, phone.value)).value)
152
+ : text(phone.value)
153
+ : ''
154
+
155
+ // Footer
156
+ const tone = find(f, 'select', (def) => def.options.includes('light'))
157
+ const light = tone ? tone.value === 'light' : false
158
+ const about = find(f, 'textarea')
159
+ const cols = find(f, 'menu', (def) => !!def.list)
160
+ const copy = find(f, 'text', (def) => !!def.tokens?.includes('{year}'))
161
+ const email = find(f, 'text', (def) => def.format === 'email')
162
+ const footPhone = find(f, 'text', (def) => def.format === 'tel')
163
+ const address = find(
164
+ f,
165
+ 'text',
166
+ (def, key) => !def.format && !def.tokens?.length && key !== copy?.key,
167
+ )
168
+ const news = find(
169
+ f,
170
+ 'boolean',
171
+ (def, key) => key === 'news' || /newsletter/i.test(def.label ?? ''),
172
+ )
173
+ const social = find(f, 'social')
174
+ const legal = find(f, 'menu', (def) => !def.list)
175
+ const footerVariant = footer?.form.variant ?? ''
176
+ const fMinimal = footerVariant === 'minimal'
177
+ const compact = footerVariant === 'compact'
178
+ const colMenus = (Array.isArray(cols?.value) ? (cols.value as unknown[]) : [])
179
+ .map((slug) => menuOf(menus, slug))
180
+ .filter((menu): menu is ChromeMenuOption => !!menu)
181
+ const shownCols = compact ? colMenus.slice(0, 1) : colMenus
182
+ const legalValue = legal ? onValue(legal.def, legal.value) : null
183
+ const legalLinks = legal
184
+ ? topItems(menuOf(menus, legal.def.optional ? rec(legalValue).value : legalValue))
185
+ : []
186
+ const textOf = (item: Found<Extract<FieldDef, { type: 'text' }>> | undefined) =>
187
+ item
188
+ ? item.def.optional
189
+ ? text(rec(onValue(item.def, item.value)).value)
190
+ : text(item.value)
191
+ : ''
192
+ const copyText = (textOf(copy) || `© {year} ${siteTitle}`).split('{year}').join(year)
193
+ const contact = [textOf(email), textOf(footPhone)].filter(Boolean).join(' · ')
194
+ const socialLinks = Array.isArray(social?.value) ? (social.value as unknown[]).map(rec) : []
195
+
196
+ const toneColor = TONE_BACKGROUND[announcement?.tone ?? 'brand'] ?? TONE_BACKGROUND.brand
197
+ const footMuted = light ? 'text-site-sub' : 'text-site-foot-txt'
198
+ const footStrong = light ? 'text-site-txt' : 'text-site-bg'
199
+
200
+ return (
201
+ <div
202
+ data-preview-site=""
203
+ data-viewport={viewport}
204
+ className={`border-bdr bg-site-bg text-site-txt rounded-10 w-full overflow-hidden border text-[13px] leading-[normal] transition-[max-width] duration-200 ${
205
+ mob ? 'max-w-[360px]' : 'max-w-[860px]'
206
+ }`}
207
+ >
208
+ {announcement?.on ? (
209
+ <div
210
+ data-preview-announcement=""
211
+ className="flex items-center justify-center gap-2.5 px-3 py-[7px] text-center text-[12px] font-semibold text-white"
212
+ style={{ background: toneColor }}
213
+ >
214
+ <span>{announcement.text}</span>
215
+ {announcement.linkLabel ? <u className="font-bold">{announcement.linkLabel} →</u> : null}
216
+ {announcement.dismissible ? <span className="ml-1.5 opacity-75">✕</span> : null}
217
+ </div>
218
+ ) : null}
219
+ {header ? (
220
+ <>
221
+ {topLinks.length > 0 && !mob && !minimal ? (
222
+ <div className="bg-site-alt text-site-sub flex justify-end gap-4 px-[18px] py-[5px] text-[11.5px]">
223
+ {topLinks.map((item) => (
224
+ <span key={item.id}>{item.label}</span>
225
+ ))}
226
+ </div>
227
+ ) : null}
228
+ <div
229
+ data-preview-header={headerVariant}
230
+ className={`border-site-bdr flex items-center gap-5 border-b px-[18px] py-3.5 ${
231
+ centered ? 'flex-col gap-2.5' : ''
232
+ }`}
233
+ >
234
+ <span className="text-[16px] font-extrabold tracking-[-0.4px]">{siteTitle}</span>
235
+ {mob && !minimal ? (
236
+ <span
237
+ aria-hidden
238
+ className="border-site-txt after:border-site-txt relative ml-auto h-3.5 w-[22px] border-y-2 after:absolute after:inset-x-0 after:top-1 after:border-t-2 after:content-['']"
239
+ />
240
+ ) : (
241
+ <>
242
+ {minimal ? (
243
+ <span className="flex-1" />
244
+ ) : (
245
+ <nav
246
+ className={`flex flex-1 flex-wrap gap-4 ${centered ? 'justify-center' : 'justify-end'}`}
247
+ >
248
+ {nav.map((item) => (
249
+ <span key={item.id} className="whitespace-nowrap">
250
+ {item.label}
251
+ </span>
252
+ ))}
253
+ </nav>
254
+ )}
255
+ <span className="flex items-center gap-2.5 whitespace-nowrap">
256
+ {search?.value === true && !minimal ? <span>⌕</span> : null}
257
+ {phoneValue && !mob ? <span>{phoneValue}</span> : null}
258
+ {ctaLabel ? (
259
+ <span className="bg-acc rounded-7 px-3 py-[7px] text-[12.5px] font-semibold text-white">
260
+ {ctaLabel}
261
+ </span>
262
+ ) : null}
263
+ </span>
264
+ </>
265
+ )}
266
+ </div>
267
+ </>
268
+ ) : null}
269
+ <div
270
+ className={`text-site-muted flex items-center justify-center bg-[repeating-linear-gradient(135deg,var(--site-alt)_0_10px,var(--site-alt2)_10px_20px)] text-[12px] ${
271
+ mob ? 'h-20' : 'h-[120px]'
272
+ }`}
273
+ >
274
+ Page content
275
+ </div>
276
+ {footer ? (
277
+ <div
278
+ data-preview-footer={footerVariant}
279
+ className={`px-[18px] pt-5 pb-3.5 text-[12px] ${light ? 'bg-site-alt text-site-sub' : 'bg-site-txt text-site-foot-txt'}`}
280
+ >
281
+ {!fMinimal ? (
282
+ <div
283
+ className={`grid gap-[18px] ${
284
+ compact
285
+ ? mob
286
+ ? 'grid-cols-1'
287
+ : 'grid-cols-[1.3fr_1fr]'
288
+ : mob
289
+ ? 'grid-cols-2'
290
+ : 'grid-cols-[1.3fr_repeat(3,1fr)]'
291
+ }`}
292
+ >
293
+ <div>
294
+ <div
295
+ className={`mb-1.5 text-[15px] font-extrabold tracking-[-0.3px] ${footStrong}`}
296
+ >
297
+ {siteTitle}
298
+ </div>
299
+ {about ? <div>{text(about.value)}</div> : null}
300
+ {!compact ? (
301
+ <>
302
+ {textOf(address) ? <span className="mt-1 block">{textOf(address)}</span> : null}
303
+ {contact ? <span className="mt-1 block">{contact}</span> : null}
304
+ </>
305
+ ) : null}
306
+ {news?.value === true ? (
307
+ <div className="mt-2 flex gap-1.5">
308
+ <i
309
+ className={`rounded-6 h-[26px] flex-1 border ${
310
+ light ? 'border-site-bdr bg-site-bg' : 'border-white/15 bg-white/8'
311
+ }`}
312
+ />
313
+ <em className="bg-acc rounded-6 px-[9px] py-1 font-semibold text-white not-italic">
314
+ Subscribe
315
+ </em>
316
+ </div>
317
+ ) : null}
318
+ </div>
319
+ {shownCols.map((menu) => (
320
+ <div key={menu.slug}>
321
+ <b className={`mb-1.5 block text-[12px] ${footStrong}`}>
322
+ {menu.name.replace(/^Footer · /, '')}
323
+ </b>
324
+ {menu.items.map((item) => (
325
+ <span key={item.id} className="mt-1 block">
326
+ {item.label}
327
+ </span>
328
+ ))}
329
+ </div>
330
+ ))}
331
+ </div>
332
+ ) : null}
333
+ <div
334
+ className={`flex flex-wrap gap-3 text-[11px] ${
335
+ fMinimal
336
+ ? ''
337
+ : `mt-4 border-t pt-2.5 ${light ? 'border-site-bdr' : 'border-white/12'}`
338
+ } ${footMuted}`}
339
+ >
340
+ <span>{copyText}</span>
341
+ {legalLinks.map((item) => (
342
+ <span key={item.id}>{item.label}</span>
343
+ ))}
344
+ <span className="ml-auto flex gap-2">
345
+ {socialLinks.map((link, index) => (
346
+ <i
347
+ key={index}
348
+ title={text(link.platform)}
349
+ className={`block h-[18px] w-[18px] rounded-full ${light ? 'bg-site-chip' : 'bg-white/15'}`}
350
+ />
351
+ ))}
352
+ </span>
353
+ </div>
354
+ </div>
355
+ ) : null}
356
+ </div>
357
+ )
358
+ }
359
+
360
+ // ─── The card ───────────────────────────────────────────────────────────
361
+
362
+ export interface ChromePreviewProps {
363
+ header?: { schema: RegionSchema; form: RegionForm }
364
+ footer?: { schema: RegionSchema; form: RegionForm }
365
+ announcement: AnnouncementForm | null
366
+ menus: ChromeMenuOption[]
367
+ siteTitle: string
368
+ /** Settings → Preview URL. Set, it renders the site's own components. */
369
+ previewUrl: string | null
370
+ /** The draft in the `/public/chrome` shape, sent to the Preview URL page. */
371
+ draft: PreviewChrome
372
+ }
373
+
374
+ /**
375
+ * The preview card (`.hf-pv`): a bar with the Desktop/Mobile switch over a
376
+ * stage that shows the site's Preview URL page, or the built-in mock.
377
+ *
378
+ * The Preview URL page loads as `{previewUrl}?draft={token}`. It gets the
379
+ * draft as `{ type: 'prenta:chrome-preview', token, chrome }`, posted to its
380
+ * origin only, when it loads, whenever the draft changes, and again whenever
381
+ * it posts `{ type: 'prenta:chrome-preview-ready', token }` to its parent.
382
+ */
383
+ export function ChromePreview({
384
+ header,
385
+ footer,
386
+ announcement,
387
+ menus,
388
+ siteTitle,
389
+ previewUrl,
390
+ draft,
391
+ }: ChromePreviewProps) {
392
+ const [viewport, setViewport] = useState<Viewport>('desk')
393
+ const origin = previewOrigin(previewUrl)
394
+ const token = useMemo(() => randomToken(), [])
395
+ const frame = useRef<HTMLIFrameElement | null>(null)
396
+ const src = origin && previewUrl ? previewFrameSrc(previewUrl, token) : null
397
+
398
+ const send = useCallback(() => {
399
+ const target = frame.current?.contentWindow
400
+ if (!target || !origin) return
401
+ target.postMessage({ type: PREVIEW_MESSAGE, token, chrome: draft }, origin)
402
+ }, [draft, origin, token])
403
+
404
+ useEffect(() => {
405
+ send()
406
+ }, [send])
407
+
408
+ useEffect(() => {
409
+ if (!origin) return
410
+ function onMessage(event: MessageEvent) {
411
+ if (event.origin !== origin || event.source !== frame.current?.contentWindow) return
412
+ const data = rec(event.data)
413
+ if (data.type === PREVIEW_READY_MESSAGE && data.token === token) send()
414
+ }
415
+ window.addEventListener('message', onMessage)
416
+ return () => window.removeEventListener('message', onMessage)
417
+ }, [origin, send, token])
418
+
419
+ const options: Array<[Viewport, string]> = [
420
+ ['desk', 'Desktop'],
421
+ ['mob', 'Mobile'],
422
+ ]
423
+ function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {
424
+ if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key)) return
425
+ event.preventDefault()
426
+ const next = viewport === 'desk' ? 'mob' : 'desk'
427
+ setViewport(next)
428
+ event.currentTarget.querySelector<HTMLButtonElement>(`[data-viewport="${next}"]`)?.focus()
429
+ }
430
+
431
+ let host = ''
432
+ if (origin) host = new URL(origin).host
433
+
434
+ return (
435
+ <section aria-label="Preview" className={`${CARD} sticky top-3 [@media(width<=1100px)]:static`}>
436
+ <div className="border-bdr text-sub flex flex-wrap items-center gap-2 border-b px-3 py-[9px] text-[12px] leading-[normal] font-semibold">
437
+ Preview
438
+ <span className="text-faint font-mono text-[11px] font-medium">
439
+ {origin
440
+ ? `${host} · draft values`
441
+ : 'built-in mock · set a Preview URL to render your components'}
442
+ </span>
443
+ <div
444
+ role="radiogroup"
445
+ aria-label="Preview width"
446
+ onKeyDown={onKeyDown}
447
+ className="rounded-8 border-bdr bg-bg ml-auto flex gap-0.5 border p-0.5"
448
+ >
449
+ {options.map(([id, label]) => {
450
+ const on = viewport === id
451
+ return (
452
+ <button
453
+ key={id}
454
+ type="button"
455
+ role="radio"
456
+ aria-checked={on}
457
+ tabIndex={on ? 0 : -1}
458
+ data-viewport={id}
459
+ onClick={() => setViewport(id)}
460
+ className={`rounded-6 cursor-pointer border-0 px-[9px] py-[3px] text-[12px] leading-[normal] font-semibold ${
461
+ on ? 'bg-card text-txt shadow-shd' : 'text-sub bg-transparent'
462
+ }`}
463
+ >
464
+ {label}
465
+ </button>
466
+ )
467
+ })}
468
+ </div>
469
+ </div>
470
+ <div className="rounded-b-12 bg-bg flex justify-center p-[18px]">
471
+ {src ? (
472
+ <iframe
473
+ ref={frame}
474
+ src={src}
475
+ title="Site preview with draft values"
476
+ onLoad={send}
477
+ sandbox="allow-scripts allow-same-origin"
478
+ referrerPolicy="no-referrer"
479
+ className={`border-bdr bg-site-bg rounded-10 block h-[560px] w-full border transition-[max-width] duration-200 ${
480
+ viewport === 'mob' ? 'max-w-[360px]' : 'max-w-[860px]'
481
+ }`}
482
+ />
483
+ ) : (
484
+ <MockSite
485
+ header={header}
486
+ footer={footer}
487
+ announcement={announcement}
488
+ menus={menus}
489
+ siteTitle={siteTitle}
490
+ viewport={viewport}
491
+ year={String(new Date().getFullYear())}
492
+ />
493
+ )}
494
+ </div>
495
+ </section>
496
+ )
497
+ }