sanity-plugin-seofields 1.2.4 → 1.2.6

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 (49) hide show
  1. package/dist/index.cjs +2604 -0
  2. package/dist/index.cjs.map +1 -0
  3. package/dist/index.d.cts +422 -0
  4. package/dist/index.d.ts +339 -492
  5. package/dist/index.js +1284 -2013
  6. package/dist/index.js.map +1 -1
  7. package/dist/next.cjs +182 -0
  8. package/dist/next.cjs.map +1 -0
  9. package/dist/next.d.cts +241 -0
  10. package/dist/next.d.ts +202 -295
  11. package/dist/next.js +110 -70
  12. package/dist/next.js.map +1 -1
  13. package/dist/types-B91ena4g.d.cts +89 -0
  14. package/dist/types-B91ena4g.d.ts +89 -0
  15. package/package.json +37 -18
  16. package/dist/index.d.mts +0 -575
  17. package/dist/index.mjs +0 -3292
  18. package/dist/index.mjs.map +0 -1
  19. package/dist/next.d.mts +0 -334
  20. package/dist/next.mjs +0 -102
  21. package/dist/next.mjs.map +0 -1
  22. package/sanity.json +0 -8
  23. package/src/components/SeoHealthDashboard.tsx +0 -1568
  24. package/src/components/SeoHealthPane.tsx +0 -81
  25. package/src/components/SeoHealthTool.tsx +0 -11
  26. package/src/components/SeoPreview.tsx +0 -178
  27. package/src/components/meta/MetaDescription.tsx +0 -39
  28. package/src/components/meta/MetaTitle.tsx +0 -44
  29. package/src/components/openGraph/OgDescription.tsx +0 -46
  30. package/src/components/openGraph/OgTitle.tsx +0 -45
  31. package/src/components/twitter/twitterDescription.tsx +0 -45
  32. package/src/components/twitter/twitterTitle.tsx +0 -45
  33. package/src/helpers/SeoMetaTags.tsx +0 -154
  34. package/src/helpers/seoMeta.ts +0 -283
  35. package/src/index.ts +0 -26
  36. package/src/next.ts +0 -12
  37. package/src/plugin.ts +0 -344
  38. package/src/schemas/index.ts +0 -121
  39. package/src/schemas/types/index.ts +0 -20
  40. package/src/schemas/types/metaAttribute/index.ts +0 -60
  41. package/src/schemas/types/metaTag/index.ts +0 -17
  42. package/src/schemas/types/openGraph/index.ts +0 -114
  43. package/src/schemas/types/robots/index.ts +0 -26
  44. package/src/schemas/types/twitter/index.ts +0 -108
  45. package/src/types.ts +0 -108
  46. package/src/utils/fieldsUtils.ts +0 -160
  47. package/src/utils/seoUtils.ts +0 -423
  48. package/src/utils/utils.ts +0 -9
  49. package/v2-incompatible.js +0 -11
@@ -1,1568 +0,0 @@
1
- import React, {useCallback, useEffect, useMemo, useState} from 'react'
2
- import {useClient, useWorkspace} from 'sanity'
3
- import {useIntentLink} from 'sanity/router'
4
- import {usePaneRouter} from 'sanity/structure'
5
- import styled, {keyframes} from 'styled-components'
6
-
7
- import {DocumentWithSeoHealth, SeoHealthMetrics} from '../types'
8
-
9
- const DashboardContainer = styled.div`
10
- width: 100%;
11
- min-height: 100%;
12
- background: #f0f2f5;
13
- padding: 28px 32px;
14
- box-sizing: border-box;
15
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
16
- `
17
-
18
- const PageHeader = styled.div`
19
- margin-bottom: 28px;
20
- `
21
-
22
- const PageTitle = styled.h1`
23
- margin: 0 0 6px 0;
24
- font-size: 22px;
25
- font-weight: 700;
26
- color: #111827;
27
- letter-spacing: -0.3px;
28
- display: flex;
29
- align-items: center;
30
- gap: 10px;
31
- `
32
-
33
- const PreviewBadge = styled.span`
34
- display: inline-block;
35
- background: #fef3c7;
36
- color: #92400e;
37
- font-size: 11px;
38
- font-weight: 600;
39
- padding: 4px 8px;
40
- border-radius: 4px;
41
- text-transform: uppercase;
42
- letter-spacing: 0.5px;
43
- margin-left: 8px;
44
- `
45
-
46
- const PageSubtitle = styled.p`
47
- margin: 0;
48
- font-size: 13px;
49
- color: #6b7280;
50
- `
51
-
52
- const StatsGrid = styled.div`
53
- display: grid;
54
- grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
55
- gap: 14px;
56
- margin-bottom: 20px;
57
- `
58
-
59
- const StatCard = styled.div<{$accent?: string}>`
60
- background: #ffffff;
61
- border-radius: 10px;
62
- padding: 16px 18px;
63
- box-shadow:
64
- 0 1px 3px rgba(0, 0, 0, 0.07),
65
- 0 1px 2px rgba(0, 0, 0, 0.05);
66
- border-left: ${(p) => (p.$accent ? `4px solid ${p.$accent}` : '4px solid transparent')};
67
- transition: box-shadow 0.15s ease;
68
-
69
- &:hover {
70
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
71
- }
72
- `
73
-
74
- const StatLabel = styled.div`
75
- font-size: 11px;
76
- font-weight: 500;
77
- color: #9ca3af;
78
- text-transform: uppercase;
79
- letter-spacing: 0.5px;
80
- margin-bottom: 8px;
81
- `
82
-
83
- const StatValue = styled.div`
84
- font-size: 26px;
85
- font-weight: 700;
86
- color: #111827;
87
- line-height: 1;
88
- `
89
-
90
- const ControlsBar = styled.div`
91
- background: #ffffff;
92
- border-radius: 10px;
93
- padding: 14px 18px;
94
- display: flex;
95
- align-items: center;
96
- gap: 12px;
97
- flex-wrap: wrap;
98
- margin-bottom: 20px;
99
- box-shadow: 0 1px 3px rgba(0, 0, 0, 0.07);
100
- `
101
-
102
- const SearchWrapper = styled.div`
103
- position: relative;
104
- flex: 1;
105
- min-width: 220px;
106
- `
107
-
108
- const SearchIconSvg = styled.span`
109
- position: absolute;
110
- left: 11px;
111
- top: 50%;
112
- transform: translateY(-50%);
113
- color: #9ca3af;
114
- display: flex;
115
- align-items: center;
116
- pointer-events: none;
117
- `
118
-
119
- const SearchInput = styled.input`
120
- width: 100%;
121
- height: 36px;
122
- padding: 0 12px 0 34px;
123
- border: 1px solid #e5e7eb;
124
- border-radius: 7px;
125
- font-size: 13px;
126
- color: #111827;
127
- background: #f9fafb;
128
- box-sizing: border-box;
129
- outline: none;
130
- transition:
131
- border-color 0.15s,
132
- background 0.15s;
133
-
134
- &::placeholder {
135
- color: #9ca3af;
136
- }
137
-
138
- &:focus {
139
- border-color: #6366f1;
140
- background: #fff;
141
- box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
142
- }
143
- `
144
-
145
- const StyledSelect = styled.select`
146
- height: 36px;
147
- padding: 0 32px 0 12px;
148
- border: 1px solid #e5e7eb;
149
- border-radius: 7px;
150
- font-size: 13px;
151
- color: #374151;
152
- background: #f9fafb
153
- url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%236b7280' d='M6 8L1 3h10z'/%3E%3C/svg%3E")
154
- no-repeat right 10px center;
155
- appearance: none;
156
- outline: none;
157
- cursor: pointer;
158
- transition: border-color 0.15s;
159
-
160
- &:focus {
161
- border-color: #6366f1;
162
- background-color: #fff;
163
- box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
164
- }
165
- `
166
-
167
- const TableCard = styled.div`
168
- background: #ffffff;
169
- border-radius: 10px;
170
- box-shadow: 0 1px 3px rgba(0, 0, 0, 0.07);
171
- overflow: hidden;
172
- `
173
-
174
- const TableHeader = styled.div`
175
- display: flex;
176
- align-items: center;
177
- padding: 11px 20px;
178
- background: #f9fafb;
179
- border-bottom: 1px solid #e5e7eb;
180
- font-size: 11px;
181
- font-weight: 600;
182
- color: #6b7280;
183
- text-transform: uppercase;
184
- letter-spacing: 0.5px;
185
- gap: 12px;
186
- `
187
-
188
- const TableRow = styled.div`
189
- display: flex;
190
- align-items: center;
191
- padding: 13px 20px;
192
- border-bottom: 1px solid #f3f4f6;
193
- gap: 12px;
194
- transition: background 0.1s;
195
-
196
- &:last-child {
197
- border-bottom: none;
198
- }
199
-
200
- &:hover {
201
- background: #fafafa;
202
- }
203
- `
204
-
205
- const ColTitle = styled.div`
206
- flex: 2;
207
- min-width: 0;
208
- `
209
-
210
- const TitleWrapper = styled.div`
211
- display: flex;
212
- align-items: center;
213
- gap: 4px;
214
- flex-wrap: wrap;
215
- min-width: 0;
216
- `
217
-
218
- /* Constrains the title + doc-id block so text-overflow works inside flex */
219
- const TitleCell = styled.div`
220
- min-width: 0;
221
- overflow: hidden;
222
- flex: 1;
223
- `
224
-
225
- const ColType = styled.div`
226
- flex: 0.8;
227
- min-width: 80px;
228
- `
229
-
230
- const ColScore = styled.div`
231
- flex: 0.6;
232
- min-width: 70px;
233
- `
234
-
235
- const ColIssues = styled.div`
236
- flex: 2;
237
- min-width: 0;
238
- `
239
-
240
- const DocTitleLink = styled.a`
241
- font-size: 13px;
242
- font-weight: 600;
243
- color: #4f46e5;
244
- white-space: nowrap;
245
- overflow: hidden;
246
- text-overflow: ellipsis;
247
- text-decoration: none;
248
- display: block;
249
- transition: color 0.15s;
250
-
251
- &:hover {
252
- color: #4338ca;
253
- text-decoration: underline;
254
- }
255
- `
256
-
257
- const DocId = styled.div`
258
- font-size: 11px;
259
- color: #9ca3af;
260
- margin-top: 2px;
261
- white-space: nowrap;
262
- overflow: hidden;
263
- text-overflow: ellipsis;
264
- `
265
-
266
- const TypeBadge = styled.span<{$bgColor?: string; $textColor?: string}>`
267
- display: inline-block;
268
- padding: 3px 8px;
269
- border-radius: 5px;
270
- font-size: 11px;
271
- font-weight: 500;
272
- background: ${(p) => p.$bgColor || '#ede9fe'};
273
- color: ${(p) => p.$textColor || '#5b21b6'};
274
- `
275
-
276
- const TypeText = styled.span`
277
- font-size: 12px;
278
- font-weight: 500;
279
- color: #374151;
280
- `
281
-
282
- const CustomBadge = styled.span<{$bgColor?: string; $textColor?: string; $fontSize?: string}>`
283
- display: inline-block;
284
- padding: 2px 6px;
285
- border-radius: 4px;
286
- font-size: ${(p) => p.$fontSize || '10px'};
287
- font-weight: 600;
288
- margin-left: 6px;
289
- background: ${(p) => p.$bgColor || '#e0e7ff'};
290
- color: ${(p) => p.$textColor || '#3730a3'};
291
- white-space: nowrap;
292
- `
293
-
294
- const ScoreBadge = styled.span<{$score: number}>`
295
- display: inline-block;
296
- padding: 4px 10px;
297
- border-radius: 6px;
298
- font-size: 12px;
299
- font-weight: 700;
300
- background: ${(p) => {
301
- if (p.$score >= 80) return '#d1fae5'
302
- if (p.$score >= 60) return '#fef3c7'
303
- if (p.$score >= 40) return '#ffedd5'
304
- return '#fee2e2'
305
- }};
306
- color: ${(p) => {
307
- if (p.$score >= 80) return '#065f46'
308
- if (p.$score >= 60) return '#92400e'
309
- if (p.$score >= 40) return '#9a3412'
310
- return '#991b1b'
311
- }};
312
- `
313
-
314
- const IssueTag = styled.div`
315
- font-size: 11px;
316
- color: #ef4444;
317
- line-height: 1.5;
318
- white-space: nowrap;
319
- overflow: hidden;
320
- text-overflow: ellipsis;
321
- `
322
-
323
- const MoreIssues = styled.div`
324
- font-size: 11px;
325
- color: #6b7280;
326
- cursor: pointer;
327
- transition: color 0.15s;
328
-
329
- &:hover {
330
- color: #374151;
331
- }
332
- `
333
-
334
- const MoreIssuesWrapper = styled.div`
335
- position: relative;
336
- display: inline-block;
337
- `
338
-
339
- const IssuesPopover = styled.div<{
340
- $left?: number
341
- }>`
342
- position: absolute;
343
- bottom: auto;
344
- left: 0;
345
- transform: translateY(calc(-100% - 14px));
346
- background: #1f2937;
347
- color: #ffffff;
348
- padding: 12px;
349
- border-radius: 8px;
350
- font-size: 12px;
351
- z-index: 50;
352
- box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
353
- width: 280px;
354
- word-break: break-word;
355
- line-height: 1.5;
356
-
357
- &::after {
358
- content: '';
359
- position: absolute;
360
- bottom: -6px;
361
- left: 12px;
362
- width: 0;
363
- height: 0;
364
- border-left: 6px solid transparent;
365
- border-right: 6px solid transparent;
366
- border-top: 6px solid #1f2937;
367
- }
368
- `
369
-
370
- const PopoverIssueItem = styled.div`
371
- display: flex;
372
- gap: 6px;
373
- margin-bottom: 6px;
374
-
375
- &:last-child {
376
- margin-bottom: 0;
377
- }
378
- `
379
-
380
- const UpgradeContainer = styled.div`
381
- display: flex;
382
- align-items: center;
383
- justify-content: center;
384
- min-height: 100%;
385
- padding: 60px 24px;
386
- `
387
-
388
- const UpgradeBox = styled.div`
389
- background: #ffffff;
390
- border-radius: 16px;
391
- padding: 48px 40px;
392
- max-width: 480px;
393
- width: 100%;
394
- text-align: center;
395
- box-shadow:
396
- 0 4px 24px rgba(0, 0, 0, 0.08),
397
- 0 1px 4px rgba(0, 0, 0, 0.05);
398
- border: 1px solid #e5e7eb;
399
- `
400
-
401
- const UpgradeLock = styled.div`
402
- font-size: 40px;
403
- margin-bottom: 16px;
404
- `
405
-
406
- const UpgradeTitle = styled.h2`
407
- margin: 0 0 10px;
408
- font-size: 20px;
409
- font-weight: 700;
410
- color: #111827;
411
- `
412
-
413
- const UpgradeText = styled.p`
414
- margin: 0 0 20px;
415
- font-size: 14px;
416
- color: #6b7280;
417
- line-height: 1.6;
418
- `
419
-
420
- const UpgradeCode = styled.pre`
421
- background: #f3f4f6;
422
- border-radius: 8px;
423
- padding: 14px 16px;
424
- font-size: 12px;
425
- color: #374151;
426
- text-align: left;
427
- margin: 0 0 24px;
428
- overflow-x: auto;
429
- line-height: 1.6;
430
- border: 1px solid #e5e7eb;
431
- `
432
-
433
- const UpgradeButton = styled.a`
434
- display: inline-block;
435
- background: #4f46e5;
436
- color: #ffffff;
437
- font-size: 14px;
438
- font-weight: 600;
439
- padding: 10px 24px;
440
- border-radius: 8px;
441
- text-decoration: none;
442
- transition: background 0.15s;
443
-
444
- &:hover {
445
- background: #4338ca;
446
- }
447
- `
448
-
449
- const ReloadButton = styled.button`
450
- display: inline-block;
451
- background: transparent;
452
- color: #6b7280;
453
- font-size: 13px;
454
- font-weight: 500;
455
- padding: 8px 20px;
456
- border-radius: 8px;
457
- border: 1px solid #d1d5db;
458
- cursor: pointer;
459
- margin-top: 10px;
460
- transition:
461
- background 0.15s,
462
- color 0.15s,
463
- border-color 0.15s;
464
-
465
- &:hover {
466
- background: #f3f4f6;
467
- color: #374151;
468
- border-color: #9ca3af;
469
- }
470
- `
471
-
472
- // Sub-component so useIntentLink can be called at the top level of a component (not inside .map)
473
- const DocTitleAnchor: React.FC<{
474
- id: string
475
- type: string
476
- structureTool?: string
477
- children: React.ReactNode
478
- }> = ({id, type, structureTool, children}) => {
479
- const {basePath} = useWorkspace()
480
- const {onClick: intentOnClick, href: intentHref} = useIntentLink({intent: 'edit', params: {id, type}})
481
- // When a specific structure tool name is provided, build a tool-scoped intent URL so that
482
- // Sanity routes directly to that tool instead of letting the router pick the first match.
483
- const href = structureTool
484
- ? `${basePath}/${structureTool}/intent/edit/id=${id};type=${type}/`
485
- : intentHref
486
- const onClick = structureTool ? undefined : intentOnClick
487
- return (
488
- <DocTitleLink href={href} onClick={onClick} title="Open document">
489
- {children}
490
- </DocTitleLink>
491
- )
492
- }
493
-
494
- // Wrapper that applies DocTitleLink styles to the ChildLink <a> rendered by Sanity's pane router
495
- const PaneLinkWrapper = styled.span`
496
- display: block;
497
- min-width: 0;
498
- overflow: hidden;
499
-
500
- a {
501
- font-size: 13px;
502
- font-weight: 600;
503
- color: #4f46e5;
504
- white-space: nowrap;
505
- overflow: hidden;
506
- text-overflow: ellipsis;
507
- text-decoration: none;
508
- display: block;
509
- transition: color 0.15s;
510
-
511
- &:hover {
512
- color: #4338ca;
513
- text-decoration: underline;
514
- }
515
- }
516
- `
517
-
518
- // Sub-component for desk-structure split-pane navigation.
519
- // Uses ChildLink from usePaneRouter to open the document editor to the right
520
- // while keeping the SEO Health pane visible on the left.
521
- const DocTitleAnchorPane: React.FC<{id: string; type: string; children: React.ReactNode}> = ({
522
- id,
523
- type,
524
- children,
525
- }) => {
526
- const {ChildLink} = usePaneRouter()
527
- return (
528
- <PaneLinkWrapper>
529
- <ChildLink childId={id} childParameters={{type}}>
530
- {children}
531
- </ChildLink>
532
- </PaneLinkWrapper>
533
- )
534
- }
535
-
536
- // Sub-component to safely call docBadge outside a .map expression
537
- const DocBadgeRenderer: React.FC<{
538
- doc: DocumentWithSeoHealth & Record<string, unknown>
539
- docBadge: (
540
- doc: DocumentWithSeoHealth & Record<string, unknown>,
541
- ) => {label: string; bgColor?: string; textColor?: string; fontSize?: string} | undefined
542
- }> = ({doc, docBadge}) => {
543
- const badge = docBadge(doc)
544
- if (!badge) return null
545
- return (
546
- <CustomBadge $bgColor={badge.bgColor} $textColor={badge.textColor} $fontSize={badge.fontSize}>
547
- {badge.label}
548
- </CustomBadge>
549
- )
550
- }
551
-
552
- const spin = keyframes`
553
- to { transform: rotate(360deg); }
554
- `
555
-
556
- const Spinner = styled.div`
557
- width: 28px;
558
- height: 28px;
559
- border: 3px solid #e5e7eb;
560
- border-top-color: #6366f1;
561
- border-radius: 50%;
562
- animation: ${spin} 0.7s linear infinite;
563
- margin: 0 auto 12px;
564
- `
565
-
566
- const LoadingState = styled.div`
567
- padding: 48px 24px;
568
- text-align: center;
569
- color: #6b7280;
570
- font-size: 13px;
571
- `
572
-
573
- const EmptyState = styled.div`
574
- padding: 48px 24px;
575
- text-align: center;
576
- color: #9ca3af;
577
- font-size: 13px;
578
- `
579
-
580
- /**
581
- * Color palette for dynamic document type badges
582
- * Colors are randomly assigned based on type hash for visual variety
583
- * while maintaining consistency across sessions
584
- */
585
- const TYPE_COLOR_PALETTE: Array<{bg: string; text: string}> = [
586
- {bg: '#dbeafe', text: '#0c4a6e'}, // Blue
587
- {bg: '#dcfce7', text: '#14532d'}, // Green
588
- {bg: '#fce7f3', text: '#500724'}, // Pink
589
- {bg: '#fed7aa', text: '#7c2d12'}, // Orange
590
- {bg: '#e9d5ff', text: '#581c87'}, // Purple
591
- {bg: '#f3e8ff', text: '#3f0f5c'}, // Deep Purple
592
- {bg: '#ccfbf1', text: '#134e4a'}, // Teal
593
- {bg: '#ddd6fe', text: '#3730a3'}, // Indigo
594
- {bg: '#fca5a5', text: '#7f1d1d'}, // Red
595
- {bg: '#a7f3d0', text: '#065f46'}, // Emerald
596
- {bg: '#fbbf24', text: '#78350f'}, // Amber
597
- {bg: '#c4b5fd', text: '#3b0764'}, // Violet
598
- {bg: '#f0fdf4', text: '#15803d'}, // Light Green
599
- {bg: '#fef2f2', text: '#991b1b'}, // Light Red
600
- {bg: '#f5f3ff', text: '#5b21b6'}, // Light Purple
601
- {bg: '#fffbeb', text: '#92400e'}, // Light Amber
602
- ]
603
-
604
- /**
605
- * Get dynamic color for a document type based on type name hash
606
- * Same type always gets the same color, but assignment is not fixed
607
- */
608
- const getTypeColor = (type: string): {bg: string; text: string} => {
609
- // Generate consistent hash from type string using simple arithmetic
610
- let hash = 0
611
- for (let i = 0; i < type.length; i += 1) {
612
- const char = type.charCodeAt(i)
613
- hash = Math.abs(hash * 31 + char)
614
- }
615
-
616
- // Use modulo to get index within palette range
617
- const colorIndex = hash % TYPE_COLOR_PALETTE.length
618
- return TYPE_COLOR_PALETTE[colorIndex]
619
- }
620
-
621
- const getStatusCategory = (score: number): SeoHealthMetrics['status'] => {
622
- if (score >= 80) return 'excellent'
623
- if (score >= 60) return 'good'
624
- if (score >= 40) return 'fair'
625
- if (score > 0) return 'poor'
626
- return 'missing'
627
- }
628
-
629
- const scoreMetaTitle = (title?: string): {score: number; issues: string[]} => {
630
- const issues: string[] = []
631
- let score = 0
632
-
633
- if (title && title.length >= 50 && title.length <= 60) {
634
- score = 15
635
- } else if (title && title.length > 0) {
636
- score = 10
637
- if (title.length < 50) issues.push('Meta title too short (< 50 chars)')
638
- if (title.length > 60) issues.push('Meta title too long (> 60 chars)')
639
- } else {
640
- issues.push('Missing meta title')
641
- }
642
-
643
- return {score, issues}
644
- }
645
-
646
- const scoreMetaDescription = (description?: string): {score: number; issues: string[]} => {
647
- const issues: string[] = []
648
- let score = 0
649
-
650
- if (description && description.length >= 120 && description.length <= 160) {
651
- score = 15
652
- } else if (description && description.length > 0) {
653
- score = 10
654
- if (description.length < 120) issues.push('Meta description too short (< 120 chars)')
655
- if (description.length > 160) issues.push('Meta description too long (> 160 chars)')
656
- } else {
657
- issues.push('Missing meta description')
658
- }
659
-
660
- return {score, issues}
661
- }
662
-
663
- const scoreOpenGraph = (openGraph?: Record<string, unknown>): {score: number; issues: string[]} => {
664
- const issues: string[] = []
665
- let score = 0
666
-
667
- if (openGraph) {
668
- if (openGraph.title) score += 6
669
- else issues.push('Missing OG title')
670
-
671
- if (openGraph.description) score += 6
672
- else issues.push('Missing OG description')
673
-
674
- if (openGraph.image) score += 6
675
- else issues.push('Missing OG image')
676
-
677
- if (openGraph.type) score += 7
678
- else issues.push('Missing OG type')
679
- } else {
680
- issues.push('Open Graph not configured')
681
- }
682
-
683
- return {score, issues}
684
- }
685
-
686
- const scoreTwitterCard = (twitter?: Record<string, unknown>): {score: number; issues: string[]} => {
687
- const issues: string[] = []
688
- let score = 0
689
-
690
- if (twitter) {
691
- if (twitter.title) score += 5
692
- else issues.push('Missing Twitter title')
693
-
694
- if (twitter.description) score += 5
695
- else issues.push('Missing Twitter description')
696
-
697
- if (twitter.image) score += 5
698
- else issues.push('Missing Twitter image')
699
- } else {
700
- issues.push('Twitter Card not configured')
701
- }
702
-
703
- return {score, issues}
704
- }
705
-
706
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
707
- const calculateHealthScore = (doc: any): SeoHealthMetrics => {
708
- let totalScore = 0
709
- const allIssues: string[] = []
710
-
711
- if (!doc.seo) {
712
- return {score: 0, status: 'missing', issues: ['SEO fields not configured']}
713
- }
714
-
715
- const {title, description, metaImage, keywords, robots, openGraph, twitter} = doc.seo
716
-
717
- // Meta Title
718
- const titleScore = scoreMetaTitle(title)
719
- totalScore += titleScore.score
720
- allIssues.push(...titleScore.issues)
721
-
722
- // Meta Description
723
- const descriptionScore = scoreMetaDescription(description)
724
- totalScore += descriptionScore.score
725
- allIssues.push(...descriptionScore.issues)
726
-
727
- // Meta Image
728
- if (metaImage) {
729
- totalScore += 10
730
- } else {
731
- allIssues.push('Missing meta image')
732
- }
733
-
734
- // Keywords
735
- if (keywords && keywords.length > 0) {
736
- totalScore += 10
737
- } else {
738
- allIssues.push('No keywords defined')
739
- }
740
-
741
- // Open Graph
742
- const ogScore = scoreOpenGraph(openGraph as Record<string, unknown> | undefined)
743
- totalScore += ogScore.score
744
- allIssues.push(...ogScore.issues)
745
-
746
- // Twitter Card
747
- const twitterScore = scoreTwitterCard(twitter as Record<string, unknown> | undefined)
748
- totalScore += twitterScore.score
749
- allIssues.push(...twitterScore.issues)
750
-
751
- // Robots settings
752
- if (robots && !robots.noIndex) {
753
- totalScore += 5
754
- }
755
-
756
- const status = getStatusCategory(totalScore)
757
-
758
- return {score: totalScore, status, issues: allIssues}
759
- }
760
-
761
- const resolveTypeLabel = (type: string, typeLabels?: Record<string, string>): string =>
762
- typeLabels?.[type] ?? type
763
-
764
- /**
765
- * Builds the GROQ projection snippet for the title field.
766
- * - undefined / 'title' → `title`
767
- * - 'name' → `"title": name`
768
- * - { post: 'title', product: 'name' } → `"title": select(_type == "post" => title, _type == "product" => name, title)`
769
- */
770
- const buildTitleProjection = (titleField?: string | Record<string, string>): string => {
771
- if (!titleField || titleField === 'title') return 'title'
772
- if (typeof titleField === 'string') return `"title": ${titleField}`
773
- const cases = Object.entries(titleField)
774
- .map(([type, field]) => `_type == "${type}" => ${field}`)
775
- .join(', ')
776
- return `"title": select(${cases}, title)`
777
- }
778
-
779
- export interface SeoHealthDashboardProps {
780
- icon?: string
781
- title?: string
782
- description?: string
783
- showTypeColumn?: boolean
784
- showDocumentId?: boolean
785
- /**
786
- * Limit the dashboard to specific document type names.
787
- * If both queryTypes and customQuery are provided, customQuery takes precedence.
788
- */
789
- queryTypes?: string[]
790
- /**
791
- * When using `queryTypes`, also filter by `seo != null`.
792
- * Set to `false` to include documents of those types even without an seo field.
793
- * Defaults to `true`.
794
- */
795
- queryRequireSeo?: boolean
796
- /**
797
- * A fully custom GROQ query used to fetch documents.
798
- * Must return objects with at least: _id, _type, title, seo, _updatedAt
799
- * Takes precedence over queryTypes.
800
- */
801
- customQuery?: string
802
- /**
803
- * The Sanity API version to use for the client (e.g. '2023-01-01').
804
- * Defaults to '2023-01-01'.
805
- */
806
- apiVersion?: string
807
- /**
808
- * License key for the SEO Health Dashboard.
809
- * Obtain a key at https://sanity-plugin-seofields.thehardik.in
810
- */
811
- licenseKey?: string
812
- /**
813
- * Map raw `_type` values to human-readable display labels used in the
814
- * Type column and the Type filter dropdown.
815
- * Any type without an entry falls back to the raw `_type` string.
816
- *
817
- * @example
818
- * typeLabels={{ productDrug: 'Products', singleCondition: 'Condition' }}
819
- */
820
- typeLabels?: Record<string, string>
821
- /**
822
- * Controls how the type is rendered in the Type column.
823
- * - `'badge'` (default) — coloured pill, consistent with score badges
824
- * - `'text'` — plain text, useful for dense layouts
825
- */
826
- typeColumnMode?: 'badge' | 'text'
827
- /**
828
- * The document field to use as the display title.
829
- *
830
- * - `string` — use this field for every document type (e.g. `'name'`)
831
- * - `Record<string, string>` — per-type mapping; unmapped types fall back to `title`
832
- *
833
- * @example
834
- * // Same field for all types
835
- * titleField: 'name'
836
- *
837
- * @example
838
- * // Different field per type
839
- * titleField: { post: 'title', product: 'name', category: 'label' }
840
- */
841
- titleField?: string | Record<string, string>
842
- /**
843
- * Callback function to render a custom badge next to the document title.
844
- * Receives the full document and should return badge data or undefined.
845
- *
846
- * @example
847
- * docBadge: (doc) => {
848
- * if (doc.services === 'NHS')
849
- * return { label: 'NHS', bgColor: '#e0f2fe', textColor: '#0369a1' }
850
- * if (doc.services === 'Private')
851
- * return { label: 'Private', bgColor: '#fef3c7', textColor: '#92400e' }
852
- * }
853
- */
854
- docBadge?: (
855
- doc: DocumentWithSeoHealth & Record<string, unknown>,
856
- ) => {label: string; bgColor?: string; textColor?: string; fontSize?: string} | undefined
857
- /**
858
- * Custom text shown while the license key is being verified.
859
- * Defaults to `"Verifying license…"`.
860
- */
861
- loadingLicense?: React.ReactNode
862
- /**
863
- * Custom text shown while documents are being fetched.
864
- * Defaults to `"Loading documents…"`.
865
- */
866
- loadingDocuments?: React.ReactNode
867
- /**
868
- * Custom text shown when the query returns zero results.
869
- * Defaults to `"No documents found"`.
870
- */
871
- noDocuments?: React.ReactNode
872
- /**
873
- * Enable preview/demo mode to show dummy data.
874
- * Useful for testing, documentation, or showcasing the dashboard.
875
- * When enabled, displays realistic sample documents with various SEO scores.
876
- * Defaults to `false`.
877
- */
878
- previewMode?: boolean
879
- /**
880
- * When `true`, clicking a document title opens the document editor as a split
881
- * pane to the right, keeping the SEO Health pane visible on the left.
882
- * This uses Sanity's pane router and requires the component to be rendered
883
- * inside a desk-structure pane context (i.e. via `createSeoHealthPane`).
884
- *
885
- * When `false` (default), clicking navigates to the document via the standard
886
- * intent-link system (full navigation).
887
- *
888
- * This is set to `true` automatically by `createSeoHealthPane`.
889
- */
890
- openInPane?: boolean
891
- /**
892
- * The `name` of the Sanity structure tool that contains the monitored documents.
893
- * When provided, clicking a document title navigates directly to that tool's
894
- * intent URL (`/{basePath}/{structureTool}/intent/edit/id=…;type=…/`) instead of
895
- * using the generic intent resolver, which always picks the first registered tool.
896
- *
897
- * Required when you have multiple structure tools and the documents live in a
898
- * non-default one (e.g. `name: 'common'`).
899
- *
900
- * @example
901
- * structureTool: 'common'
902
- */
903
- structureTool?: string
904
- }
905
-
906
- /**
907
- * Generate dummy data for preview mode showing various SEO health scenarios
908
- */
909
- const generateDummyData = (): DocumentWithSeoHealth[] => {
910
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
911
- const dummyDocs: any[] = [
912
- {
913
- _id: 'preview-post-1',
914
- _type: 'post',
915
- title: 'Getting Started with SEO Best Practices',
916
- slug: {current: 'getting-started-seo'},
917
- _updatedAt: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(),
918
- seo: {
919
- title: 'Getting Started with SEO Best Practices | My Blog',
920
- description:
921
- 'Learn the fundamentals of SEO optimization to improve your website visibility and search rankings.',
922
- keywords: ['seo', 'best practices', 'optimization'],
923
- metaImage: {_type: 'image', asset: {_ref: 'image-123', _type: 'reference'}},
924
- openGraph: {
925
- title: 'SEO Best Practices Guide',
926
- description: 'Master SEO optimization',
927
- image: {_type: 'image', asset: {_ref: 'image-123', _type: 'reference'}, alt: 'SEO Guide'},
928
- type: 'article',
929
- },
930
- twitter: {
931
- title: 'SEO Best Practices',
932
- description: 'Learn SEO optimization',
933
- image: {_type: 'image', asset: {_ref: 'image-123', _type: 'reference'}, alt: 'Guide'},
934
- card: 'summary_large_image',
935
- },
936
- },
937
- },
938
- {
939
- _id: 'preview-post-2',
940
- _type: 'post',
941
- title: 'Advanced Analytics Strategy',
942
- slug: {current: 'advanced-analytics'},
943
- _updatedAt: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString(),
944
- seo: {
945
- title: 'Advanced Analytics',
946
- description: 'Strategy tips',
947
- keywords: ['analytics', 'data'],
948
- openGraph: {
949
- title: 'Analytics Guide',
950
- },
951
- },
952
- },
953
- {
954
- _id: 'preview-page-1',
955
- _type: 'page',
956
- title: 'About Us',
957
- slug: {current: 'about'},
958
- _updatedAt: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString(),
959
- seo: {
960
- title: 'About',
961
- keywords: ['company', 'team'],
962
- metaImage: {_type: 'image', asset: {_ref: 'image-456', _type: 'reference'}},
963
- },
964
- },
965
- {
966
- _id: 'preview-post-3',
967
- _type: 'post',
968
- title: 'Content Marketing Trends for 2024',
969
- slug: {current: 'content-marketing-trends'},
970
- _updatedAt: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(),
971
- seo: {
972
- title: 'Content Marketing Trends 2024',
973
- description:
974
- 'Discover the latest content marketing trends and strategies to engage your audience effectively.',
975
- keywords: ['content marketing', 'trends', 'strategy', 'engagement'],
976
- metaImage: {_type: 'image', asset: {_ref: 'image-789', _type: 'reference'}},
977
- openGraph: {
978
- title: 'Content Marketing Trends 2024',
979
- description: 'Latest trends in content marketing',
980
- image: {_type: 'image', asset: {_ref: 'image-789', _type: 'reference'}, alt: 'Trends'},
981
- type: 'article',
982
- },
983
- twitter: {
984
- title: 'Content Marketing Trends',
985
- description: 'Discover the latest trends',
986
- card: 'summary',
987
- },
988
- },
989
- },
990
- {
991
- _id: 'preview-post-4',
992
- _type: 'product',
993
- title: 'Pro Plan',
994
- slug: {current: 'pro-plan'},
995
- _updatedAt: new Date(Date.now() - 15 * 24 * 60 * 60 * 1000).toISOString(),
996
- seo: {
997
- title: 'Pro',
998
- keywords: ['pricing'],
999
- },
1000
- },
1001
- {
1002
- _id: 'preview-page-2',
1003
- _type: 'page',
1004
- title: 'Contact',
1005
- slug: {current: 'contact'},
1006
- _updatedAt: new Date(Date.now() - 8 * 24 * 60 * 60 * 1000).toISOString(),
1007
- seo: {
1008
- openGraph: {
1009
- title: 'Get in Touch',
1010
- },
1011
- },
1012
- },
1013
- {
1014
- _id: 'preview-post-5',
1015
- _type: 'post',
1016
- title: 'Mobile Optimization Guide',
1017
- slug: {current: 'mobile-optimization'},
1018
- _updatedAt: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(),
1019
- seo: {
1020
- title: 'Mobile Optimization Guide: Best Practices for Responsive Design',
1021
- description:
1022
- 'Complete guide to mobile optimization including responsive design, performance tips, and user experience best practices for modern web development.',
1023
- keywords: ['mobile', 'optimization', 'responsive', 'performance'],
1024
- metaImage: {_type: 'image', asset: {_ref: 'image-mobile', _type: 'reference'}},
1025
- openGraph: {
1026
- title: 'Mobile Optimization Best Practices',
1027
- description: 'Master mobile web optimization',
1028
- image: {_type: 'image', asset: {_ref: 'image-mobile', _type: 'reference'}, alt: 'Mobile'},
1029
- type: 'article',
1030
- },
1031
- twitter: {
1032
- title: 'Mobile Optimization Tips',
1033
- description: 'Responsive design best practices',
1034
- image: {_type: 'image', asset: {_ref: 'image-mobile', _type: 'reference'}, alt: 'Mobile'},
1035
- card: 'summary_large_image',
1036
- },
1037
- },
1038
- },
1039
- ]
1040
-
1041
- // Calculate health scores and return
1042
- return dummyDocs.map((doc) => ({
1043
- ...doc,
1044
- health: calculateHealthScore(doc),
1045
- }))
1046
- }
1047
-
1048
- const SeoHealthDashboard: React.FC<SeoHealthDashboardProps> = ({
1049
- icon = '📊',
1050
- title = 'SEO Health Dashboard',
1051
- description = 'Monitor and optimize SEO fields across all your documents',
1052
- showTypeColumn = true,
1053
- showDocumentId = true,
1054
- queryTypes,
1055
- queryRequireSeo = true,
1056
- customQuery,
1057
- apiVersion = '2023-01-01',
1058
- licenseKey,
1059
- typeLabels,
1060
- typeColumnMode = 'badge',
1061
- titleField,
1062
- docBadge,
1063
- loadingLicense,
1064
- loadingDocuments,
1065
- noDocuments,
1066
- previewMode = false,
1067
- openInPane = false,
1068
- structureTool,
1069
- }) => {
1070
- const client = useClient({apiVersion})
1071
- const [licenseStatus, setLicenseStatus] = useState<'loading' | 'valid' | 'invalid'>('loading')
1072
- const [documents, setDocuments] = useState<DocumentWithSeoHealth[]>([])
1073
- const [loading, setLoading] = useState(true)
1074
- const [searchQuery, setSearchQuery] = useState('')
1075
- const [filterStatus, setFilterStatus] = useState<string>('all')
1076
- const [filterType, setFilterType] = useState<string>('all')
1077
- const [sortBy, setSortBy] = useState<'score' | 'title'>('score')
1078
- const [activePopover, setActivePopover] = useState<{
1079
- top: number
1080
- left: number
1081
- issues: string[]
1082
- } | null>(null)
1083
-
1084
- const VALIDATION_ENDPOINT = 'https://sanity-plugin-seofields.thehardik.in/api/validate-license'
1085
- const CACHE_TTL_MS = 60 * 60 * 1000 // 1 hour
1086
-
1087
- const validateLicense = useCallback(
1088
- async (forceRefresh = false) => {
1089
- // Preview mode bypasses license validation
1090
- if (previewMode) {
1091
- setLicenseStatus('valid')
1092
- return
1093
- }
1094
-
1095
- // No key provided
1096
- if (!licenseKey) {
1097
- setLicenseStatus('invalid')
1098
- return
1099
- }
1100
-
1101
- const projectId = client.config().projectId ?? ''
1102
- const cacheKey = `seofields_license_${projectId}`
1103
-
1104
- if (forceRefresh) {
1105
- try {
1106
- sessionStorage.removeItem(cacheKey)
1107
- } catch {
1108
- // ignore storage errors
1109
- }
1110
- }
1111
-
1112
- // Check sessionStorage cache
1113
- if (!forceRefresh) {
1114
- try {
1115
- const cached = sessionStorage.getItem(cacheKey)
1116
- if (cached) {
1117
- const {valid, ts} = JSON.parse(cached) as {valid: boolean; ts: number}
1118
- if (Date.now() - ts < CACHE_TTL_MS) {
1119
- setLicenseStatus(valid ? 'valid' : 'invalid')
1120
- return
1121
- }
1122
- }
1123
- } catch {
1124
- // ignore storage errors
1125
- }
1126
- }
1127
-
1128
- setLicenseStatus('loading')
1129
-
1130
- try {
1131
- const res = await fetch(VALIDATION_ENDPOINT, {
1132
- method: 'POST',
1133
- headers: {'Content-Type': 'application/json'},
1134
- body: JSON.stringify({licenseKey, projectId}),
1135
- })
1136
- const valid = res.ok
1137
- setLicenseStatus(valid ? 'valid' : 'invalid')
1138
- try {
1139
- sessionStorage.setItem(cacheKey, JSON.stringify({valid, ts: Date.now()}))
1140
- } catch {
1141
- // ignore storage errors
1142
- }
1143
- } catch {
1144
- // Network error — fail open to avoid blocking legitimate users
1145
- setLicenseStatus('valid')
1146
- }
1147
- },
1148
- // eslint-disable-next-line react-hooks/exhaustive-deps
1149
- [licenseKey, previewMode],
1150
- )
1151
-
1152
- useEffect(() => {
1153
- validateLicense()
1154
- // eslint-disable-next-line react-hooks/exhaustive-deps
1155
- }, [licenseKey, previewMode])
1156
-
1157
- const handleMouseEnterIssues = (el: HTMLDivElement | null, issues: string[]) => {
1158
- if (!el) return
1159
- const rect = el.getBoundingClientRect()
1160
- const popoverWidth = 280
1161
- const viewportWidth = window.innerWidth
1162
-
1163
- // Align popover left edge to trigger left edge, clamp within viewport
1164
- let left = rect.left
1165
- if (left + popoverWidth > viewportWidth - 10) left = viewportWidth - popoverWidth - 10
1166
- if (left < 10) left = 10
1167
-
1168
- setActivePopover({top: rect.top, left, issues})
1169
- }
1170
-
1171
- useEffect(() => {
1172
- const fetchDocuments = async () => {
1173
- try {
1174
- setLoading(true)
1175
-
1176
- // Use dummy data in preview mode
1177
- if (previewMode) {
1178
- setDocuments(generateDummyData())
1179
- return
1180
- }
1181
-
1182
- let groqQuery: string
1183
- let params: Record<string, unknown> = {}
1184
-
1185
- if (customQuery) {
1186
- // Mode 3: fully custom GROQ (user-provided)
1187
- groqQuery = customQuery
1188
- } else if (queryTypes && queryTypes.length > 0) {
1189
- // Mode 2: filter by specific document types (excluding drafts)
1190
- const seoFilter = queryRequireSeo ? ' && seo != null' : ''
1191
- const titleProj = buildTitleProjection(titleField)
1192
- groqQuery = `*[_type in $types${seoFilter} && !(_id in path("drafts.**"))]{
1193
- _id,
1194
- _type,
1195
- ${titleProj},
1196
- slug,
1197
- seo,
1198
- _updatedAt
1199
- }`
1200
- params = {types: queryTypes}
1201
- } else {
1202
- // Mode 1: default — all documents with an seo field (excluding drafts)
1203
- const titleProj = buildTitleProjection(titleField)
1204
- groqQuery = `*[seo != null && !(_id in path("drafts.**"))]{
1205
- _id,
1206
- _type,
1207
- ${titleProj},
1208
- slug,
1209
- seo,
1210
- _updatedAt
1211
- }`
1212
- }
1213
-
1214
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1215
- const result = await client.fetch(groqQuery, params, {perspective: 'published'})
1216
-
1217
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1218
- const docsWithHealth: DocumentWithSeoHealth[] = result.map((doc: any) => ({
1219
- ...doc,
1220
- health: calculateHealthScore(doc),
1221
- }))
1222
-
1223
- setDocuments(docsWithHealth)
1224
- } catch (error) {
1225
- console.error('Error fetching documents:', error)
1226
- } finally {
1227
- setLoading(false)
1228
- }
1229
- }
1230
-
1231
- fetchDocuments()
1232
- // eslint-disable-next-line react-hooks/exhaustive-deps
1233
- }, [
1234
- client,
1235
- customQuery,
1236
- queryRequireSeo,
1237
- // eslint-disable-next-line react-hooks/exhaustive-deps
1238
- JSON.stringify(queryTypes),
1239
- // eslint-disable-next-line react-hooks/exhaustive-deps
1240
- JSON.stringify(titleField),
1241
- previewMode,
1242
- ])
1243
-
1244
- const uniqueDocumentTypes = useMemo(() => {
1245
- const types = new Set(documents.map((doc) => doc._type))
1246
- return Array.from(types).sort()
1247
- }, [documents])
1248
-
1249
- const filteredAndSortedDocs = useMemo(() => {
1250
- let filtered = documents
1251
-
1252
- if (searchQuery) {
1253
- filtered = filtered.filter(
1254
- (doc) =>
1255
- doc.title?.toLowerCase().includes(searchQuery.toLowerCase()) ||
1256
- doc._id?.toLowerCase().includes(searchQuery.toLowerCase()),
1257
- )
1258
- }
1259
-
1260
- if (filterStatus !== 'all') {
1261
- filtered = filtered.filter((doc) => doc.health.status === filterStatus)
1262
- }
1263
-
1264
- if (filterType !== 'all') {
1265
- filtered = filtered.filter((doc) => doc._type === filterType)
1266
- }
1267
-
1268
- const sorted = [...filtered].sort((a, b) => {
1269
- if (sortBy === 'score') {
1270
- return b.health.score - a.health.score
1271
- }
1272
- return (a.title || '').localeCompare(b.title || '')
1273
- })
1274
-
1275
- return sorted
1276
- }, [documents, searchQuery, filterStatus, filterType, sortBy])
1277
-
1278
- const stats = useMemo(() => {
1279
- const total = documents.length
1280
- const excellent = documents.filter((d) => d.health.score >= 80).length
1281
- const good = documents.filter((d) => d.health.score >= 60 && d.health.score < 80).length
1282
- const fair = documents.filter((d) => d.health.score >= 40 && d.health.score < 60).length
1283
- const poor = documents.filter((d) => d.health.score > 0 && d.health.score < 40).length
1284
- const missing = documents.filter((d) => d.health.score === 0).length
1285
-
1286
- const avgScore =
1287
- total > 0 ? Math.round(documents.reduce((sum, d) => sum + d.health.score, 0) / total) : 0
1288
-
1289
- return {total, excellent, good, fair, poor, missing, avgScore}
1290
- }, [documents])
1291
-
1292
- const handleMouseLeave = useCallback(() => {
1293
- setActivePopover(null)
1294
- }, [])
1295
-
1296
- return (
1297
- <DashboardContainer>
1298
- {licenseStatus === 'loading' && (
1299
- <LoadingState style={{padding: '80px 24px'}}>
1300
- <Spinner />
1301
- {loadingLicense ?? 'Verifying license…'}
1302
- </LoadingState>
1303
- )}
1304
- {licenseStatus === 'invalid' && (
1305
- <UpgradeContainer>
1306
- <UpgradeBox>
1307
- {licenseKey ? (
1308
- <>
1309
- <UpgradeLock>❌</UpgradeLock>
1310
- <UpgradeTitle>Invalid License Key</UpgradeTitle>
1311
- <UpgradeText>
1312
- The license key you provided is invalid or has been revoked. Please check your key
1313
- and update it in the plugin config.
1314
- </UpgradeText>
1315
- <UpgradeCode>{`seofields({
1316
- healthDashboard: {
1317
- licenseKey: 'YOUR_LICENSE_KEY', // ← replace with a valid key
1318
- },
1319
- })`}</UpgradeCode>
1320
- <UpgradeButton
1321
- href="https://sanity-plugin-seofields.thehardik.in"
1322
- target="_blank"
1323
- rel="noopener noreferrer"
1324
- >
1325
- Get a New License Key →
1326
- </UpgradeButton>
1327
- <br />
1328
- {/* eslint-disable-next-line react/jsx-no-bind */}
1329
- <ReloadButton onClick={() => validateLicense(true)}>
1330
- Click here If You Just Updated Your Key
1331
- </ReloadButton>
1332
- </>
1333
- ) : (
1334
- <>
1335
- <UpgradeLock>🔒</UpgradeLock>
1336
- <UpgradeTitle>SEO Health Dashboard</UpgradeTitle>
1337
- <UpgradeText>
1338
- This feature requires a license key. Add your key to the plugin config to unlock
1339
- the full dashboard.
1340
- </UpgradeText>
1341
- <UpgradeCode>{`// sanity.config.ts
1342
- import { seofields } from 'sanity-plugin-seofields'
1343
-
1344
- export default defineConfig({
1345
- plugins: [
1346
- seofields({
1347
- healthDashboard: {
1348
- licenseKey: 'SEOF-XXXX-XXXX-XXXX',
1349
- },
1350
- }),
1351
- ],
1352
- })`}</UpgradeCode>
1353
- <UpgradeButton
1354
- href="https://sanity-plugin-seofields.thehardik.in"
1355
- target="_blank"
1356
- rel="noopener noreferrer"
1357
- >
1358
- Get a License Key →
1359
- </UpgradeButton>
1360
- </>
1361
- )}
1362
- </UpgradeBox>
1363
- </UpgradeContainer>
1364
- )}
1365
- {licenseStatus === 'valid' && (
1366
- <>
1367
- {/* Header */}
1368
- <PageHeader>
1369
- <PageTitle>
1370
- <span>
1371
- {icon} {title}
1372
- </span>
1373
- {previewMode && <PreviewBadge>Preview Mode</PreviewBadge>}
1374
- </PageTitle>
1375
- <PageSubtitle>{description}</PageSubtitle>
1376
- </PageHeader>
1377
- {/* Stats Grid */}
1378
- {!loading && (
1379
- <StatsGrid>
1380
- <StatCard>
1381
- <StatLabel>Total Docs</StatLabel>
1382
- <StatValue>{stats.total}</StatValue>
1383
- </StatCard>
1384
- <StatCard>
1385
- <StatLabel>Avg Score</StatLabel>
1386
- <StatValue>{stats.avgScore}%</StatValue>
1387
- </StatCard>
1388
- <StatCard $accent="#10b981">
1389
- <StatLabel>Excellent (80+)</StatLabel>
1390
- <StatValue>{stats.excellent}</StatValue>
1391
- </StatCard>
1392
- <StatCard $accent="#f59e0b">
1393
- <StatLabel>Good (60–79)</StatLabel>
1394
- <StatValue>{stats.good}</StatValue>
1395
- </StatCard>
1396
- <StatCard $accent="#f97316">
1397
- <StatLabel>Fair (40–59)</StatLabel>
1398
- <StatValue>{stats.fair}</StatValue>
1399
- </StatCard>
1400
- <StatCard $accent="#ef4444">
1401
- <StatLabel>Poor / Missing</StatLabel>
1402
- <StatValue>{stats.poor + stats.missing}</StatValue>
1403
- </StatCard>
1404
- </StatsGrid>
1405
- )}
1406
- {/* Controls */}
1407
- <ControlsBar>
1408
- <SearchWrapper>
1409
- <SearchIconSvg>
1410
- <svg width="14" height="14" viewBox="0 0 20 20" fill="currentColor">
1411
- <path
1412
- fillRule="evenodd"
1413
- d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"
1414
- clipRule="evenodd"
1415
- />
1416
- </svg>
1417
- </SearchIconSvg>
1418
- <SearchInput
1419
- placeholder="Search documents..."
1420
- value={searchQuery}
1421
- // eslint-disable-next-line react/jsx-no-bind
1422
- onChange={(e) => setSearchQuery(e.currentTarget.value)}
1423
- />
1424
- </SearchWrapper>
1425
- <StyledSelect
1426
- value={filterStatus}
1427
- // eslint-disable-next-line react/jsx-no-bind
1428
- onChange={(e) => setFilterStatus(e.currentTarget.value)}
1429
- >
1430
- <option value="all">All Status</option>
1431
- <option value="excellent">Excellent</option>
1432
- <option value="good">Good</option>
1433
- <option value="fair">Fair</option>
1434
- <option value="poor">Poor</option>
1435
- <option value="missing">Missing</option>
1436
- </StyledSelect>
1437
- {uniqueDocumentTypes.length > 1 && (
1438
- <StyledSelect
1439
- value={filterType}
1440
- // eslint-disable-next-line react/jsx-no-bind
1441
- onChange={(e) => setFilterType(e.currentTarget.value)}
1442
- >
1443
- <option value="all">All Types</option>
1444
- {uniqueDocumentTypes.map((type) => (
1445
- <option key={type} value={type}>
1446
- {resolveTypeLabel(type, typeLabels)}
1447
- </option>
1448
- ))}
1449
- </StyledSelect>
1450
- )}
1451
- <StyledSelect
1452
- value={sortBy}
1453
- // eslint-disable-next-line react/jsx-no-bind
1454
- onChange={(e) => setSortBy(e.currentTarget.value as 'score' | 'title')}
1455
- >
1456
- <option value="score">Sort by Score</option>
1457
- <option value="title">Sort by Title</option>
1458
- </StyledSelect>
1459
- </ControlsBar>
1460
- {/* Documents Table */}
1461
- <TableCard>
1462
- {loading && (
1463
- <LoadingState>
1464
- <Spinner />
1465
- {loadingDocuments ?? 'Loading documents…'}
1466
- </LoadingState>
1467
- )}
1468
- {!loading &&
1469
- (filteredAndSortedDocs.length === 0 ? (
1470
- <EmptyState>{noDocuments ?? 'No documents found'}</EmptyState>
1471
- ) : (
1472
- <>
1473
- <TableHeader>
1474
- <ColTitle>Title</ColTitle>
1475
- {showTypeColumn && <ColType>Type</ColType>}
1476
- <ColScore>Score</ColScore>
1477
- <ColIssues>Top Issues</ColIssues>
1478
- </TableHeader>
1479
- {filteredAndSortedDocs.map((doc) => {
1480
- return (
1481
- <TableRow key={doc._id}>
1482
- <ColTitle>
1483
- <TitleWrapper>
1484
- <TitleCell>
1485
- {openInPane ? (
1486
- <DocTitleAnchorPane id={doc._id} type={doc._type}>
1487
- {doc.title || 'Untitled'}
1488
- </DocTitleAnchorPane>
1489
- ) : (
1490
- <DocTitleAnchor id={doc._id} type={doc._type} structureTool={structureTool}>
1491
- {doc.title || 'Untitled'}
1492
- </DocTitleAnchor>
1493
- )}
1494
- {showDocumentId && <DocId>{doc._id}</DocId>}
1495
- </TitleCell>
1496
- {docBadge && (
1497
- <DocBadgeRenderer
1498
- doc={doc as DocumentWithSeoHealth & Record<string, unknown>}
1499
- docBadge={docBadge}
1500
- />
1501
- )}
1502
- </TitleWrapper>
1503
- </ColTitle>
1504
- {showTypeColumn && (
1505
- <ColType>
1506
- {typeColumnMode === 'text' ? (
1507
- <TypeText>{resolveTypeLabel(doc._type, typeLabels)}</TypeText>
1508
- ) : (
1509
- (() => {
1510
- const typeColor = getTypeColor(doc._type)
1511
- return (
1512
- <TypeBadge $bgColor={typeColor.bg} $textColor={typeColor.text}>
1513
- {resolveTypeLabel(doc._type, typeLabels)}
1514
- </TypeBadge>
1515
- )
1516
- })()
1517
- )}
1518
- </ColType>
1519
- )}
1520
- <ColScore>
1521
- <ScoreBadge $score={doc.health.score}>{doc.health.score}%</ScoreBadge>
1522
- </ColScore>
1523
- <ColIssues>
1524
- {doc.health.issues.slice(0, 2).map((issue) => (
1525
- <IssueTag key={`issue-${doc._id}-${issue}`}>• {issue}</IssueTag>
1526
- ))}
1527
- {doc.health.issues.length > 2 && (
1528
- <MoreIssuesWrapper
1529
- // eslint-disable-next-line react/jsx-no-bind
1530
- onMouseEnter={function (e) {
1531
- handleMouseEnterIssues(
1532
- e.currentTarget as HTMLDivElement,
1533
- doc.health.issues,
1534
- )
1535
- }}
1536
- onMouseLeave={handleMouseLeave}
1537
- >
1538
- <MoreIssues>+{doc.health.issues.length - 2} more issues</MoreIssues>
1539
- </MoreIssuesWrapper>
1540
- )}
1541
- </ColIssues>
1542
- </TableRow>
1543
- )
1544
- })}
1545
- </>
1546
- ))}
1547
- </TableCard>
1548
- {/* Single shared popover rendered outside the table */}
1549
- {activePopover && (
1550
- <IssuesPopover
1551
- style={{
1552
- top: activePopover.top,
1553
- left: activePopover.left,
1554
- transform: 'translateY(calc(-100% - 10px))',
1555
- }}
1556
- >
1557
- {activePopover.issues.map((issue) => (
1558
- <PopoverIssueItem key={issue}>⚠️ {issue}</PopoverIssueItem>
1559
- ))}
1560
- </IssuesPopover>
1561
- )}{' '}
1562
- </>
1563
- )}{' '}
1564
- </DashboardContainer>
1565
- )
1566
- }
1567
-
1568
- export default SeoHealthDashboard