@actuate-media/cms-admin 0.32.0 → 0.33.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 (28) hide show
  1. package/dist/__tests__/views/seo-settings.render.test.d.ts +2 -0
  2. package/dist/__tests__/views/seo-settings.render.test.d.ts.map +1 -0
  3. package/dist/__tests__/views/seo-settings.render.test.js +152 -0
  4. package/dist/__tests__/views/seo-settings.render.test.js.map +1 -0
  5. package/dist/__tests__/views/settings-save-footer.render.test.js +16 -9
  6. package/dist/__tests__/views/settings-save-footer.render.test.js.map +1 -1
  7. package/dist/actuate-admin.css +1 -1
  8. package/dist/components/MediaPickerModal.d.ts +15 -1
  9. package/dist/components/MediaPickerModal.d.ts.map +1 -1
  10. package/dist/components/MediaPickerModal.js +17 -1
  11. package/dist/components/MediaPickerModal.js.map +1 -1
  12. package/dist/views/Settings.js +2 -2
  13. package/dist/views/Settings.js.map +1 -1
  14. package/dist/views/settings/SeoSettingsTab.d.ts +7 -0
  15. package/dist/views/settings/SeoSettingsTab.d.ts.map +1 -0
  16. package/dist/views/settings/SeoSettingsTab.js +126 -0
  17. package/dist/views/settings/SeoSettingsTab.js.map +1 -0
  18. package/dist/views/settings/useSeoSettings.d.ts +79 -0
  19. package/dist/views/settings/useSeoSettings.d.ts.map +1 -0
  20. package/dist/views/settings/useSeoSettings.js +278 -0
  21. package/dist/views/settings/useSeoSettings.js.map +1 -0
  22. package/package.json +2 -2
  23. package/src/__tests__/views/seo-settings.render.test.tsx +219 -0
  24. package/src/__tests__/views/settings-save-footer.render.test.tsx +16 -9
  25. package/src/components/MediaPickerModal.tsx +39 -2
  26. package/src/views/Settings.tsx +2 -2
  27. package/src/views/settings/SeoSettingsTab.tsx +658 -0
  28. package/src/views/settings/useSeoSettings.ts +374 -0
@@ -0,0 +1,658 @@
1
+ 'use client'
2
+
3
+ import { useId, useState } from 'react'
4
+ import {
5
+ AlertTriangle,
6
+ CheckCircle2,
7
+ ExternalLink,
8
+ ImageIcon,
9
+ Info,
10
+ Loader2,
11
+ RefreshCw,
12
+ Search,
13
+ Upload,
14
+ X,
15
+ } from 'lucide-react'
16
+ import { MediaPickerModal } from '../../components/MediaPickerModal.js'
17
+ import { SEOConfigPanel } from '../../components/SEOConfigPanel.js'
18
+ import {
19
+ ConfirmDangerousSettingDialog,
20
+ SettingsSaveBar,
21
+ SettingsSourceBadge,
22
+ SettingsToggleRow,
23
+ SettingsValidationSummary,
24
+ } from './components.js'
25
+ import {
26
+ META_DESCRIPTION_MAX,
27
+ META_DESCRIPTION_MIN,
28
+ useSeoSettings,
29
+ validateTitleTemplateClient,
30
+ type SeoSettingsForm,
31
+ type UseSeoSettings,
32
+ } from './useSeoSettings.js'
33
+
34
+ const INPUT_CLASS =
35
+ 'w-full rounded-md border border-border bg-input-background px-3 py-2 text-base text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-60'
36
+ const LABEL_CLASS = 'mb-1 flex items-center gap-2 text-sm font-medium text-foreground'
37
+
38
+ const SUPPORTED_TOKENS = [
39
+ '%page%',
40
+ '%site%',
41
+ '%type%',
42
+ '%separator%',
43
+ '%author%',
44
+ '%date%',
45
+ '%category%',
46
+ '%tag%',
47
+ ]
48
+
49
+ /** Sample data used to render a live title preview. */
50
+ function previewTitle(template: string, siteName: string): string {
51
+ const tpl = template.trim() === '' ? '%page% — %site%' : template
52
+ const vars: Record<string, string> = {
53
+ page: 'Sample Page Title',
54
+ site: siteName || 'Your Site',
55
+ type: 'Page',
56
+ separator: '|',
57
+ author: 'Jane Doe',
58
+ date: new Date().toISOString().slice(0, 10),
59
+ category: 'News',
60
+ tag: 'guide',
61
+ }
62
+ return tpl
63
+ .replace(/%(\w+)%/g, (_, k: string) => vars[k] ?? '')
64
+ .replace(/\s+/g, ' ')
65
+ .replace(/^[\s\-–—|·•]+/, '')
66
+ .replace(/[\s\-–—|·•]+$/, '')
67
+ .trim()
68
+ }
69
+
70
+ export interface SeoSettingsTabProps {
71
+ onNavigate?: (path: string) => void
72
+ /** Current user's role. Only ADMIN can persist changes. */
73
+ role?: string
74
+ }
75
+
76
+ export function SeoSettingsTab({ onNavigate, role }: SeoSettingsTabProps) {
77
+ const canEdit = role === 'ADMIN'
78
+ const seo = useSeoSettings(canEdit)
79
+
80
+ if (seo.loading) {
81
+ return (
82
+ <div className="space-y-4" aria-busy="true" aria-live="polite">
83
+ <span className="sr-only">Loading SEO settings…</span>
84
+ {[0, 1, 2, 3].map((i) => (
85
+ <div key={i} className="border-border bg-card rounded-lg border p-4">
86
+ <div className="bg-muted mb-4 h-4 w-40 animate-pulse rounded" />
87
+ <div className="space-y-3">
88
+ <div className="bg-muted h-9 w-full animate-pulse rounded" />
89
+ <div className="bg-muted h-9 w-full animate-pulse rounded" />
90
+ </div>
91
+ </div>
92
+ ))}
93
+ </div>
94
+ )
95
+ }
96
+
97
+ return (
98
+ <div className="space-y-4">
99
+ <SeoWorkspaceCallout onNavigate={onNavigate} />
100
+
101
+ {seo.loadError && (
102
+ <div
103
+ role="alert"
104
+ className="border-destructive/30 bg-destructive/10 text-destructive flex items-center gap-3 rounded-lg border p-3 text-sm"
105
+ >
106
+ <AlertTriangle size={16} className="shrink-0" aria-hidden="true" />
107
+ <span className="flex-1">{seo.loadError}</span>
108
+ <button
109
+ type="button"
110
+ onClick={seo.refetch}
111
+ className="border-destructive/40 rounded-md border px-3 py-1 transition-colors hover:opacity-80"
112
+ >
113
+ Retry
114
+ </button>
115
+ </div>
116
+ )}
117
+
118
+ {!canEdit && (
119
+ <div className="border-border bg-muted text-muted-foreground flex items-center gap-2 rounded-lg border p-3 text-sm">
120
+ <Info size={16} className="shrink-0" aria-hidden="true" />
121
+ You have read-only access to SEO defaults. Only administrators can make changes.
122
+ </div>
123
+ )}
124
+
125
+ <SettingsValidationSummary issues={seo.issues} />
126
+
127
+ <MetaDefaultsCard seo={seo} canEdit={canEdit} />
128
+ <SearchEngineVerificationCard seo={seo} canEdit={canEdit} />
129
+ <SitemapDefaultsCard seo={seo} canEdit={canEdit} onNavigate={onNavigate} />
130
+
131
+ <details className="border-border bg-card group rounded-lg border">
132
+ <summary className="text-foreground flex cursor-pointer list-none items-center justify-between p-4 text-base font-medium">
133
+ <span>Advanced &amp; per-collection SEO</span>
134
+ <span className="text-muted-foreground text-sm font-normal group-open:hidden">Show</span>
135
+ <span className="text-muted-foreground hidden text-sm font-normal group-open:inline">
136
+ Hide
137
+ </span>
138
+ </summary>
139
+ <div className="border-border border-t p-4">
140
+ <p className="text-muted-foreground mb-4 text-sm">
141
+ Site name, social handle, per-collection schema, and route toggles. These share the same
142
+ SEO configuration as the defaults above.
143
+ </p>
144
+ <SEOConfigPanel />
145
+ </div>
146
+ </details>
147
+
148
+ {seo.saveState === 'error' && seo.saveError && (
149
+ <div
150
+ role="alert"
151
+ className="border-destructive/30 bg-destructive/10 text-destructive flex items-center gap-3 rounded-lg border p-3 text-sm"
152
+ >
153
+ <AlertTriangle size={16} className="shrink-0" aria-hidden="true" />
154
+ <span className="flex-1">{seo.saveError}</span>
155
+ <button
156
+ type="button"
157
+ onClick={seo.save}
158
+ className="border-destructive/40 rounded-md border px-3 py-1 transition-colors hover:opacity-80"
159
+ >
160
+ Retry
161
+ </button>
162
+ </div>
163
+ )}
164
+
165
+ <SettingsSaveBar
166
+ dirty={seo.dirty}
167
+ canEdit={canEdit}
168
+ hasErrors={seo.hasErrors}
169
+ saveState={seo.saveState}
170
+ onSave={seo.save}
171
+ onReset={seo.reset}
172
+ />
173
+ </div>
174
+ )
175
+ }
176
+
177
+ // ---------------------------------------------------------------------------
178
+ // Full SEO workspace callout
179
+ // ---------------------------------------------------------------------------
180
+
181
+ function SeoWorkspaceCallout({ onNavigate }: { onNavigate?: (path: string) => void }) {
182
+ return (
183
+ <div className="border-border bg-accent/40 flex items-center gap-4 rounded-lg border p-4">
184
+ <span className="bg-brand/10 text-brand flex h-10 w-10 shrink-0 items-center justify-center rounded-lg">
185
+ <Search size={20} aria-hidden="true" />
186
+ </span>
187
+ <div className="flex-1">
188
+ <p className="text-foreground text-base font-medium">Looking for the full SEO workspace?</p>
189
+ <p className="text-muted-foreground text-sm">
190
+ These are site-wide defaults. Per-page meta, audits, redirects and AI recovery live in the
191
+ SEO dashboard.
192
+ </p>
193
+ </div>
194
+ <button
195
+ type="button"
196
+ onClick={() => onNavigate?.('/seo')}
197
+ className="border-border text-foreground hover:bg-accent inline-flex shrink-0 items-center gap-2 rounded-md border px-4 py-2 text-base font-medium transition-colors"
198
+ >
199
+ <ExternalLink size={16} aria-hidden="true" />
200
+ Open SEO
201
+ </button>
202
+ </div>
203
+ )
204
+ }
205
+
206
+ // ---------------------------------------------------------------------------
207
+ // Meta Defaults
208
+ // ---------------------------------------------------------------------------
209
+
210
+ function MetaDefaultsCard({ seo, canEdit }: { seo: UseSeoSettings; canEdit: boolean }) {
211
+ const headingId = useId()
212
+ const templateId = useId()
213
+ const descId = useId()
214
+ const [pickerOpen, setPickerOpen] = useState(false)
215
+
216
+ const { form, setField, issues, sources } = seo
217
+ const templateIssue = issues.find((i) => i.field === 'titleTemplate')
218
+ const descIssue = issues.find((i) => i.field === 'defaultMetaDescription')
219
+ const tplValidation = validateTitleTemplateClient(form.titleTemplate)
220
+
221
+ const descLen = form.defaultMetaDescription.trim().length
222
+ const descCountClass =
223
+ descLen > META_DESCRIPTION_MAX || (descLen > 0 && descLen < META_DESCRIPTION_MIN)
224
+ ? 'text-warning'
225
+ : 'text-muted-foreground'
226
+
227
+ const templateEditable = sources.titleTemplate?.editable !== false && canEdit
228
+ const descEditable = sources.defaultMetaDescription?.editable !== false && canEdit
229
+ const imageEditable = sources.defaultOgImage?.editable !== false && canEdit
230
+
231
+ return (
232
+ <section aria-labelledby={headingId} className="border-border bg-card rounded-lg border p-4">
233
+ <h3 id={headingId} className="text-foreground mb-4 text-base font-medium">
234
+ Meta Defaults
235
+ </h3>
236
+
237
+ <div className="space-y-4">
238
+ {/* Title template */}
239
+ <div>
240
+ <label htmlFor={templateId} className={LABEL_CLASS}>
241
+ Title template
242
+ <SettingsSourceBadge meta={sources.titleTemplate} />
243
+ </label>
244
+ <input
245
+ id={templateId}
246
+ type="text"
247
+ value={form.titleTemplate}
248
+ disabled={!templateEditable}
249
+ placeholder="%page% — %site%"
250
+ aria-invalid={templateIssue?.severity === 'error'}
251
+ onChange={(e) => setField('titleTemplate', e.target.value)}
252
+ className={INPUT_CLASS}
253
+ />
254
+ <p className="text-muted-foreground mt-1 text-sm">
255
+ Tokens: {SUPPORTED_TOKENS.join(' ')}. Applied to all pages without a custom SEO title.
256
+ </p>
257
+ {templateIssue ? (
258
+ <p className="text-destructive mt-1 text-sm">{templateIssue.message}</p>
259
+ ) : (
260
+ tplValidation.valid && (
261
+ <p className="text-muted-foreground mt-1 text-sm">
262
+ Preview:{' '}
263
+ <span className="text-foreground font-medium">
264
+ {previewTitle(form.titleTemplate, seo.siteName)}
265
+ </span>
266
+ </p>
267
+ )
268
+ )}
269
+ </div>
270
+
271
+ {/* Default meta description */}
272
+ <div>
273
+ <label htmlFor={descId} className={LABEL_CLASS}>
274
+ Default meta description
275
+ <SettingsSourceBadge meta={sources.defaultMetaDescription} />
276
+ </label>
277
+ <textarea
278
+ id={descId}
279
+ rows={3}
280
+ value={form.defaultMetaDescription}
281
+ disabled={!descEditable}
282
+ aria-describedby={`${descId}-count`}
283
+ onChange={(e) => setField('defaultMetaDescription', e.target.value)}
284
+ className={`${INPUT_CLASS} resize-y`}
285
+ />
286
+ <div className="mt-1 flex items-center justify-between gap-2">
287
+ <p className="text-muted-foreground text-sm">
288
+ Used when a page or post defines no description of its own.
289
+ </p>
290
+ <span id={`${descId}-count`} className={`text-sm ${descCountClass}`}>
291
+ {descLen}/{META_DESCRIPTION_MAX}
292
+ </span>
293
+ </div>
294
+ {descIssue && <p className="text-warning mt-1 text-sm">{descIssue.message}</p>}
295
+ </div>
296
+
297
+ {/* Default social share image */}
298
+ <div>
299
+ <span className={LABEL_CLASS}>
300
+ Default social share image
301
+ <SettingsSourceBadge meta={sources.defaultOgImage} />
302
+ </span>
303
+ <div className="flex items-center gap-3">
304
+ <div className="border-border bg-muted flex h-16 w-28 shrink-0 items-center justify-center overflow-hidden rounded-md border">
305
+ {form.defaultOgImage ? (
306
+ // eslint-disable-next-line @next/next/no-img-element
307
+ <img
308
+ src={form.defaultOgImage}
309
+ alt="Default social share preview"
310
+ className="h-full w-full object-cover"
311
+ />
312
+ ) : (
313
+ <ImageIcon size={20} className="text-muted-foreground" aria-hidden="true" />
314
+ )}
315
+ </div>
316
+ <div className="flex flex-col gap-2">
317
+ <div className="flex items-center gap-2">
318
+ <button
319
+ type="button"
320
+ onClick={() => setPickerOpen(true)}
321
+ disabled={!imageEditable}
322
+ className="border-border text-foreground hover:bg-accent inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-60"
323
+ >
324
+ <Upload size={14} aria-hidden="true" />
325
+ {form.defaultOgImage ? 'Replace' : 'Choose image'}
326
+ </button>
327
+ {form.defaultOgImage && imageEditable && (
328
+ <button
329
+ type="button"
330
+ onClick={() => {
331
+ setField('defaultOgImage', '')
332
+ setField('defaultSocialImageAssetId', '')
333
+ }}
334
+ className="text-muted-foreground hover:text-destructive inline-flex items-center gap-1 rounded-md px-2 py-1.5 text-sm transition-colors"
335
+ >
336
+ <X size={14} aria-hidden="true" />
337
+ Remove
338
+ </button>
339
+ )}
340
+ </div>
341
+ <p className="text-muted-foreground text-sm">1200×630 recommended</p>
342
+ </div>
343
+ </div>
344
+ </div>
345
+ </div>
346
+
347
+ {/* SERP + social preview */}
348
+ <SeoPreviewCard
349
+ title={previewTitle(form.titleTemplate, seo.siteName)}
350
+ url={seo.siteUrl || 'https://example.com'}
351
+ description={
352
+ form.defaultMetaDescription.trim() || 'Your default meta description appears here.'
353
+ }
354
+ image={form.defaultOgImage}
355
+ siteName={seo.siteName}
356
+ />
357
+
358
+ <MediaPickerModal
359
+ open={pickerOpen}
360
+ onClose={() => setPickerOpen(false)}
361
+ accept="image/*"
362
+ onSelect={(url) => setField('defaultOgImage', url)}
363
+ onSelectItem={(item) => setField('defaultSocialImageAssetId', item.id)}
364
+ />
365
+ </section>
366
+ )
367
+ }
368
+
369
+ function SeoPreviewCard({
370
+ title,
371
+ url,
372
+ description,
373
+ image,
374
+ siteName,
375
+ }: {
376
+ title: string
377
+ url: string
378
+ description: string
379
+ image: string
380
+ siteName: string
381
+ }) {
382
+ return (
383
+ <div className="border-border mt-4 grid gap-4 border-t pt-4 sm:grid-cols-2">
384
+ <div className="border-border rounded-lg border p-3">
385
+ <p className="text-muted-foreground mb-2 text-xs font-medium tracking-wide uppercase">
386
+ Search preview
387
+ </p>
388
+ <p className="text-success truncate text-sm">{url}</p>
389
+ <p className="text-primary truncate text-lg leading-snug">{title}</p>
390
+ <p className="text-muted-foreground line-clamp-2 text-sm">{description}</p>
391
+ </div>
392
+ <div className="border-border overflow-hidden rounded-lg border">
393
+ <div className="bg-muted flex aspect-1200/630 items-center justify-center">
394
+ {image ? (
395
+ // eslint-disable-next-line @next/next/no-img-element
396
+ <img src={image} alt="Social share preview" className="h-full w-full object-cover" />
397
+ ) : (
398
+ <ImageIcon size={24} className="text-muted-foreground" aria-hidden="true" />
399
+ )}
400
+ </div>
401
+ <div className="p-3">
402
+ <p className="text-muted-foreground truncate text-xs uppercase">
403
+ {siteName || new URL(url).hostname}
404
+ </p>
405
+ <p className="text-foreground truncate text-sm font-medium">{title}</p>
406
+ <p className="text-muted-foreground line-clamp-1 text-xs">{description}</p>
407
+ </div>
408
+ </div>
409
+ </div>
410
+ )
411
+ }
412
+
413
+ // ---------------------------------------------------------------------------
414
+ // Search Engine Verification
415
+ // ---------------------------------------------------------------------------
416
+
417
+ function VerificationField({
418
+ label,
419
+ field,
420
+ placeholder,
421
+ value,
422
+ onChange,
423
+ disabled,
424
+ }: {
425
+ label: string
426
+ field: keyof SeoSettingsForm
427
+ placeholder: string
428
+ value: string
429
+ onChange: (key: keyof SeoSettingsForm, value: string) => void
430
+ disabled: boolean
431
+ }) {
432
+ const id = useId()
433
+ const hasValue = value.trim().length > 0
434
+ return (
435
+ <div>
436
+ <label htmlFor={id} className={LABEL_CLASS}>
437
+ {label}
438
+ {hasValue && (
439
+ <span className="text-muted-foreground border-border bg-muted inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 text-xs">
440
+ <CheckCircle2 size={11} className="text-success" aria-hidden="true" />
441
+ Saved
442
+ </span>
443
+ )}
444
+ </label>
445
+ <input
446
+ id={id}
447
+ type="text"
448
+ autoComplete="off"
449
+ spellCheck={false}
450
+ value={value}
451
+ disabled={disabled}
452
+ placeholder={placeholder}
453
+ onChange={(e) => onChange(field, e.target.value)}
454
+ className={INPUT_CLASS}
455
+ />
456
+ </div>
457
+ )
458
+ }
459
+
460
+ function SearchEngineVerificationCard({ seo, canEdit }: { seo: UseSeoSettings; canEdit: boolean }) {
461
+ const headingId = useId()
462
+ const { form, setField } = seo
463
+ const onChange = (key: keyof SeoSettingsForm, value: string) => setField(key, value as never)
464
+
465
+ return (
466
+ <section aria-labelledby={headingId} className="border-border bg-card rounded-lg border p-4">
467
+ <h3 id={headingId} className="text-foreground mb-1 text-base font-medium">
468
+ Search Engine Verification
469
+ </h3>
470
+ <p className="text-muted-foreground mb-4 text-sm">
471
+ Paste the verification token from each provider. We render only safe meta tags into your
472
+ site&apos;s head — never arbitrary HTML.
473
+ </p>
474
+
475
+ <div className="space-y-4">
476
+ <VerificationField
477
+ label="Google Search Console"
478
+ field="googleVerification"
479
+ placeholder="google-site-verification…"
480
+ value={form.googleVerification}
481
+ onChange={onChange}
482
+ disabled={!canEdit}
483
+ />
484
+ <VerificationField
485
+ label="Bing Webmaster Tools"
486
+ field="bingVerification"
487
+ placeholder="msvalidate.01…"
488
+ value={form.bingVerification}
489
+ onChange={onChange}
490
+ disabled={!canEdit}
491
+ />
492
+ </div>
493
+
494
+ <details className="group mt-4">
495
+ <summary className="text-brand cursor-pointer list-none text-sm hover:underline">
496
+ <span className="group-open:hidden">More providers</span>
497
+ <span className="hidden group-open:inline">Fewer providers</span>
498
+ </summary>
499
+ <div className="mt-4 space-y-4">
500
+ <VerificationField
501
+ label="Yandex"
502
+ field="yandexVerification"
503
+ placeholder="yandex-verification…"
504
+ value={form.yandexVerification}
505
+ onChange={onChange}
506
+ disabled={!canEdit}
507
+ />
508
+ <VerificationField
509
+ label="Pinterest"
510
+ field="pinterestVerification"
511
+ placeholder="p:domain_verify…"
512
+ value={form.pinterestVerification}
513
+ onChange={onChange}
514
+ disabled={!canEdit}
515
+ />
516
+ <VerificationField
517
+ label="Facebook domain verification"
518
+ field="facebookDomainVerification"
519
+ placeholder="facebook-domain-verification…"
520
+ value={form.facebookDomainVerification}
521
+ onChange={onChange}
522
+ disabled={!canEdit}
523
+ />
524
+ </div>
525
+ </details>
526
+ </section>
527
+ )
528
+ }
529
+
530
+ // ---------------------------------------------------------------------------
531
+ // Sitemap Defaults
532
+ // ---------------------------------------------------------------------------
533
+
534
+ function SitemapDefaultsCard({
535
+ seo,
536
+ canEdit,
537
+ onNavigate,
538
+ }: {
539
+ seo: UseSeoSettings
540
+ canEdit: boolean
541
+ onNavigate?: (path: string) => void
542
+ }) {
543
+ const headingId = useId()
544
+ const [pendingDisable, setPendingDisable] = useState(false)
545
+ const { form, setField, sitemap, siteUrl, isProduction, regenerateSitemap, regenerating } = seo
546
+
547
+ function requestAutoGenerate(value: boolean) {
548
+ // Turning auto-generation off on production can let the sitemap go stale.
549
+ if (!value && isProduction) {
550
+ setPendingDisable(true)
551
+ return
552
+ }
553
+ setField('autoGenerateSitemap', value)
554
+ }
555
+
556
+ const lastGenerated = sitemap?.lastGeneratedAt
557
+ ? new Date(sitemap.lastGeneratedAt).toLocaleString()
558
+ : 'Never'
559
+
560
+ return (
561
+ <section aria-labelledby={headingId} className="border-border bg-card rounded-lg border p-4">
562
+ <h3 id={headingId} className="text-foreground mb-4 text-base font-medium">
563
+ Sitemap
564
+ </h3>
565
+
566
+ {!siteUrl && (
567
+ <div className="border-warning/30 bg-warning/10 text-warning mb-4 flex items-start gap-2 rounded-md border p-3 text-sm">
568
+ <AlertTriangle size={16} className="mt-0.5 shrink-0" aria-hidden="true" />
569
+ Site URL is not set — sitemap URLs may be incorrect. Set it in Settings → General.
570
+ </div>
571
+ )}
572
+
573
+ <div className="space-y-4">
574
+ <SettingsToggleRow
575
+ label="Auto-generate XML sitemap"
576
+ description="Rebuild the sitemap whenever content is published."
577
+ checked={form.autoGenerateSitemap}
578
+ disabled={!canEdit}
579
+ onChange={requestAutoGenerate}
580
+ />
581
+ <SettingsToggleRow
582
+ label="Ping search engines on publish"
583
+ description="Notify Google and Bing when the sitemap changes."
584
+ checked={form.pingOnPublish}
585
+ disabled={!canEdit || !form.autoGenerateSitemap}
586
+ onChange={(v) => setField('pingOnPublish', v)}
587
+ />
588
+ </div>
589
+
590
+ <div className="border-border mt-4 space-y-2 border-t pt-4">
591
+ <div className="flex items-center justify-between gap-2 text-sm">
592
+ <span className="text-muted-foreground">Sitemap URL</span>
593
+ {sitemap?.sitemapUrl ? (
594
+ <a
595
+ href={sitemap.sitemapUrl}
596
+ target="_blank"
597
+ rel="noreferrer"
598
+ className="text-brand inline-flex items-center gap-1 font-medium hover:underline"
599
+ >
600
+ View sitemap
601
+ <ExternalLink size={13} aria-hidden="true" />
602
+ </a>
603
+ ) : (
604
+ <span className="text-foreground font-medium">/sitemap.xml</span>
605
+ )}
606
+ </div>
607
+ <SummaryRow label="Last generated" value={lastGenerated} />
608
+ <SummaryRow label="Included URLs" value={String(sitemap?.pagesIncluded ?? '—')} />
609
+ <SummaryRow label="Excluded URLs" value={String(sitemap?.pagesExcluded ?? '—')} />
610
+ <div className="flex items-center justify-between gap-2 pt-1">
611
+ <button
612
+ type="button"
613
+ onClick={regenerateSitemap}
614
+ disabled={!canEdit || regenerating}
615
+ className="border-border text-foreground hover:bg-accent inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-60"
616
+ >
617
+ {regenerating ? (
618
+ <Loader2 size={14} className="animate-spin" aria-hidden="true" />
619
+ ) : (
620
+ <RefreshCw size={14} aria-hidden="true" />
621
+ )}
622
+ Regenerate now
623
+ </button>
624
+ {onNavigate && (
625
+ <button
626
+ type="button"
627
+ onClick={() => onNavigate('/seo?tab=technical')}
628
+ className="text-brand text-sm hover:underline"
629
+ >
630
+ Manage in SEO Technical →
631
+ </button>
632
+ )}
633
+ </div>
634
+ </div>
635
+
636
+ <ConfirmDangerousSettingDialog
637
+ open={pendingDisable}
638
+ title="Stop auto-generating the sitemap on production?"
639
+ description="Your sitemap may go stale as content changes, which can hurt how quickly search engines discover new pages."
640
+ confirmLabel="Turn Off"
641
+ onConfirm={() => {
642
+ setField('autoGenerateSitemap', false)
643
+ setPendingDisable(false)
644
+ }}
645
+ onCancel={() => setPendingDisable(false)}
646
+ />
647
+ </section>
648
+ )
649
+ }
650
+
651
+ function SummaryRow({ label, value }: { label: string; value: string }) {
652
+ return (
653
+ <div className="flex items-center justify-between gap-2 text-sm">
654
+ <span className="text-muted-foreground">{label}</span>
655
+ <span className="text-foreground font-medium">{value}</span>
656
+ </div>
657
+ )
658
+ }