@prenta/admin 1.6.2 → 1.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 (70) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/__tests__/components/seo-editor-pane.render.test.js +2 -1
  3. package/dist/__tests__/components/seo-editor-pane.render.test.js.map +1 -1
  4. package/dist/__tests__/components/seo-issue-fix-panel.render.test.d.ts +2 -0
  5. package/dist/__tests__/components/seo-issue-fix-panel.render.test.d.ts.map +1 -0
  6. package/dist/__tests__/components/seo-issue-fix-panel.render.test.js +60 -0
  7. package/dist/__tests__/components/seo-issue-fix-panel.render.test.js.map +1 -0
  8. package/dist/__tests__/components/seo-linking-section.render.test.d.ts +2 -0
  9. package/dist/__tests__/components/seo-linking-section.render.test.d.ts.map +1 -0
  10. package/dist/__tests__/components/seo-linking-section.render.test.js +94 -0
  11. package/dist/__tests__/components/seo-linking-section.render.test.js.map +1 -0
  12. package/dist/__tests__/lib/collection-routes.test.js +8 -1
  13. package/dist/__tests__/lib/collection-routes.test.js.map +1 -1
  14. package/dist/__tests__/lib/seo-service-errors.test.js +35 -3
  15. package/dist/__tests__/lib/seo-service-errors.test.js.map +1 -1
  16. package/dist/__tests__/views/seo-content-pane.render.test.js +8 -1
  17. package/dist/__tests__/views/seo-content-pane.render.test.js.map +1 -1
  18. package/dist/components/seo/SeoEditorPane.d.ts +2 -1
  19. package/dist/components/seo/SeoEditorPane.d.ts.map +1 -1
  20. package/dist/components/seo/SeoEditorPane.js +2 -2
  21. package/dist/components/seo/SeoEditorPane.js.map +1 -1
  22. package/dist/components/seo/SeoIssueFixPanel.d.ts.map +1 -1
  23. package/dist/components/seo/SeoIssueFixPanel.js +32 -8
  24. package/dist/components/seo/SeoIssueFixPanel.js.map +1 -1
  25. package/dist/components/seo/SeoLinkingSection.d.ts +5 -1
  26. package/dist/components/seo/SeoLinkingSection.d.ts.map +1 -1
  27. package/dist/components/seo/SeoLinkingSection.js +50 -3
  28. package/dist/components/seo/SeoLinkingSection.js.map +1 -1
  29. package/dist/lib/collection-routes.d.ts +2 -0
  30. package/dist/lib/collection-routes.d.ts.map +1 -1
  31. package/dist/lib/collection-routes.js +5 -0
  32. package/dist/lib/collection-routes.js.map +1 -1
  33. package/dist/lib/seo-service.d.ts +25 -2
  34. package/dist/lib/seo-service.d.ts.map +1 -1
  35. package/dist/lib/seo-service.js +23 -3
  36. package/dist/lib/seo-service.js.map +1 -1
  37. package/dist/views/SEO.d.ts.map +1 -1
  38. package/dist/views/SEO.js +1 -1
  39. package/dist/views/SEO.js.map +1 -1
  40. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
  41. package/dist/views/page-editor/PageSectionEditor.js +1 -1
  42. package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
  43. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
  44. package/dist/views/post-editor/PostSectionEditor.js +1 -1
  45. package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
  46. package/dist/views/seo/ContentTab.d.ts +2 -1
  47. package/dist/views/seo/ContentTab.d.ts.map +1 -1
  48. package/dist/views/seo/ContentTab.js +18 -12
  49. package/dist/views/seo/ContentTab.js.map +1 -1
  50. package/dist/views/seo/LinksTab.d.ts +3 -1
  51. package/dist/views/seo/LinksTab.d.ts.map +1 -1
  52. package/dist/views/seo/LinksTab.js +36 -6
  53. package/dist/views/seo/LinksTab.js.map +1 -1
  54. package/package.json +5 -5
  55. package/src/__tests__/components/seo-editor-pane.render.test.tsx +4 -1
  56. package/src/__tests__/components/seo-issue-fix-panel.render.test.tsx +65 -0
  57. package/src/__tests__/components/seo-linking-section.render.test.tsx +146 -0
  58. package/src/__tests__/lib/collection-routes.test.ts +13 -1
  59. package/src/__tests__/lib/seo-service-errors.test.ts +40 -2
  60. package/src/__tests__/views/seo-content-pane.render.test.tsx +8 -1
  61. package/src/components/seo/SeoEditorPane.tsx +3 -0
  62. package/src/components/seo/SeoIssueFixPanel.tsx +34 -10
  63. package/src/components/seo/SeoLinkingSection.tsx +116 -18
  64. package/src/lib/collection-routes.ts +13 -0
  65. package/src/lib/seo-service.ts +44 -4
  66. package/src/views/SEO.tsx +2 -1
  67. package/src/views/page-editor/PageSectionEditor.tsx +1 -0
  68. package/src/views/post-editor/PostSectionEditor.tsx +1 -0
  69. package/src/views/seo/ContentTab.tsx +40 -2
  70. package/src/views/seo/LinksTab.tsx +108 -19
@@ -2,7 +2,21 @@
2
2
 
3
3
  import { useEffect, useState } from 'react'
4
4
  import { Link2, Loader2, Sparkles } from 'lucide-react'
5
- import { fetchLinkSuggestions, type LinkSuggestion } from '../../lib/seo-service.js'
5
+ import { toast } from 'sonner'
6
+ import { sourceEditPath } from '../../lib/collection-routes.js'
7
+ import {
8
+ applySeoInternalLink,
9
+ fetchLinkSuggestions,
10
+ type LinkSuggestion,
11
+ } from '../../lib/seo-service.js'
12
+ import { fetchPlanInfo, hasPlanFeature, type PlanInfo } from '../../lib/plan.js'
13
+ import { PlanUpgradeCallout } from '../PlanUpgradeCallout.js'
14
+
15
+ const approveBtnCls =
16
+ 'inline-flex items-center justify-center gap-1.5 rounded-[7px] bg-primary px-2.5 py-1.5 text-[11.5px] font-medium text-primary-foreground transition-colors hover:opacity-90 focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50'
17
+
18
+ const editorBtnCls =
19
+ 'inline-flex items-center justify-center gap-1.5 rounded-[7px] border border-[var(--bdr)] bg-[var(--card)] px-2.5 py-1.5 text-[11.5px] font-medium text-[var(--txt)] transition-colors hover:bg-[var(--acc-l)] focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none'
6
20
 
7
21
  export function SeoLinkingSection({
8
22
  entityType,
@@ -11,6 +25,8 @@ export function SeoLinkingSection({
11
25
  externalLinks,
12
26
  internalLinkOk,
13
27
  externalLinkOk,
28
+ onNavigate,
29
+ planInfo: planInfoProp,
14
30
  }: {
15
31
  entityType: 'page' | 'post'
16
32
  entityId: string | null
@@ -18,10 +34,31 @@ export function SeoLinkingSection({
18
34
  externalLinks: number
19
35
  internalLinkOk: boolean
20
36
  externalLinkOk: boolean
37
+ onNavigate?: (path: string) => void
38
+ /** When omitted (page/post editors), fetched on mount. */
39
+ planInfo?: PlanInfo | null
21
40
  }) {
22
41
  const [loading, setLoading] = useState(false)
23
42
  const [suggestions, setSuggestions] = useState<LinkSuggestion[]>([])
24
43
  const [error, setError] = useState<string | null>(null)
44
+ const [applyingKey, setApplyingKey] = useState<string | null>(null)
45
+ const [planInfo, setPlanInfo] = useState<PlanInfo | null | undefined>(planInfoProp)
46
+
47
+ useEffect(() => {
48
+ if (planInfoProp !== undefined) {
49
+ setPlanInfo(planInfoProp)
50
+ return
51
+ }
52
+ let alive = true
53
+ void fetchPlanInfo().then((info) => {
54
+ if (alive) setPlanInfo(info)
55
+ })
56
+ return () => {
57
+ alive = false
58
+ }
59
+ }, [planInfoProp])
60
+
61
+ const inlineApplyEnabled = hasPlanFeature(planInfo, 'seo.inlineApply')
25
62
 
26
63
  useEffect(() => {
27
64
  if (!entityId) {
@@ -48,6 +85,33 @@ export function SeoLinkingSection({
48
85
  }
49
86
  }, [entityType, entityId])
50
87
 
88
+ async function handleApprove(suggestion: LinkSuggestion) {
89
+ if (!entityId) return
90
+ const key = `${suggestion.sourceEntityId}-${suggestion.fingerprint}`
91
+ setApplyingKey(key)
92
+ const res = await applySeoInternalLink({
93
+ sourceEntityId: suggestion.sourceEntityId,
94
+ targetEntityId: entityId,
95
+ anchorText: suggestion.anchorText,
96
+ fingerprint: suggestion.fingerprint,
97
+ })
98
+ setApplyingKey(null)
99
+ if (res.error) {
100
+ toast.error(res.error)
101
+ return
102
+ }
103
+ toast.success('Link added.')
104
+ setSuggestions((prev) =>
105
+ prev.filter(
106
+ (row) =>
107
+ !(
108
+ row.fingerprint === suggestion.fingerprint &&
109
+ row.sourceEntityId === suggestion.sourceEntityId
110
+ ),
111
+ ),
112
+ )
113
+ }
114
+
51
115
  return (
52
116
  <div className="space-y-4">
53
117
  <div className="grid grid-cols-2 gap-3">
@@ -90,22 +154,56 @@ export function SeoLinkingSection({
90
154
  <Sparkles className="h-3.5 w-3.5 text-[var(--acc)]" aria-hidden />
91
155
  Link opportunities
92
156
  </div>
157
+ {!inlineApplyEnabled && suggestions.some((s) => s.wrappable) ? (
158
+ <div className="mb-2">
159
+ <PlanUpgradeCallout feature="seo.inlineApply" upgradeUrl={planInfo?.upgradeUrl} />
160
+ </div>
161
+ ) : null}
93
162
  <ul className="space-y-2">
94
- {suggestions.map((s) => (
95
- <li
96
- key={`${s.sourceEntityId}-${s.anchorText}`}
97
- className="rounded-[7px] border border-[var(--bdr)] bg-[var(--card)] p-2.5"
98
- >
99
- <div className="text-[12.5px] font-medium text-[var(--txt)]">{s.sourceTitle}</div>
100
- <p className="mt-0.5 text-[11px] text-[var(--muted)]">{s.sourceUrl}</p>
101
- <p className="mt-1 text-[11.5px] text-[var(--sub)]">
102
- Suggested anchor: <span className="text-[var(--txt)]">{s.anchorText}</span>
103
- </p>
104
- {s.excerpt && (
105
- <p className="mt-1 line-clamp-2 text-[11px] text-[var(--muted)]">{s.excerpt}</p>
106
- )}
107
- </li>
108
- ))}
163
+ {suggestions.map((s) => {
164
+ const key = `${s.sourceEntityId}-${s.fingerprint || s.anchorText}`
165
+ const busy = applyingKey === key
166
+ return (
167
+ <li
168
+ key={key}
169
+ className="rounded-[7px] border border-[var(--bdr)] bg-[var(--card)] p-2.5"
170
+ >
171
+ <div className="text-[12.5px] font-medium text-[var(--txt)]">{s.sourceTitle}</div>
172
+ <p className="mt-0.5 text-[11px] text-[var(--muted)]">{s.sourceUrl}</p>
173
+ <p className="mt-1 text-[11.5px] text-[var(--sub)]">
174
+ Suggested anchor: <span className="text-[var(--txt)]">{s.anchorText}</span>
175
+ </p>
176
+ {s.excerpt && (
177
+ <p className="mt-1 line-clamp-2 text-[11px] text-[var(--muted)]">{s.excerpt}</p>
178
+ )}
179
+ {s.wrappable && inlineApplyEnabled ? (
180
+ <div className="mt-2">
181
+ <button
182
+ type="button"
183
+ className={approveBtnCls}
184
+ disabled={busy}
185
+ onClick={() => void handleApprove(s)}
186
+ >
187
+ {busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden /> : null}
188
+ Approve link
189
+ </button>
190
+ </div>
191
+ ) : onNavigate ? (
192
+ <div className="mt-2">
193
+ <button
194
+ type="button"
195
+ className={editorBtnCls}
196
+ onClick={() =>
197
+ onNavigate(sourceEditPath(s.sourceCollection, s.sourceEntityId))
198
+ }
199
+ >
200
+ Open in editor
201
+ </button>
202
+ </div>
203
+ ) : null}
204
+ </li>
205
+ )
206
+ })}
109
207
  </ul>
110
208
  </div>
111
209
  ) : (
@@ -118,8 +216,8 @@ export function SeoLinkingSection({
118
216
  <div className="flex items-start gap-2 rounded-[7px] border border-[var(--bdr)] bg-[var(--bg)] p-2.5 text-[11px] text-[var(--sub)]">
119
217
  <Link2 className="mt-0.5 h-3.5 w-3.5 shrink-0 text-[var(--acc)]" aria-hidden />
120
218
  <span>
121
- Best practice: mix internal links (site structure + crawl paths) with selective outbound
122
- links (trust signals). We flag counts here; apply links in the content editor.
219
+ Wrappable suggestions can be approved here. Others need an editor pass so we do not invent
220
+ a sentence.
123
221
  </span>
124
222
  </div>
125
223
  </div>
@@ -24,6 +24,19 @@ export function editPathForDoc(
24
24
  return `/${collection}/${id}`
25
25
  }
26
26
 
27
+ /** Editor route for an inbound-link suggestion source document. */
28
+ export function sourceEditPath(
29
+ sourceCollection: string | undefined,
30
+ sourceEntityId: string,
31
+ ): string {
32
+ const collection = sourceCollection ?? 'pages'
33
+ return editPathForDoc(
34
+ { type: collection === 'pages' ? 'page' : 'post' },
35
+ collection,
36
+ sourceEntityId,
37
+ )
38
+ }
39
+
27
40
  /** The route that starts a new document in the correct editor. */
28
41
  export function newPathForCollection(
29
42
  col: CollectionRouteMeta | undefined,
@@ -111,6 +111,8 @@ export interface SeoOverview {
111
111
  searchPerformance: SearchPerformanceRow[]
112
112
  searchConsoleConnected: boolean
113
113
  lastAuditRunId: string | null
114
+ /** True when the last audit hit AUDIT_MAX_ENTITIES. */
115
+ auditTruncated?: boolean
114
116
  }
115
117
 
116
118
  const EMPTY_OVERVIEW: SeoOverview = {
@@ -122,6 +124,7 @@ const EMPTY_OVERVIEW: SeoOverview = {
122
124
  searchPerformance: [],
123
125
  searchConsoleConnected: false,
124
126
  lastAuditRunId: null,
127
+ auditTruncated: false,
125
128
  }
126
129
 
127
130
  export async function fetchSeoOverview(): Promise<SeoOverview> {
@@ -208,10 +211,29 @@ export type LlmsInclude = 'AUTO' | 'ALWAYS' | 'NEVER'
208
211
 
209
212
  export type ContentSeoFilter = 'all' | 'pages' | 'posts'
210
213
 
211
- export async function fetchSeoContentRecords(): Promise<SeoContentRecord[]> {
212
- const res = await cmsApi<{ records: SeoContentRecord[] }>('/seo/content')
214
+ export interface SeoContentList {
215
+ records: SeoContentRecord[]
216
+ total: number
217
+ page: number
218
+ pageSize: number
219
+ truncated: boolean
220
+ }
221
+
222
+ export async function fetchSeoContentRecords(
223
+ opts: { page?: number; pageSize?: number } = {},
224
+ ): Promise<SeoContentList> {
225
+ const page = opts.page ?? 1
226
+ const pageSize = opts.pageSize ?? 50
227
+ const qs = new URLSearchParams({ page: String(page), pageSize: String(pageSize) })
228
+ const res = await cmsApi<SeoContentList>(`/seo/content?${qs}`)
213
229
  throwIfError(res)
214
- return res.data?.records ?? []
230
+ return {
231
+ records: res.data?.records ?? [],
232
+ total: res.data?.total ?? 0,
233
+ page: res.data?.page ?? page,
234
+ pageSize: res.data?.pageSize ?? pageSize,
235
+ truncated: res.data?.truncated === true,
236
+ }
215
237
  }
216
238
 
217
239
  export async function fetchSeoContentRecord(
@@ -417,7 +439,7 @@ export interface SeoFixFieldChange {
417
439
  export interface SeoIssueFixSuggestion {
418
440
  autoFixable: boolean
419
441
  source?: 'deterministic' | 'ai'
420
- fixStrategy?: 'redirect' | 'fix-link'
442
+ fixStrategy?: 'redirect' | 'fix-link' | 'insert-link'
421
443
  aiUnavailable?: boolean
422
444
  fingerprint: string
423
445
  patch: Record<string, unknown>
@@ -498,6 +520,7 @@ export const SEO_INLINE_FIX_ISSUE_TYPES = new Set([
498
520
  'duplicate-meta-description',
499
521
  'missing-structured-data',
500
522
  'broken-internal-link',
523
+ 'orphan-page',
501
524
  ])
502
525
 
503
526
  export function isInlineSeoFixIssue(type: string): boolean {
@@ -1125,6 +1148,23 @@ export interface LinkSuggestion {
1125
1148
  anchorText: string
1126
1149
  confidence: number
1127
1150
  excerpt: string
1151
+ fingerprint: string
1152
+ wrappable: boolean
1153
+ sourceCollection?: string
1154
+ }
1155
+
1156
+ export async function applySeoInternalLink(input: {
1157
+ sourceEntityId: string
1158
+ targetEntityId: string
1159
+ anchorText: string
1160
+ fingerprint: string
1161
+ }): Promise<{ error?: string }> {
1162
+ const res = await cmsApi('/seo/links/apply', {
1163
+ method: 'POST',
1164
+ body: JSON.stringify(input),
1165
+ })
1166
+ if (res.error) return { error: res.error }
1167
+ return {}
1128
1168
  }
1129
1169
 
1130
1170
  export async function fetchLinkGraphSnapshot(): Promise<LinkGraphSnapshot> {
package/src/views/SEO.tsx CHANGED
@@ -249,11 +249,12 @@ export function SEO({ onNavigate, initialTab = 'overview' }: SEOProps) {
249
249
  <ContentTab
250
250
  initialEntityType={deepLink.entityType}
251
251
  initialEntityId={deepLink.entityId}
252
+ onNavigate={onNavigate}
252
253
  />
253
254
  )}
254
255
  </Tabs.Content>
255
256
  <Tabs.Content value="links" tabIndex={-1}>
256
- {activeTab === 'links' && <LinksTab onNavigate={onNavigate} />}
257
+ {activeTab === 'links' && <LinksTab onNavigate={onNavigate} planInfo={planInfo} />}
257
258
  </Tabs.Content>
258
259
  <Tabs.Content value="technical" tabIndex={-1}>
259
260
  {activeTab === 'technical' && <TechnicalTab />}
@@ -1013,6 +1013,7 @@ export function PageSectionEditor({
1013
1013
  onDraftChange={handlePageSettings}
1014
1014
  onClose={() => setRailTab('sections')}
1015
1015
  onSaved={handleSeoSaved}
1016
+ onNavigate={onNavigate}
1016
1017
  />
1017
1018
  )}
1018
1019
  </div>
@@ -849,6 +849,7 @@ export function PostSectionEditor({
849
849
  onDraftChange={handleFields}
850
850
  onClose={() => setRailTab('sections')}
851
851
  onSaved={handleSeoSaved}
852
+ onNavigate={onNavigate}
852
853
  />
853
854
  )}
854
855
  </div>
@@ -44,11 +44,18 @@ function parseEntityType(value: string | null | undefined): SeoEntityType | null
44
44
  export function ContentTab({
45
45
  initialEntityType,
46
46
  initialEntityId,
47
+ onNavigate,
47
48
  }: {
48
49
  initialEntityType?: string | null
49
50
  initialEntityId?: string | null
51
+ onNavigate?: (path: string) => void
50
52
  }) {
51
- const { data, loading, error, refetch } = useSeoResource(fetchSeoContentRecords, [])
53
+ const [page, setPage] = useState(1)
54
+ const pageSize = 50
55
+ const { data, loading, error, refetch } = useSeoResource(
56
+ () => fetchSeoContentRecords({ page, pageSize }),
57
+ [page],
58
+ )
52
59
  const [search, setSearch] = useState('')
53
60
  const [filter, setFilter] = useState<ContentSeoFilter>('all')
54
61
  const [selection, setSelection] = useState<Selection>(() => {
@@ -57,9 +64,13 @@ export function ContentTab({
57
64
  })
58
65
 
59
66
  const rows = useMemo(
60
- () => applyContentSeoQuery(data ?? [], { search, filter }),
67
+ () => applyContentSeoQuery(data?.records ?? [], { search, filter }),
61
68
  [data, search, filter],
62
69
  )
70
+ const total = data?.total ?? 0
71
+ const totalPages = Math.max(1, Math.ceil(total / pageSize))
72
+ const rangeStart = total === 0 ? 0 : (page - 1) * pageSize + 1
73
+ const rangeEnd = Math.min(page * pageSize, total)
63
74
 
64
75
  const toggleRow = (type: SeoEntityType, id: string) => {
65
76
  setSelection((prev) => (prev?.type === type && prev.id === id ? null : { type, id }))
@@ -183,6 +194,32 @@ export function ContentTab({
183
194
  </table>
184
195
  </div>
185
196
  )}
197
+ {total > pageSize && (
198
+ <div className="flex shrink-0 items-center justify-between gap-3 border-t border-[var(--bdr)] px-3 py-2">
199
+ <p className="text-sm text-[var(--sub)]">
200
+ {rangeStart}–{rangeEnd} of {total}
201
+ {data?.truncated ? ' (capped)' : ''}
202
+ </p>
203
+ <div className="flex gap-2">
204
+ <button
205
+ type="button"
206
+ disabled={page <= 1}
207
+ onClick={() => setPage((p) => Math.max(1, p - 1))}
208
+ className="rounded-md px-3 py-1.5 text-sm font-medium text-[var(--txt)] hover:bg-[var(--acc-l)] disabled:cursor-not-allowed disabled:opacity-50"
209
+ >
210
+ Previous
211
+ </button>
212
+ <button
213
+ type="button"
214
+ disabled={page >= totalPages}
215
+ onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
216
+ className="rounded-md px-3 py-1.5 text-sm font-medium text-[var(--txt)] hover:bg-[var(--acc-l)] disabled:cursor-not-allowed disabled:opacity-50"
217
+ >
218
+ Next
219
+ </button>
220
+ </div>
221
+ </div>
222
+ )}
186
223
  </SectionCard>
187
224
  </SplitPaneList>
188
225
 
@@ -194,6 +231,7 @@ export function ContentTab({
194
231
  entityId={selection.id}
195
232
  onClose={() => setSelection(null)}
196
233
  onSaved={refetch}
234
+ onNavigate={onNavigate}
197
235
  />
198
236
  )}
199
237
  </InspectorPane>
@@ -17,6 +17,7 @@ import {
17
17
  } from 'lucide-react'
18
18
  import { toast } from 'sonner'
19
19
  import {
20
+ applySeoInternalLink,
20
21
  fetchLinkGraphSnapshot,
21
22
  fetchLinkHealthIssues,
22
23
  fetchLinkSuggestions,
@@ -24,7 +25,9 @@ import {
24
25
  type LinkGraphEntityRef,
25
26
  type LinkSuggestion,
26
27
  } from '../../lib/seo-service.js'
27
- import { editPathForDoc } from '../../lib/collection-routes.js'
28
+ import { editPathForDoc, sourceEditPath } from '../../lib/collection-routes.js'
29
+ import { hasPlanFeature, type PlanInfo } from '../../lib/plan.js'
30
+ import { PlanUpgradeCallout } from '../../components/PlanUpgradeCallout.js'
28
31
  import {
29
32
  SectionCard,
30
33
  SeoLoading,
@@ -88,10 +91,39 @@ function ActionCard({
88
91
  function SuggestionList({
89
92
  suggestions,
90
93
  loading,
94
+ targetEntityId,
95
+ onNavigate,
96
+ onApplied,
97
+ planInfo,
91
98
  }: {
92
99
  suggestions: LinkSuggestion[]
93
100
  loading: boolean
101
+ targetEntityId: string
102
+ onNavigate?: (path: string) => void
103
+ onApplied?: (suggestion: LinkSuggestion) => void
104
+ planInfo?: PlanInfo | null
94
105
  }) {
106
+ const [applyingKey, setApplyingKey] = useState<string | null>(null)
107
+ const inlineApplyEnabled = hasPlanFeature(planInfo, 'seo.inlineApply')
108
+
109
+ async function handleApprove(suggestion: LinkSuggestion) {
110
+ const key = `${suggestion.sourceEntityId}-${suggestion.fingerprint}`
111
+ setApplyingKey(key)
112
+ const res = await applySeoInternalLink({
113
+ sourceEntityId: suggestion.sourceEntityId,
114
+ targetEntityId,
115
+ anchorText: suggestion.anchorText,
116
+ fingerprint: suggestion.fingerprint,
117
+ })
118
+ setApplyingKey(null)
119
+ if (res.error) {
120
+ toast.error(res.error)
121
+ return
122
+ }
123
+ toast.success('Link added.')
124
+ onApplied?.(suggestion)
125
+ }
126
+
95
127
  if (loading) {
96
128
  return (
97
129
  <p className="text-muted-foreground flex items-center gap-2 text-sm">
@@ -108,26 +140,64 @@ function SuggestionList({
108
140
  )
109
141
  }
110
142
  return (
111
- <ul className="divide-border divide-y rounded-lg border">
112
- {suggestions.map((s) => (
113
- <li key={`${s.sourceEntityId}-${s.anchorText}`} className="px-4 py-3">
114
- <p className="text-foreground text-sm font-medium">{s.sourceTitle}</p>
115
- <p className="text-muted-foreground mt-0.5 text-xs">
116
- Link &ldquo;{s.anchorText}&rdquo; → {s.sourceUrl || 'target page'}{' '}
117
- <span className="text-muted-foreground/80 tabular-nums">
118
- ({Math.round(s.confidence * 100)}% match)
119
- </span>
120
- </p>
121
- {s.excerpt && (
122
- <p className="text-muted-foreground mt-1 line-clamp-2 text-xs">{s.excerpt}</p>
123
- )}
124
- </li>
125
- ))}
126
- </ul>
143
+ <div className="space-y-3">
144
+ {!inlineApplyEnabled && suggestions.some((s) => s.wrappable) ? (
145
+ <PlanUpgradeCallout feature="seo.inlineApply" upgradeUrl={planInfo?.upgradeUrl} />
146
+ ) : null}
147
+ <ul className="divide-border divide-y rounded-lg border">
148
+ {suggestions.map((s) => {
149
+ const key = `${s.sourceEntityId}-${s.fingerprint || s.anchorText}`
150
+ const busy = applyingKey === key
151
+ return (
152
+ <li key={key} className="px-4 py-3">
153
+ <p className="text-foreground text-sm font-medium">{s.sourceTitle}</p>
154
+ <p className="text-muted-foreground mt-0.5 text-xs">
155
+ Link &ldquo;{s.anchorText}&rdquo; → {s.sourceUrl || 'target page'}{' '}
156
+ <span className="text-muted-foreground/80 tabular-nums">
157
+ ({Math.round(s.confidence * 100)}% match)
158
+ </span>
159
+ </p>
160
+ {s.excerpt && (
161
+ <p className="text-muted-foreground mt-1 line-clamp-2 text-xs">{s.excerpt}</p>
162
+ )}
163
+ {s.wrappable && inlineApplyEnabled ? (
164
+ <div className="mt-2">
165
+ <button
166
+ type="button"
167
+ className={btnPrimary}
168
+ disabled={busy}
169
+ onClick={() => void handleApprove(s)}
170
+ >
171
+ {busy ? <Loader2 className="h-4 w-4 animate-spin" aria-hidden /> : null}
172
+ Approve link
173
+ </button>
174
+ </div>
175
+ ) : onNavigate ? (
176
+ <div className="mt-2">
177
+ <button
178
+ type="button"
179
+ className={btnSecondary}
180
+ onClick={() => onNavigate(sourceEditPath(s.sourceCollection, s.sourceEntityId))}
181
+ >
182
+ Open in editor
183
+ </button>
184
+ </div>
185
+ ) : null}
186
+ </li>
187
+ )
188
+ })}
189
+ </ul>
190
+ </div>
127
191
  )
128
192
  }
129
193
 
130
- export function LinksTab({ onNavigate }: { onNavigate?: (path: string) => void }) {
194
+ export function LinksTab({
195
+ onNavigate,
196
+ planInfo,
197
+ }: {
198
+ onNavigate?: (path: string) => void
199
+ planInfo?: PlanInfo | null
200
+ }) {
131
201
  const { data, loading, error, refetch } = useSeoResource(fetchLinkGraphSnapshot, [])
132
202
  const [indexing, setIndexing] = useState(false)
133
203
  const [healthLoading, setHealthLoading] = useState(false)
@@ -379,7 +449,26 @@ export function LinksTab({ onNavigate }: { onNavigate?: (path: string) => void }
379
449
  <p className="text-foreground mb-2 text-sm font-medium">
380
450
  AI suggestions for &ldquo;{selectedOrphan.title}&rdquo;
381
451
  </p>
382
- <SuggestionList suggestions={suggestions} loading={suggestLoading} />
452
+ <SuggestionList
453
+ suggestions={suggestions}
454
+ loading={suggestLoading}
455
+ targetEntityId={selectedOrphan.id}
456
+ onNavigate={onNavigate}
457
+ planInfo={planInfo}
458
+ onApplied={(applied) => {
459
+ setSuggestions((prev) =>
460
+ prev.filter(
461
+ (row) =>
462
+ !(
463
+ row.fingerprint === applied.fingerprint &&
464
+ row.sourceEntityId === applied.sourceEntityId
465
+ ),
466
+ ),
467
+ )
468
+ refetch()
469
+ void loadSuggestions(selectedOrphan)
470
+ }}
471
+ />
383
472
  </div>
384
473
  )}
385
474
  </div>