@prenta/admin 1.16.0 → 1.17.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 (41) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/dist/__tests__/components/faq-proposal-preview.render.test.d.ts +2 -0
  3. package/dist/__tests__/components/faq-proposal-preview.render.test.d.ts.map +1 -0
  4. package/dist/__tests__/components/faq-proposal-preview.render.test.js +97 -0
  5. package/dist/__tests__/components/faq-proposal-preview.render.test.js.map +1 -0
  6. package/dist/__tests__/components/seo-issue-fix-panel.render.test.js +37 -11
  7. package/dist/__tests__/components/seo-issue-fix-panel.render.test.js.map +1 -1
  8. package/dist/__tests__/lib/seo-service-errors.test.js +3 -0
  9. package/dist/__tests__/lib/seo-service-errors.test.js.map +1 -1
  10. package/dist/__tests__/lib/seo-service-proposals.test.js +24 -0
  11. package/dist/__tests__/lib/seo-service-proposals.test.js.map +1 -1
  12. package/dist/__tests__/views/seo-proposals-tab.render.test.js +277 -0
  13. package/dist/__tests__/views/seo-proposals-tab.render.test.js.map +1 -1
  14. package/dist/components/seo/FaqProposalPreview.d.ts +39 -0
  15. package/dist/components/seo/FaqProposalPreview.d.ts.map +1 -0
  16. package/dist/components/seo/FaqProposalPreview.js +62 -0
  17. package/dist/components/seo/FaqProposalPreview.js.map +1 -0
  18. package/dist/components/seo/SeoIssueFixPanel.d.ts.map +1 -1
  19. package/dist/components/seo/SeoIssueFixPanel.js +7 -2
  20. package/dist/components/seo/SeoIssueFixPanel.js.map +1 -1
  21. package/dist/lib/seo-service.d.ts +61 -2
  22. package/dist/lib/seo-service.d.ts.map +1 -1
  23. package/dist/lib/seo-service.js +9 -2
  24. package/dist/lib/seo-service.js.map +1 -1
  25. package/dist/views/seo/ProposalsTab.d.ts.map +1 -1
  26. package/dist/views/seo/ProposalsTab.js +160 -7
  27. package/dist/views/seo/ProposalsTab.js.map +1 -1
  28. package/dist/views/settings/SeoAutopilotCard.d.ts.map +1 -1
  29. package/dist/views/settings/SeoAutopilotCard.js +6 -0
  30. package/dist/views/settings/SeoAutopilotCard.js.map +1 -1
  31. package/package.json +3 -3
  32. package/src/__tests__/components/faq-proposal-preview.render.test.tsx +137 -0
  33. package/src/__tests__/components/seo-issue-fix-panel.render.test.tsx +42 -11
  34. package/src/__tests__/lib/seo-service-errors.test.ts +4 -0
  35. package/src/__tests__/lib/seo-service-proposals.test.ts +30 -0
  36. package/src/__tests__/views/seo-proposals-tab.render.test.tsx +358 -0
  37. package/src/components/seo/FaqProposalPreview.tsx +134 -0
  38. package/src/components/seo/SeoIssueFixPanel.tsx +7 -2
  39. package/src/lib/seo-service.ts +57 -1
  40. package/src/views/seo/ProposalsTab.tsx +279 -13
  41. package/src/views/settings/SeoAutopilotCard.tsx +6 -0
@@ -0,0 +1,134 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * Expanded-row body for an `add-section` (FAQ) proposal in the SEO inbox:
5
+ * the signal that earned the page a proposal, an editable section heading,
6
+ * and one checkbox per drafted question so the reviewer can drop weak ones
7
+ * before approving. Fully controlled — the tab owns the draft.
8
+ */
9
+ import { MessageCircleQuestionMark } from 'lucide-react'
10
+ import { useId } from 'react'
11
+ import type { SeoFaqSignals } from '../../lib/seo-service.js'
12
+
13
+ /** Server-enforced floor: a FAQ section with one question is not a FAQ. */
14
+ export const FAQ_MIN_SELECTED = 2
15
+ /** Mirrors core's `FAQ_HEADING_MAX` (measured after trim). */
16
+ export const FAQ_HEADING_MAX = 80
17
+ /** The window core aggregates FAQ signals over (`FAQ_SIGNAL_WINDOW_DAYS`). */
18
+ const SIGNAL_WINDOW_DAYS = 28
19
+
20
+ export interface FaqProposalPreviewItem {
21
+ question: string
22
+ answer: string
23
+ /** Verbatim page quote the answer was grounded in; omit / `''` when none survived. */
24
+ sourceQuote?: string
25
+ }
26
+
27
+ export interface FaqProposalPreviewProps {
28
+ /** The heading the AI drafted (shown as the input's placeholder). */
29
+ heading: string
30
+ items: FaqProposalPreviewItem[]
31
+ signals: SeoFaqSignals | null
32
+ /** Controlled; parallel to `items`. */
33
+ selected: boolean[]
34
+ onToggle: (index: number) => void
35
+ /** Controlled heading field value. */
36
+ headingValue: string
37
+ onHeadingChange: (value: string) => void
38
+ disabled?: boolean
39
+ }
40
+
41
+ /**
42
+ * One-line "why this page" summary: referral citations win over retrieval
43
+ * fetches, and the top question-shaped query is appended when present.
44
+ * Engine names are rendered as the server sends them (registry display names).
45
+ */
46
+ export function faqSignalLine(signals: SeoFaqSignals | null): string | null {
47
+ if (!signals) return null
48
+ const parts: string[] = []
49
+ const g = signals.geo
50
+ const engines = g?.engines.join(', ') || 'AI engines'
51
+ if (g && g.referralVisits > 0) {
52
+ parts.push(`Cited by ${engines} ${g.referralVisits}× in ${SIGNAL_WINDOW_DAYS} days`)
53
+ } else if (g && g.retrievalHits > 0) {
54
+ parts.push(`Fetched by ${engines} ${g.retrievalHits}× in ${SIGNAL_WINDOW_DAYS} days`)
55
+ }
56
+ const q = signals.queries[0]
57
+ if (q) parts.push(`asked "${q.query}" ${q.impressions}× on Google`)
58
+ return parts.length > 0 ? parts.join(' · ') : null
59
+ }
60
+
61
+ /**
62
+ * Why the current draft cannot be approved, or `null` when it can. Shared by
63
+ * the status line below and the tab's Approve buttons so both agree. The
64
+ * heading is deliberately not a blocker: a blank field means "use the drafted
65
+ * heading" (the input's placeholder), and the tab sends no override.
66
+ */
67
+ export function faqDraftBlocker(selected: boolean[]): string | null {
68
+ const count = selected.filter(Boolean).length
69
+ if (count < FAQ_MIN_SELECTED) {
70
+ return `Select at least ${FAQ_MIN_SELECTED} questions to approve (${count} selected).`
71
+ }
72
+ return null
73
+ }
74
+
75
+ export function FaqProposalPreview(props: FaqProposalPreviewProps) {
76
+ const headingId = useId()
77
+ const selectedCount = props.selected.filter(Boolean).length
78
+ const line = faqSignalLine(props.signals)
79
+ const blocker = faqDraftBlocker(props.selected)
80
+ return (
81
+ <div className="space-y-4">
82
+ {line ? (
83
+ <p className="text-muted-foreground flex items-center gap-2 text-sm">
84
+ <MessageCircleQuestionMark size={16} aria-hidden="true" />
85
+ <span>{line}</span>
86
+ </p>
87
+ ) : null}
88
+ <div className="space-y-1">
89
+ <label htmlFor={headingId} className="text-foreground block text-sm font-medium">
90
+ Section heading
91
+ </label>
92
+ <input
93
+ id={headingId}
94
+ type="text"
95
+ value={props.headingValue}
96
+ placeholder={props.heading}
97
+ maxLength={FAQ_HEADING_MAX}
98
+ disabled={props.disabled}
99
+ onChange={(e) => props.onHeadingChange(e.target.value)}
100
+ className="bg-input-background text-foreground border-border focus-visible:ring-ring w-full max-w-md rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:outline-none disabled:opacity-60"
101
+ />
102
+ </div>
103
+ <ul className="divide-border divide-y" aria-label="Proposed questions">
104
+ {props.items.map((item, i) => {
105
+ const id = `${headingId}-item-${i}`
106
+ return (
107
+ <li key={id} className="flex gap-3 py-3">
108
+ <input
109
+ id={id}
110
+ type="checkbox"
111
+ checked={props.selected[i] ?? true}
112
+ disabled={props.disabled}
113
+ onChange={() => props.onToggle(i)}
114
+ className="border-border text-primary focus-visible:ring-ring mt-1 h-4 w-4 shrink-0 rounded"
115
+ />
116
+ <div className="min-w-0 space-y-1">
117
+ <label htmlFor={id} className="text-foreground block text-sm font-medium">
118
+ {item.question}
119
+ </label>
120
+ <p className="text-foreground text-sm">{item.answer}</p>
121
+ {item.sourceQuote ? (
122
+ <p className="text-muted-foreground text-sm">Source: “{item.sourceQuote}”</p>
123
+ ) : null}
124
+ </div>
125
+ </li>
126
+ )
127
+ })}
128
+ </ul>
129
+ <p role="status" className="text-muted-foreground text-sm">
130
+ {blocker ?? `${selectedCount} of ${props.items.length} questions will be added.`}
131
+ </p>
132
+ </div>
133
+ )
134
+ }
@@ -24,6 +24,9 @@ import { PlanUpgradeCallout } from '../PlanUpgradeCallout.js'
24
24
  import { ChangePreview } from './ChangePreview.js'
25
25
  import { SeoErrorState, btnPrimary, btnSecondary } from './primitives.js'
26
26
 
27
+ // Redirect suggestions always carry `fixStrategy: 'redirect'` (core
28
+ // `issue-fix-redirect.ts`); an undefined strategy is a metadata / schema patch
29
+ // (meta title, description, canonical, structured data, FAQPage node).
27
30
  function approveButtonLabel(strategy: SeoIssueFixSuggestion['fixStrategy']): string {
28
31
  switch (strategy) {
29
32
  case 'fix-link':
@@ -31,8 +34,9 @@ function approveButtonLabel(strategy: SeoIssueFixSuggestion['fixStrategy']): str
31
34
  case 'insert-link':
32
35
  return 'Approve link'
33
36
  case 'redirect':
34
- case undefined:
35
37
  return 'Approve redirect'
38
+ case undefined:
39
+ return 'Approve fix'
36
40
  default: {
37
41
  const _exhaustive: never = strategy
38
42
  return _exhaustive
@@ -47,8 +51,9 @@ function approveSuccessToast(strategy: SeoIssueFixSuggestion['fixStrategy']): st
47
51
  case 'insert-link':
48
52
  return 'Link added and issue resolved.'
49
53
  case 'redirect':
50
- case undefined:
51
54
  return 'Redirect created and issue resolved.'
55
+ case undefined:
56
+ return 'Fix applied and issue resolved.'
52
57
  default: {
53
58
  const _exhaustive: never = strategy
54
59
  return _exhaustive
@@ -501,15 +501,30 @@ export async function fetchSeoIssueFixSuggestion(issueId: string): Promise<{
501
501
  return { suggestion: res.data?.suggestion }
502
502
  }
503
503
 
504
+ /**
505
+ * Per-approval refinements for an `add-section` (FAQ) fix. Both are ignored by
506
+ * the server for every other fix strategy.
507
+ */
508
+ export interface SeoApplyFixOptions {
509
+ /** Indices into `section.content.items` to keep; the server requires at least two. */
510
+ includeItems?: number[]
511
+ /** Replacement section heading (server trims, then enforces `FAQ_HEADING_MAX`, mirrored from core). */
512
+ headingOverride?: string
513
+ }
514
+
504
515
  export async function applySeoIssueFix(
505
516
  issueId: string,
506
517
  fingerprint: string,
518
+ opts?: SeoApplyFixOptions,
507
519
  ): Promise<{ issue?: SeoIssue; error?: string }> {
520
+ const body: Record<string, unknown> = { fingerprint }
521
+ if (opts?.includeItems) body.includeItems = opts.includeItems
522
+ if (opts?.headingOverride !== undefined) body.headingOverride = opts.headingOverride
508
523
  const res = await cmsApi<{ issue: SeoIssue }>(
509
524
  `/seo/issues/${encodeURIComponent(issueId)}/apply-fix`,
510
525
  {
511
526
  method: 'POST',
512
- body: JSON.stringify({ fingerprint }),
527
+ body: JSON.stringify(body),
513
528
  },
514
529
  )
515
530
  if (res.error) return { error: res.error }
@@ -561,6 +576,7 @@ export const SEO_INLINE_FIX_ISSUE_TYPES = new Set([
561
576
  'duplicate-meta-title',
562
577
  'duplicate-meta-description',
563
578
  'missing-structured-data',
579
+ 'faq-schema-missing',
564
580
  'broken-internal-link',
565
581
  'orphan-page',
566
582
  ])
@@ -843,6 +859,7 @@ export type SeoAutopilotIssueType =
843
859
  | 'missing-canonical'
844
860
  | 'accidental-noindex'
845
861
  | 'missing-structured-data'
862
+ | 'faq-schema-missing'
846
863
  | 'redirect'
847
864
 
848
865
  export const SEO_AUTOPILOT_ISSUE_TYPES: readonly SeoAutopilotIssueType[] = [
@@ -855,6 +872,7 @@ export const SEO_AUTOPILOT_ISSUE_TYPES: readonly SeoAutopilotIssueType[] = [
855
872
  'missing-canonical',
856
873
  'accidental-noindex',
857
874
  'missing-structured-data',
875
+ 'faq-schema-missing',
858
876
  'redirect',
859
877
  ]
860
878
 
@@ -1747,6 +1765,28 @@ export async function fetchLinkHealthIssues(): Promise<{
1747
1765
  }
1748
1766
 
1749
1767
  // ─── AI proposals inbox ─────────────────────────────────────────────────
1768
+ // Mirrors `packages/cms-core/src/seo/proposals.ts` (`SeoProposal`) and
1769
+ // `seo/faq-signals.ts` (`FaqSignals`). The server is the contract.
1770
+
1771
+ /** Same union as core's `SeoProposalFixStrategy`; `null` for plain metadata writes. */
1772
+ export type SeoProposalFixStrategy = 'redirect' | 'fix-link' | 'insert-link' | 'add-section'
1773
+
1774
+ /** Why a page got a `missing-faq` proposal (28-day window). */
1775
+ export interface SeoFaqSignals {
1776
+ /**
1777
+ * Answer-engine activity. `engines` carries the referrer registry's display
1778
+ * names (`ChatGPT`, `Perplexity`, …), sorted; empty when only retrieval
1779
+ * crawls were seen.
1780
+ */
1781
+ geo: { referralVisits: number; retrievalHits: number; engines: string[] } | null
1782
+ /** Question-shaped Search Console queries, highest impressions first. */
1783
+ queries: Array<{ query: string; impressions: number; clicks: number }>
1784
+ }
1785
+
1786
+ export interface SeoFaqSectionDraft {
1787
+ sectionType: 'faq'
1788
+ content: { heading: string; items: Array<{ question: string; answer: string }> }
1789
+ }
1750
1790
 
1751
1791
  export interface SeoProposal {
1752
1792
  /** `issue:<issueId>` | `redirect:<suggestionId>` */
@@ -1770,6 +1810,22 @@ export interface SeoProposal {
1770
1810
  severity: SeoSeverity | null
1771
1811
  issueId: string | null
1772
1812
  suggestionId: string | null
1813
+ /** Always `'redirect'` for redirect rows; `null` when the cached patch carries none. */
1814
+ fixStrategy: SeoProposalFixStrategy | null
1815
+ /**
1816
+ * `add-section` only: the section approval will insert. `null` when the
1817
+ * cached patch failed core's structural checks — the row must still render
1818
+ * (badge + signals) but cannot be approved from the inbox.
1819
+ */
1820
+ section: SeoFaqSectionDraft | null
1821
+ /**
1822
+ * `add-section` only: verbatim page quotes parallel to `section.content.items`.
1823
+ * A malformed row arrives as `sourceQuote: ''` — render as "no quote", never
1824
+ * as an empty blockquote.
1825
+ */
1826
+ grounding: Array<{ sourceQuote: string }> | null
1827
+ /** `missing-faq` only. */
1828
+ signals: SeoFaqSignals | null
1773
1829
  }
1774
1830
 
1775
1831
  export interface SeoProposalsPayload {
@@ -5,9 +5,11 @@
5
5
  * in one review surface. Batch approve honours server governance
6
6
  * (`allowBulkApply`); the UI only pre-disables the button using the flags the
7
7
  * list payload exposes. Generation is human-triggered, capped per pass, and
8
- * loops client-side while the server reports `remaining > 0`.
8
+ * loops client-side while the server reports `remaining > 0`. FAQ
9
+ * (`add-section`) rows expand into a per-question preview and approve one at
10
+ * a time through `apply-fix`; in a batch they apply the whole drafted section.
9
11
  */
10
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
12
+ import { useCallback, useEffect, useId, useMemo, useRef, useState, type ReactNode } from 'react'
11
13
  import {
12
14
  AlertTriangle,
13
15
  ArrowRightLeft,
@@ -22,11 +24,13 @@ import {
22
24
  } from 'lucide-react'
23
25
  import { toast } from 'sonner'
24
26
  import {
27
+ applySeoIssueFix,
25
28
  bulkApplySeoProposals,
26
29
  dismissRedirectSuggestion,
27
30
  dismissSeoIssueFix,
28
31
  fetchSeoProposals,
29
32
  generateSeoProposals,
33
+ type SeoApplyFixOptions,
30
34
  type SeoProposal,
31
35
  type SeoProposalBulkApplyItem,
32
36
  type SeoProposalsPayload,
@@ -35,6 +39,11 @@ import { hasPlanFeature, type PlanInfo } from '../../lib/plan.js'
35
39
  import { brandAlignmentLabel, brandAlignmentTone } from '../../lib/brand-voice-client.js'
36
40
  import { PlanUpgradeCallout } from '../../components/PlanUpgradeCallout.js'
37
41
  import { ChangePreview } from '../../components/seo/ChangePreview.js'
42
+ import {
43
+ FaqProposalPreview,
44
+ faqDraftBlocker,
45
+ faqSignalLine,
46
+ } from '../../components/seo/FaqProposalPreview.js'
38
47
  import {
39
48
  SectionCard,
40
49
  SeoEmptyState,
@@ -76,6 +85,77 @@ function toBulkItem(p: SeoProposal): SeoProposalBulkApplyItem {
76
85
  : { kind: 'issue-fix', id: p.issueId ?? '', fingerprint: p.fingerprint ?? undefined }
77
86
  }
78
87
 
88
+ /** An `add-section` (FAQ) proposal; `p.section` may still be `null` when the cache was unreadable. */
89
+ function isFaqProposal(p: SeoProposal): boolean {
90
+ return p.fixStrategy === 'add-section'
91
+ }
92
+
93
+ /**
94
+ * Reviewer edits to a FAQ proposal before approval. `selected` is parallel to
95
+ * `section.content.items`; `heading` is the raw input value (trimmed on send).
96
+ */
97
+ interface FaqDraft {
98
+ selected: boolean[]
99
+ heading: string
100
+ }
101
+
102
+ /**
103
+ * Drafts are keyed by the drafted content, not just the issue: `fingerprint`
104
+ * tracks the page (`updatedAt`), so dismiss + regenerate on an unchanged page
105
+ * keeps the same id and fingerprint while the questions change. Folding the
106
+ * questions (and `generatedAt`) into the key makes a regenerated proposal
107
+ * start clean instead of inheriting stale ticks or a stale heading edit.
108
+ */
109
+ function faqDraftKey(p: SeoProposal): string {
110
+ const questions = p.section?.content.items.map((i) => i.question).join('\u0001') ?? ''
111
+ return `${p.id}:${p.generatedAt ?? ''}:${questions}`
112
+ }
113
+
114
+ function initialFaqDraft(p: SeoProposal): FaqDraft {
115
+ const section = p.section
116
+ return {
117
+ selected: section ? section.content.items.map(() => true) : [],
118
+ heading: section?.content.heading ?? '',
119
+ }
120
+ }
121
+
122
+ /**
123
+ * The row's stored draft when it still fits the section (one `selected` flag
124
+ * per item), otherwise the untouched default. Every reader *and* writer goes
125
+ * through this so a mismatched draft is replaced rather than mutated.
126
+ */
127
+ function currentFaqDraft(drafts: Record<string, FaqDraft>, p: SeoProposal): FaqDraft {
128
+ const draft = drafts[faqDraftKey(p)]
129
+ if (draft && draft.selected.length === (p.section?.content.items.length ?? 0)) return draft
130
+ return initialFaqDraft(p)
131
+ }
132
+
133
+ /**
134
+ * Body of the single-row `apply-fix` call. `includeItems` only when the
135
+ * reviewer dropped a question; `headingOverride` only when the trimmed heading
136
+ * is non-empty and differs from the draft — so an untouched row sends exactly
137
+ * `{ fingerprint }`, the same request the Audit tab makes.
138
+ */
139
+ function faqApplyOptions(p: SeoProposal, draft: FaqDraft): SeoApplyFixOptions {
140
+ const opts: SeoApplyFixOptions = {}
141
+ const includeItems = draft.selected.flatMap((on, i) => (on ? [i] : []))
142
+ if (includeItems.length < draft.selected.length) opts.includeItems = includeItems
143
+ const heading = draft.heading.trim()
144
+ if (heading !== '' && heading !== p.section?.content.heading) opts.headingOverride = heading
145
+ return opts
146
+ }
147
+
148
+ /** Change-column text for a FAQ row: question count plus the strongest signal. */
149
+ function faqSummary(p: SeoProposal): string {
150
+ const signal = faqSignalLine(p.signals)
151
+ if (!p.section) return signal ?? p.justification
152
+ const count = plural(p.section.content.items.length, 'question')
153
+ return signal ? `${count} · ${signal}` : count
154
+ }
155
+
156
+ const FAQ_UNREADABLE_HINT =
157
+ 'The drafted section could not be read from the cache. Dismiss this proposal and generate again to get a fresh draft.'
158
+
79
159
  function BrandScoreBadge({ score }: { score: number }) {
80
160
  return (
81
161
  <span
@@ -187,6 +267,8 @@ export function ProposalsTab({
187
267
  const [confirmGenerate, setConfirmGenerate] = useState(false)
188
268
  const [mutation, setMutation] = useState<Mutation | null>(null)
189
269
  const [pendingItems, setPendingItems] = useState<SeoProposalBulkApplyItem[]>([])
270
+ /** Per-row FAQ review edits, created lazily on first interaction (see `faqDraftKey`). */
271
+ const [faqDrafts, setFaqDrafts] = useState<Record<string, FaqDraft>>({})
190
272
  const [generating, setGenerating] = useState<{ done: number; total: number } | null>(null)
191
273
  /** Bumped after every mutation so the autopilot ledger below refetches with the inbox. */
192
274
  const [refreshKey, setRefreshKey] = useState(0)
@@ -305,6 +387,70 @@ export function ProposalsTab({
305
387
  [afterMutation, beginMutation, endMutation],
306
388
  )
307
389
 
390
+ /** The row's current draft, or the untouched default when the reviewer has not edited it. */
391
+ const faqDraftFor = useCallback(
392
+ (p: SeoProposal): FaqDraft => currentFaqDraft(faqDrafts, p),
393
+ [faqDrafts],
394
+ )
395
+ const toggleFaqItem = useCallback((p: SeoProposal, index: number) => {
396
+ setFaqDrafts((cur) => {
397
+ const base = currentFaqDraft(cur, p)
398
+ const selected = base.selected.map((on, i) => (i === index ? !on : on))
399
+ return { ...cur, [faqDraftKey(p)]: { ...base, selected } }
400
+ })
401
+ }, [])
402
+ const setFaqHeading = useCallback((p: SeoProposal, heading: string) => {
403
+ setFaqDrafts((cur) => ({ ...cur, [faqDraftKey(p)]: { ...currentFaqDraft(cur, p), heading } }))
404
+ }, [])
405
+
406
+ // Drop drafts whose proposal is gone or was regenerated, so the map cannot grow across refetches.
407
+ useEffect(() => {
408
+ const liveKeys = new Set(proposals.filter(isFaqProposal).map(faqDraftKey))
409
+ setFaqDrafts((cur) => {
410
+ const stale = Object.keys(cur).filter((key) => !liveKeys.has(key))
411
+ if (stale.length === 0) return cur
412
+ const next = { ...cur }
413
+ for (const key of stale) delete next[key]
414
+ return next
415
+ })
416
+ }, [proposals])
417
+
418
+ /**
419
+ * Single-row approve for a FAQ proposal. Goes through `apply-fix` (not
420
+ * bulk-apply) so the reviewer's per-question selection and heading edit are
421
+ * honoured; the bulk path applies the whole drafted section unchanged.
422
+ */
423
+ const handleApproveFaq = useCallback(
424
+ async (p: SeoProposal) => {
425
+ if (!p.issueId || !p.fingerprint || !p.section) return
426
+ const draft = faqDraftFor(p)
427
+ if (faqDraftBlocker(draft.selected) !== null) return
428
+ if (!beginMutation('apply')) return
429
+ try {
430
+ const res = await applySeoIssueFix(p.issueId, p.fingerprint, faqApplyOptions(p, draft))
431
+ if (!mountedRef.current) return
432
+ if (res.error) {
433
+ toast.error(res.error)
434
+ return
435
+ }
436
+ toast.success('FAQ section added.')
437
+ setFaqDrafts((cur) => {
438
+ const key = faqDraftKey(p)
439
+ if (!(key in cur)) return cur
440
+ const rest = { ...cur }
441
+ delete rest[key]
442
+ return rest
443
+ })
444
+ afterMutation()
445
+ } catch (err) {
446
+ if (mountedRef.current) toast.error(errorMessage(err))
447
+ } finally {
448
+ endMutation()
449
+ }
450
+ },
451
+ [afterMutation, beginMutation, endMutation, faqDraftFor],
452
+ )
453
+
308
454
  const handleDismissSelected = useCallback(async () => {
309
455
  const targets = selectedProposals
310
456
  if (targets.length === 0) return
@@ -573,6 +719,41 @@ export function ProposalsTab({
573
719
  <tbody className="divide-border divide-y">
574
720
  {proposals.map((p) => {
575
721
  const first = p.changes[0]
722
+ const canApply = inlineApplyEnabled || p.kind === 'redirect'
723
+ if (isFaqProposal(p)) {
724
+ const draft = faqDraftFor(p)
725
+ const blocker = p.section
726
+ ? faqDraftBlocker(draft.selected)
727
+ : FAQ_UNREADABLE_HINT
728
+ return (
729
+ <ProposalRow
730
+ key={p.id}
731
+ proposal={p}
732
+ selected={selected.has(p.id)}
733
+ expanded={expanded.has(p.id)}
734
+ summary={faqSummary(p)}
735
+ onToggleSelect={() => toggleSelect(p.id)}
736
+ onToggleExpanded={() => toggleExpanded(p.id)}
737
+ onApprove={() => void handleApproveFaq(p)}
738
+ onDismiss={() => void handleDismissOne(p)}
739
+ busy={busy}
740
+ canApply={canApply}
741
+ approveBlocker={blocker}
742
+ details={
743
+ <FaqProposalDetails
744
+ proposal={p}
745
+ draft={draft}
746
+ blocker={blocker}
747
+ busy={busy}
748
+ canApply={canApply}
749
+ onToggle={(i) => toggleFaqItem(p, i)}
750
+ onHeadingChange={(h) => setFaqHeading(p, h)}
751
+ onApprove={() => void handleApproveFaq(p)}
752
+ />
753
+ }
754
+ />
755
+ )
756
+ }
576
757
  return (
577
758
  <ProposalRow
578
759
  key={p.id}
@@ -589,7 +770,7 @@ export function ProposalsTab({
589
770
  onApprove={() => void runApply([toBulkItem(p)])}
590
771
  onDismiss={() => void handleDismissOne(p)}
591
772
  busy={busy}
592
- canApply={inlineApplyEnabled || p.kind === 'redirect'}
773
+ canApply={canApply}
593
774
  />
594
775
  )
595
776
  })}
@@ -637,6 +818,8 @@ function ProposalRow({
637
818
  onDismiss,
638
819
  busy,
639
820
  canApply,
821
+ approveBlocker = null,
822
+ details,
640
823
  }: {
641
824
  proposal: SeoProposal
642
825
  selected: boolean
@@ -648,7 +831,16 @@ function ProposalRow({
648
831
  onDismiss: () => void
649
832
  busy: boolean
650
833
  canApply: boolean
834
+ /**
835
+ * Why the row-level Approve is disabled (FAQ rows). Exposed as the button's
836
+ * tooltip and accessible description so the reason is available while the
837
+ * row is collapsed, not only in the expanded body.
838
+ */
839
+ approveBlocker?: string | null
840
+ /** Replaces the default before/after table in the expanded row. */
841
+ details?: ReactNode
651
842
  }) {
843
+ const approveHintId = `${useId()}-approve-hint`
652
844
  return (
653
845
  <>
654
846
  <tr className={`${selected ? 'bg-accent/30' : ''} hover:bg-accent/20 transition-colors`}>
@@ -666,6 +858,7 @@ function ProposalRow({
666
858
  <td className="px-3 py-3 align-top">
667
859
  <div className="flex flex-wrap items-center gap-2">
668
860
  <span className="text-foreground font-medium">{p.title}</span>
861
+ {isFaqProposal(p) ? <SeoStatusBadge label="FAQ section" tone="neutral" /> : null}
669
862
  {p.severity ? (
670
863
  <SeoStatusBadge
671
864
  label={p.severity}
@@ -724,15 +917,24 @@ function ProposalRow({
724
917
  <td className="px-3 py-3 align-top">
725
918
  <div className="flex items-center justify-end gap-1">
726
919
  {!p.stale && canApply ? (
727
- <button
728
- type="button"
729
- onClick={onApprove}
730
- disabled={busy}
731
- className="text-success hover:bg-accent focus-visible:ring-ring rounded-md p-1.5 focus-visible:ring-2 focus-visible:outline-none disabled:opacity-60"
732
- aria-label={`Approve ${p.title}`}
733
- >
734
- <Check className="h-4 w-4" aria-hidden />
735
- </button>
920
+ <>
921
+ <button
922
+ type="button"
923
+ onClick={onApprove}
924
+ disabled={busy || approveBlocker !== null}
925
+ className="text-success hover:bg-accent focus-visible:ring-ring rounded-md p-1.5 focus-visible:ring-2 focus-visible:outline-none disabled:opacity-60"
926
+ aria-label={`Approve ${p.title}`}
927
+ title={approveBlocker ?? undefined}
928
+ aria-describedby={approveBlocker ? approveHintId : undefined}
929
+ >
930
+ <Check className="h-4 w-4" aria-hidden />
931
+ </button>
932
+ {approveBlocker ? (
933
+ <span id={approveHintId} className="sr-only">
934
+ {approveBlocker}
935
+ </span>
936
+ ) : null}
937
+ </>
736
938
  ) : null}
737
939
  <button
738
940
  type="button"
@@ -751,7 +953,7 @@ function ProposalRow({
751
953
  <td colSpan={6} className="bg-muted/30 px-4 py-4">
752
954
  <div className="space-y-3">
753
955
  <p className="text-foreground text-sm">{p.justification}</p>
754
- <ChangePreview changes={p.changes} />
956
+ {details ?? <ChangePreview changes={p.changes} />}
755
957
  </div>
756
958
  </td>
757
959
  </tr>
@@ -759,3 +961,67 @@ function ProposalRow({
759
961
  </>
760
962
  )
761
963
  }
964
+
965
+ /**
966
+ * Expanded body of a FAQ row: the per-question preview plus a reviewed
967
+ * Approve button. When core could not read the cached section the row shows
968
+ * the recovery hint instead of a preview and offers no approve at all.
969
+ */
970
+ function FaqProposalDetails({
971
+ proposal: p,
972
+ draft,
973
+ blocker,
974
+ busy,
975
+ canApply,
976
+ onToggle,
977
+ onHeadingChange,
978
+ onApprove,
979
+ }: {
980
+ proposal: SeoProposal
981
+ draft: FaqDraft
982
+ blocker: string | null
983
+ busy: boolean
984
+ canApply: boolean
985
+ onToggle: (index: number) => void
986
+ onHeadingChange: (value: string) => void
987
+ onApprove: () => void
988
+ }) {
989
+ const section = p.section
990
+ if (!section) {
991
+ return (
992
+ <p className="text-muted-foreground flex items-start gap-2 text-sm">
993
+ <AlertTriangle className="text-warning mt-0.5 h-4 w-4 shrink-0" aria-hidden />
994
+ <span>{FAQ_UNREADABLE_HINT}</span>
995
+ </p>
996
+ )
997
+ }
998
+ const items = section.content.items.map((item, i) => ({
999
+ ...item,
1000
+ sourceQuote: p.grounding?.[i]?.sourceQuote || undefined,
1001
+ }))
1002
+ return (
1003
+ <div className="space-y-4">
1004
+ <FaqProposalPreview
1005
+ heading={section.content.heading}
1006
+ items={items}
1007
+ signals={p.signals}
1008
+ selected={draft.selected}
1009
+ onToggle={onToggle}
1010
+ headingValue={draft.heading}
1011
+ onHeadingChange={onHeadingChange}
1012
+ disabled={busy}
1013
+ />
1014
+ {!p.stale && canApply ? (
1015
+ <button
1016
+ type="button"
1017
+ className={btnPrimary}
1018
+ onClick={onApprove}
1019
+ disabled={busy || blocker !== null}
1020
+ >
1021
+ <Check className="h-4 w-4" aria-hidden />
1022
+ Approve
1023
+ </button>
1024
+ ) : null}
1025
+ </div>
1026
+ )
1027
+ }
@@ -120,6 +120,12 @@ export const AUTOPILOT_TYPE_GROUPS: ReadonlyArray<{
120
120
  help: 'Adds the collection’s default Schema.org type.',
121
121
  producer: 'Rule-based',
122
122
  },
123
+ {
124
+ id: 'faq-schema-missing',
125
+ label: 'FAQ schema for visible FAQ sections',
126
+ help: 'Adds a FAQPage node to a page’s stored structured data when a visible FAQ section has none — typically pages published before the section was added. Re-publishing the page clears it too.',
127
+ producer: 'Rule-based',
128
+ },
123
129
  ],
124
130
  },
125
131
  {