@prenta/admin 1.6.2 → 1.7.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 +6 -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 +24 -1
  15. package/dist/__tests__/lib/seo-service-errors.test.js.map +1 -1
  16. package/dist/__tests__/views/seo-content-pane.render.test.js +1 -0
  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 +12 -1
  34. package/dist/lib/seo-service.d.ts.map +1 -1
  35. package/dist/lib/seo-service.js +10 -0
  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 +2 -2
  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 +3 -3
  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 +29 -0
  60. package/src/__tests__/views/seo-content-pane.render.test.tsx +1 -0
  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 +19 -1
  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 +3 -0
  70. package/src/views/seo/LinksTab.tsx +108 -19
@@ -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,
@@ -417,7 +417,7 @@ export interface SeoFixFieldChange {
417
417
  export interface SeoIssueFixSuggestion {
418
418
  autoFixable: boolean
419
419
  source?: 'deterministic' | 'ai'
420
- fixStrategy?: 'redirect' | 'fix-link'
420
+ fixStrategy?: 'redirect' | 'fix-link' | 'insert-link'
421
421
  aiUnavailable?: boolean
422
422
  fingerprint: string
423
423
  patch: Record<string, unknown>
@@ -498,6 +498,7 @@ export const SEO_INLINE_FIX_ISSUE_TYPES = new Set([
498
498
  'duplicate-meta-description',
499
499
  'missing-structured-data',
500
500
  'broken-internal-link',
501
+ 'orphan-page',
501
502
  ])
502
503
 
503
504
  export function isInlineSeoFixIssue(type: string): boolean {
@@ -1125,6 +1126,23 @@ export interface LinkSuggestion {
1125
1126
  anchorText: string
1126
1127
  confidence: number
1127
1128
  excerpt: string
1129
+ fingerprint: string
1130
+ wrappable: boolean
1131
+ sourceCollection?: string
1132
+ }
1133
+
1134
+ export async function applySeoInternalLink(input: {
1135
+ sourceEntityId: string
1136
+ targetEntityId: string
1137
+ anchorText: string
1138
+ fingerprint: string
1139
+ }): Promise<{ error?: string }> {
1140
+ const res = await cmsApi('/seo/links/apply', {
1141
+ method: 'POST',
1142
+ body: JSON.stringify(input),
1143
+ })
1144
+ if (res.error) return { error: res.error }
1145
+ return {}
1128
1146
  }
1129
1147
 
1130
1148
  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,9 +44,11 @@ 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
53
  const { data, loading, error, refetch } = useSeoResource(fetchSeoContentRecords, [])
52
54
  const [search, setSearch] = useState('')
@@ -194,6 +196,7 @@ export function ContentTab({
194
196
  entityId={selection.id}
195
197
  onClose={() => setSelection(null)}
196
198
  onSaved={refetch}
199
+ onNavigate={onNavigate}
197
200
  />
198
201
  )}
199
202
  </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>