3710 lines
170 KiB
TypeScript
3710 lines
170 KiB
TypeScript
import * as icon from '@mdi/js';
|
|
import Head from 'next/head';
|
|
import axios from 'axios';
|
|
import React, { ReactElement } from 'react';
|
|
import { ToastContainer, toast } from 'react-toastify';
|
|
import BaseButton from '../../components/BaseButton';
|
|
import BaseButtons from '../../components/BaseButtons';
|
|
import BaseIcon from '../../components/BaseIcon';
|
|
import CardBox from '../../components/CardBox';
|
|
import FormField from '../../components/FormField';
|
|
import LayoutAuthenticated from '../../layouts/Authenticated';
|
|
import SectionMain from '../../components/SectionMain';
|
|
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton';
|
|
import { getPageTitle } from '../../config';
|
|
import { getSiteEntitlements } from '../../helpers/siteEntitlements';
|
|
import { useAppSelector } from '../../stores/hooks';
|
|
|
|
type Entitlements = {
|
|
canAdvancedCrawl?: boolean;
|
|
canPlatformOutput?: boolean;
|
|
maxPagesPerCrawl?: number;
|
|
permissions?: {
|
|
advancedCrawl?: string;
|
|
platformOutput?: string;
|
|
};
|
|
};
|
|
|
|
type AnalysisPayload = {
|
|
requestedUrl?: string;
|
|
analyzedUrl?: string;
|
|
pageTitle?: string | null;
|
|
fetchedAt?: string;
|
|
statusCode?: number;
|
|
recommendationCount?: number;
|
|
notice?: string;
|
|
crawlPlan?: {
|
|
requestedPages?: number;
|
|
allowedPages?: number;
|
|
actualPagesAnalyzed?: number;
|
|
advancedCrawlEnabled?: boolean;
|
|
provider?: string;
|
|
includeTargets?: string[];
|
|
excludeTargets?: string[];
|
|
};
|
|
crawlSummary?: {
|
|
pagesWithStructuredData?: number;
|
|
pagesWithoutStructuredData?: number;
|
|
pagesWithInvalidJsonLd?: number;
|
|
failedPages?: number;
|
|
discoveredInternalPages?: number;
|
|
};
|
|
pages?: {
|
|
url?: string;
|
|
title?: string | null;
|
|
statusCode?: number | null;
|
|
hasStructuredData?: boolean;
|
|
schemaTypes?: string[];
|
|
jsonLdTypes?: string[];
|
|
wordpress?: {
|
|
detected?: boolean;
|
|
plugins?: {
|
|
key?: string;
|
|
label?: string;
|
|
category?: string;
|
|
confidence?: string;
|
|
}[];
|
|
};
|
|
}[];
|
|
failedPages?: {
|
|
url?: string;
|
|
error?: string;
|
|
}[];
|
|
entitlements?: Entitlements;
|
|
platform?: {
|
|
detected?: string;
|
|
label?: string;
|
|
matchedSignals?: string[];
|
|
};
|
|
schema?: {
|
|
hasStructuredData?: boolean;
|
|
types?: string[];
|
|
jsonLd?: {
|
|
count?: number;
|
|
types?: string[];
|
|
invalidBlocks?: { index: number; message: string }[];
|
|
};
|
|
microdata?: {
|
|
count?: number;
|
|
detected?: boolean;
|
|
types?: string[];
|
|
};
|
|
rdfa?: {
|
|
count?: number;
|
|
detected?: boolean;
|
|
types?: string[];
|
|
};
|
|
};
|
|
wordpress?: {
|
|
detected?: boolean;
|
|
detectedPageCount?: number;
|
|
plugins?: {
|
|
key?: string;
|
|
label?: string;
|
|
category?: string;
|
|
confidence?: string;
|
|
pageCount?: number;
|
|
pageUrls?: string[];
|
|
evidence?: string[];
|
|
implementationHint?: string;
|
|
}[];
|
|
schemaOwnership?: {
|
|
mode?: string;
|
|
label?: string;
|
|
summary?: string;
|
|
recommendedImplementation?: string;
|
|
notes?: string[];
|
|
} | null;
|
|
pluginRecommendations?: {
|
|
key?: string;
|
|
label?: string;
|
|
category?: string;
|
|
priority?: string;
|
|
title?: string;
|
|
summary?: string;
|
|
recommendedApproach?: string;
|
|
applicableSchemaTypes?: string[];
|
|
}[];
|
|
duplicateRisk?: {
|
|
level?: string;
|
|
label?: string;
|
|
summary?: string;
|
|
warnings?: string[];
|
|
affectedPlugins?: string[];
|
|
} | null;
|
|
};
|
|
error?: string;
|
|
};
|
|
|
|
type Recommendation = {
|
|
id: string;
|
|
title: string;
|
|
recommendation_type?: string;
|
|
schema_type?: string;
|
|
page_scope?: string;
|
|
priority?: string;
|
|
reason?: string;
|
|
expected_impact?: string;
|
|
suggested_schema?: string | null;
|
|
};
|
|
|
|
type ReportResponse = {
|
|
site?: {
|
|
id: string;
|
|
name?: string;
|
|
base_url?: string;
|
|
detected_platform?: string;
|
|
crawl_status?: string;
|
|
};
|
|
crawl?: {
|
|
id: string;
|
|
status?: string;
|
|
};
|
|
analysis?: AnalysisPayload | null;
|
|
recommendations?: Recommendation[];
|
|
entitlements?: Entitlements;
|
|
error?: string;
|
|
};
|
|
|
|
type SetupSectionId = 'targeting' | 'options' | 'limits';
|
|
type ResultsTabId = 'overview' | 'pages' | 'recommendations' | 'delivery';
|
|
type PageResultsFilterId = 'all' | 'withSchema' | 'missingSchema' | 'failed';
|
|
type RecommendationQuickFilterId = 'all' | 'fixFirst' | 'codeReady' | 'needsCode';
|
|
|
|
type SetupAccordionSectionProps = {
|
|
title: string;
|
|
description: string;
|
|
iconPath: string;
|
|
badge?: React.ReactNode;
|
|
isOpen: boolean;
|
|
onToggle: () => void;
|
|
children: React.ReactNode;
|
|
};
|
|
|
|
type ResultsTabButtonProps = {
|
|
label: string;
|
|
iconPath: string;
|
|
count?: number | string;
|
|
isActive: boolean;
|
|
onClick: () => void;
|
|
};
|
|
|
|
type PageFilterChipProps = {
|
|
label: string;
|
|
count: number;
|
|
iconPath: string;
|
|
isActive: boolean;
|
|
onClick: () => void;
|
|
};
|
|
|
|
type DeliverySummaryCardProps = {
|
|
label: string;
|
|
value: string | number;
|
|
helper: string;
|
|
iconPath: string;
|
|
toneClassName?: string;
|
|
};
|
|
|
|
type StatusBadgeProps = {
|
|
children: React.ReactNode;
|
|
className?: string;
|
|
iconPath?: string;
|
|
iconSize?: number;
|
|
uppercase?: boolean;
|
|
compact?: boolean;
|
|
breakAll?: boolean;
|
|
shadow?: boolean;
|
|
};
|
|
|
|
type DeliveryActionCardProps = {
|
|
title: string;
|
|
description: string;
|
|
iconPath: string;
|
|
badge?: React.ReactNode;
|
|
children: React.ReactNode;
|
|
};
|
|
|
|
type SectionStatCardProps = {
|
|
label: string;
|
|
value: React.ReactNode;
|
|
helper?: React.ReactNode;
|
|
iconPath?: string;
|
|
className?: string;
|
|
labelClassName?: string;
|
|
valueClassName?: string;
|
|
helperClassName?: string;
|
|
iconWrapperClassName?: string;
|
|
iconSize?: number;
|
|
};
|
|
|
|
type SummaryPanelProps = {
|
|
label: string;
|
|
value?: React.ReactNode;
|
|
description?: React.ReactNode;
|
|
aside?: React.ReactNode;
|
|
children?: React.ReactNode;
|
|
className?: string;
|
|
labelClassName?: string;
|
|
valueClassName?: string;
|
|
descriptionClassName?: string;
|
|
};
|
|
|
|
type PlatformOutputMeta = {
|
|
implementationLabel: string;
|
|
developerDestination: string;
|
|
payloadLabel: string;
|
|
liveStatus: string;
|
|
demoNote: string;
|
|
iconPath: string;
|
|
steps: string[];
|
|
};
|
|
|
|
type PlatformPreviewArtifact = {
|
|
id: string;
|
|
label: string;
|
|
fileName: string;
|
|
description: string;
|
|
iconPath: string;
|
|
toneClassName?: string;
|
|
};
|
|
|
|
type PlatformFinalDeliverable = {
|
|
id: string;
|
|
title: string;
|
|
destination: string;
|
|
owner: string;
|
|
description: string;
|
|
iconPath: string;
|
|
statusLabel: string;
|
|
toneClassName?: string;
|
|
};
|
|
|
|
type PlatformPageMapping = {
|
|
id: string;
|
|
pageLabel: string;
|
|
pageUrl: string | null;
|
|
schemaType: string;
|
|
packageFile: string;
|
|
deliverableTitle: string;
|
|
destination: string;
|
|
actionLabel: string;
|
|
statusLabel: string;
|
|
statusClassName: string;
|
|
iconPath: string;
|
|
};
|
|
|
|
type PlatformTimelineStep = {
|
|
id: string;
|
|
label: string;
|
|
description: string;
|
|
iconPath: string;
|
|
status: 'complete' | 'current' | 'upcoming';
|
|
timeLabel: string;
|
|
};
|
|
|
|
const PLATFORM_OPTIONS = [
|
|
{ value: 'wordpress', label: 'WordPress' },
|
|
{ value: 'shopify', label: 'Shopify' },
|
|
{ value: 'webflow', label: 'Webflow' },
|
|
{ value: 'custom', label: 'Custom / Other' },
|
|
];
|
|
|
|
const PLATFORM_OUTPUT_META: Record<string, PlatformOutputMeta> = {
|
|
wordpress: {
|
|
implementationLabel: 'JSON-LD handoff for theme or SEO plugin injection',
|
|
developerDestination: 'Theme hook, SEO plugin field, or reusable snippet partial',
|
|
payloadLabel: 'WordPress deployment package',
|
|
liveStatus: 'Live generator is still gated behind the Premium Step 4 workflow.',
|
|
demoNote: 'This demo shows the package shape a WordPress developer would receive, without publishing anything.',
|
|
iconPath: icon.mdiWordpress,
|
|
steps: [
|
|
'Bundle the schema-ready fixes into reusable JSON-LD blocks.',
|
|
'Map each block to the matching WordPress template or page type.',
|
|
'Hand off insertion notes for theme hooks or plugin fields.',
|
|
],
|
|
},
|
|
shopify: {
|
|
implementationLabel: 'Theme/app embed package for Shopify templates',
|
|
developerDestination: 'Theme section, app embed, or Liquid snippet placement',
|
|
payloadLabel: 'Shopify theme package',
|
|
liveStatus: 'Live generator is still gated behind the Premium Step 4 workflow.',
|
|
demoNote: 'This demo previews how a Shopify-focused package would be organized for implementation.',
|
|
iconPath: icon.mdiShopping,
|
|
steps: [
|
|
'Group schema output by product, collection, and content page patterns.',
|
|
'Show where Liquid snippets or theme app blocks would be inserted.',
|
|
'Package implementation notes for theme review before launch.',
|
|
],
|
|
},
|
|
webflow: {
|
|
implementationLabel: 'Embed/code-block package for Webflow pages and CMS templates',
|
|
developerDestination: 'Page settings, CMS template embeds, or shared custom code areas',
|
|
payloadLabel: 'Webflow embed package',
|
|
liveStatus: 'Live generator is still gated behind the Premium Step 4 workflow.',
|
|
demoNote: 'This demo mirrors the structure a Webflow implementer would review before copying embeds.',
|
|
iconPath: icon.mdiMonitorDashboard,
|
|
steps: [
|
|
'Split schema by static pages and CMS collection templates.',
|
|
'Highlight where custom code or embed blocks would be added.',
|
|
'Prepare a page-by-page install checklist for Webflow publishing.',
|
|
],
|
|
},
|
|
custom: {
|
|
implementationLabel: 'Developer handoff package for custom sites or other CMS platforms',
|
|
developerDestination: 'Template partials, component wrappers, or tag-manager-assisted deployment',
|
|
payloadLabel: 'Custom implementation package',
|
|
liveStatus: 'Live generator is still gated behind the Premium Step 4 workflow.',
|
|
demoNote: 'This demo keeps the output generic so your developer can adapt it to the stack in use.',
|
|
iconPath: icon.mdiCodeTags,
|
|
steps: [
|
|
'Bundle schema by priority so engineering can ship the highest-impact fixes first.',
|
|
'Map each fix to the most likely template or component owner.',
|
|
'Provide a simple validation checklist for QA after deployment.',
|
|
],
|
|
},
|
|
};
|
|
|
|
const parseTargetLines = (value: string) => value
|
|
.split(/\r?\n/)
|
|
.map((entry) => entry.trim())
|
|
.filter(Boolean);
|
|
|
|
const recommendationPriorityOrder = ['critical', 'high', 'medium', 'low', 'other'] as const;
|
|
type RecommendationPriorityId = (typeof recommendationPriorityOrder)[number];
|
|
|
|
type RecommendationPriorityMeta = {
|
|
id: RecommendationPriorityId;
|
|
label: string;
|
|
sortOrder: number;
|
|
iconPath: string;
|
|
badgeClassName: string;
|
|
sectionTitle: string;
|
|
sectionDescription: string;
|
|
accentClassName: string;
|
|
};
|
|
|
|
const recommendationPriorityMetaMap: Record<RecommendationPriorityId, RecommendationPriorityMeta> = {
|
|
critical: {
|
|
id: 'critical',
|
|
label: 'Critical',
|
|
sortOrder: 0,
|
|
iconPath: icon.mdiAlertCircleOutline,
|
|
badgeClassName: 'bg-rose-600 text-white dark:bg-rose-500 dark:text-white',
|
|
sectionTitle: 'Critical fixes first',
|
|
sectionDescription: 'Resolve these before anything else because they are the most urgent structured data gaps.',
|
|
accentClassName: 'border-rose-200 bg-rose-50/80 dark:border-rose-500/30 dark:bg-rose-500/10',
|
|
},
|
|
high: {
|
|
id: 'high',
|
|
label: 'High',
|
|
sortOrder: 1,
|
|
iconPath: icon.mdiAlertOutline,
|
|
badgeClassName: 'bg-amber-500 text-white dark:bg-amber-400 dark:text-slate-950',
|
|
sectionTitle: 'High priority',
|
|
sectionDescription: 'These recommendations should be tackled early because they likely affect key pages or important schema coverage.',
|
|
accentClassName: 'border-amber-200 bg-amber-50/80 dark:border-amber-500/30 dark:bg-amber-500/10',
|
|
},
|
|
medium: {
|
|
id: 'medium',
|
|
label: 'Medium',
|
|
sortOrder: 2,
|
|
iconPath: icon.mdiArrowDownCircleOutline,
|
|
badgeClassName: 'bg-sky-600 text-white dark:bg-sky-500 dark:text-white',
|
|
sectionTitle: 'Next up',
|
|
sectionDescription: 'Address these after the urgent items to improve broader coverage and quality.',
|
|
accentClassName: 'border-sky-200 bg-sky-50/80 dark:border-sky-500/30 dark:bg-sky-500/10',
|
|
},
|
|
low: {
|
|
id: 'low',
|
|
label: 'Low',
|
|
sortOrder: 3,
|
|
iconPath: icon.mdiCheckCircleOutline,
|
|
badgeClassName: 'bg-emerald-600 text-white dark:bg-emerald-500 dark:text-white',
|
|
sectionTitle: 'Quick wins',
|
|
sectionDescription: 'Useful polish items that can be handled once higher-impact fixes are moving.',
|
|
accentClassName: 'border-emerald-200 bg-emerald-50/80 dark:border-emerald-500/30 dark:bg-emerald-500/10',
|
|
},
|
|
other: {
|
|
id: 'other',
|
|
label: 'Unprioritized',
|
|
sortOrder: 4,
|
|
iconPath: icon.mdiLightbulbOutline,
|
|
badgeClassName: 'bg-slate-800 text-white dark:bg-slate-200 dark:text-slate-950',
|
|
sectionTitle: 'More opportunities',
|
|
sectionDescription: 'These are useful follow-up ideas that were not assigned a stronger priority label.',
|
|
accentClassName: 'border-slate-200 bg-slate-50/80 dark:border-slate-700 dark:bg-slate-900/40',
|
|
},
|
|
};
|
|
|
|
const normalizeRecommendationPriority = (priority?: string): RecommendationPriorityId => {
|
|
const normalizedPriority = priority?.trim().toLowerCase();
|
|
|
|
if (!normalizedPriority) {
|
|
return 'other';
|
|
}
|
|
|
|
if (normalizedPriority.includes('critical') || normalizedPriority === 'p0') {
|
|
return 'critical';
|
|
}
|
|
|
|
if (normalizedPriority.includes('high') || normalizedPriority === 'p1') {
|
|
return 'high';
|
|
}
|
|
|
|
if (normalizedPriority.includes('medium') || normalizedPriority.includes('med') || normalizedPriority === 'p2') {
|
|
return 'medium';
|
|
}
|
|
|
|
if (normalizedPriority.includes('low') || normalizedPriority === 'p3') {
|
|
return 'low';
|
|
}
|
|
|
|
return 'other';
|
|
};
|
|
|
|
const getRecommendationPriorityMeta = (priority?: string) => recommendationPriorityMetaMap[normalizeRecommendationPriority(priority)];
|
|
|
|
const isFixFirstRecommendation = (recommendation: Recommendation) => {
|
|
const priorityId = normalizeRecommendationPriority(recommendation.priority);
|
|
return priorityId === 'critical' || priorityId === 'high';
|
|
};
|
|
|
|
const getRecommendationScopeSortOrder = (pageScope?: string) => {
|
|
const normalizedScope = pageScope?.trim().toLowerCase() || '';
|
|
|
|
if (!normalizedScope) {
|
|
return 4;
|
|
}
|
|
|
|
if (normalizedScope.includes('site') || normalizedScope.includes('global') || normalizedScope.includes('all')) {
|
|
return 0;
|
|
}
|
|
|
|
if (normalizedScope.includes('home')) {
|
|
return 1;
|
|
}
|
|
|
|
if (normalizedScope.includes('template') || normalizedScope.includes('category') || normalizedScope.includes('collection') || normalizedScope.includes('product')) {
|
|
return 2;
|
|
}
|
|
|
|
if (normalizedScope.includes('page')) {
|
|
return 3;
|
|
}
|
|
|
|
return 4;
|
|
};
|
|
|
|
const SetupAccordionSection = ({
|
|
title,
|
|
description,
|
|
iconPath,
|
|
badge,
|
|
isOpen,
|
|
onToggle,
|
|
children,
|
|
}: SetupAccordionSectionProps) => (
|
|
<div className='overflow-hidden rounded-2xl border border-slate-200 bg-slate-50/90 shadow-sm dark:border-slate-700 dark:bg-slate-900/40'>
|
|
<button
|
|
type='button'
|
|
className='flex w-full items-start justify-between gap-3 px-4 py-4 text-left sm:gap-4 sm:px-5'
|
|
onClick={onToggle}
|
|
aria-expanded={isOpen}
|
|
>
|
|
<div className='flex items-start gap-3'>
|
|
<span className='mt-0.5 inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-white text-slate-600 shadow-sm dark:bg-slate-950/60 dark:text-slate-200 sm:h-10 sm:w-10 sm:rounded-2xl'>
|
|
<BaseIcon path={iconPath} size={18} />
|
|
</span>
|
|
<div>
|
|
<div className='flex flex-wrap items-center gap-3'>
|
|
<h3 className='text-base font-semibold text-slate-900 dark:text-white'>{title}</h3>
|
|
{badge && (
|
|
<span className='rounded-full bg-white px-3 py-1 text-xs font-semibold text-slate-600 dark:bg-slate-950/50 dark:text-slate-200'>
|
|
{badge}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<p className='mt-1.5 pr-2 text-sm leading-6 text-slate-500 dark:text-slate-300 sm:pr-6'>{description}</p>
|
|
</div>
|
|
</div>
|
|
<BaseIcon
|
|
path={isOpen ? icon.mdiChevronUp : icon.mdiChevronDown}
|
|
className='mt-1 text-slate-500 dark:text-slate-300'
|
|
/>
|
|
</button>
|
|
|
|
{isOpen && (
|
|
<div className='border-t border-slate-200 px-4 py-4 dark:border-slate-700 sm:px-5 sm:py-5'>
|
|
{children}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
const ResultsTabButton = ({ label, iconPath, count, isActive, onClick }: ResultsTabButtonProps) => (
|
|
<button
|
|
type='button'
|
|
onClick={onClick}
|
|
className={`inline-flex min-w-[108px] shrink-0 flex-col items-center justify-center gap-1.5 rounded-2xl px-3 py-3.5 text-xs font-semibold transition-colors sm:min-w-0 sm:flex-row sm:gap-2 sm:px-4 sm:text-sm ${isActive
|
|
? 'bg-white text-slate-900 shadow-sm dark:bg-slate-950 dark:text-white'
|
|
: 'text-slate-500 hover:text-slate-900 dark:text-slate-300 dark:hover:text-white'}`}
|
|
>
|
|
<BaseIcon path={iconPath} size={16} className='shrink-0' />
|
|
<span>{label}</span>
|
|
{count !== undefined && count !== null && (
|
|
<span className={`rounded-full px-2 py-0.5 text-xs ${isActive
|
|
? 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-100'
|
|
: 'bg-slate-200 text-slate-600 dark:bg-slate-700 dark:text-slate-200'}`}
|
|
>
|
|
{count}
|
|
</span>
|
|
)}
|
|
</button>
|
|
);
|
|
|
|
const PageFilterChip = ({ label, count, iconPath, isActive, onClick }: PageFilterChipProps) => (
|
|
<button
|
|
type='button'
|
|
onClick={onClick}
|
|
className={`inline-flex min-w-max shrink-0 items-center gap-2 rounded-full border px-3 py-2 text-xs font-semibold transition-colors sm:text-sm ${isActive
|
|
? 'border-sky-300 bg-sky-50 text-sky-700 dark:border-sky-500/40 dark:bg-sky-500/10 dark:text-sky-200'
|
|
: 'border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:text-slate-900 dark:border-slate-700 dark:bg-slate-950/40 dark:text-slate-300 dark:hover:text-white'}`}
|
|
>
|
|
<BaseIcon path={iconPath} size={16} className='shrink-0' />
|
|
<span>{label}</span>
|
|
<span className={`rounded-full px-2 py-0.5 text-[11px] ${isActive
|
|
? 'bg-white/80 text-sky-700 dark:bg-slate-950/60 dark:text-sky-100'
|
|
: 'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-200'}`}>
|
|
{count}
|
|
</span>
|
|
</button>
|
|
);
|
|
|
|
const StatusBadge = ({
|
|
children,
|
|
className = '',
|
|
iconPath,
|
|
iconSize = 14,
|
|
uppercase = false,
|
|
compact = false,
|
|
breakAll = false,
|
|
shadow = false,
|
|
}: StatusBadgeProps) => (
|
|
<span
|
|
className={[
|
|
'inline-flex items-center gap-1 rounded-full font-semibold',
|
|
compact ? 'px-2.5 py-1 text-[11px]' : 'px-3 py-1 text-xs',
|
|
uppercase ? 'uppercase tracking-wide' : '',
|
|
breakAll ? 'break-all' : '',
|
|
shadow ? 'shadow-sm' : '',
|
|
className,
|
|
].filter(Boolean).join(' ')}
|
|
>
|
|
{iconPath && <BaseIcon path={iconPath} size={iconSize} className='shrink-0' />}
|
|
<span>{children}</span>
|
|
</span>
|
|
);
|
|
|
|
const DeliverySummaryCard = ({
|
|
label,
|
|
value,
|
|
helper,
|
|
iconPath,
|
|
toneClassName = 'border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-950/40',
|
|
}: DeliverySummaryCardProps) => (
|
|
<div className={`rounded-2xl border p-4 ${toneClassName}`}>
|
|
<div className='flex items-start justify-between gap-3'>
|
|
<div>
|
|
<div className='text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400'>{label}</div>
|
|
<div className='mt-2 text-lg font-semibold text-slate-900 dark:text-white'>{value}</div>
|
|
</div>
|
|
<span className='inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl bg-slate-100 text-slate-600 dark:bg-slate-900 dark:text-slate-200'>
|
|
<BaseIcon path={iconPath} size={20} />
|
|
</span>
|
|
</div>
|
|
<p className='mt-3 text-sm leading-6 text-slate-500 dark:text-slate-300'>{helper}</p>
|
|
</div>
|
|
);
|
|
|
|
const DeliveryActionCard = ({ title, description, iconPath, badge, children }: DeliveryActionCardProps) => (
|
|
<div className='rounded-2xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-700 dark:bg-slate-950/30 sm:p-5'>
|
|
<div className='flex flex-wrap items-start justify-between gap-3'>
|
|
<div className='flex items-start gap-3'>
|
|
<span className='inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl bg-slate-100 text-slate-600 dark:bg-slate-900 dark:text-slate-200'>
|
|
<BaseIcon path={iconPath} size={20} />
|
|
</span>
|
|
<div>
|
|
<h5 className='text-sm font-semibold text-slate-900 dark:text-white sm:text-base'>{title}</h5>
|
|
<p className='mt-1 text-sm leading-6 text-slate-500 dark:text-slate-300'>{description}</p>
|
|
</div>
|
|
</div>
|
|
{badge}
|
|
</div>
|
|
<div className='mt-4'>{children}</div>
|
|
</div>
|
|
);
|
|
|
|
const SectionStatCard = ({
|
|
label,
|
|
value,
|
|
helper,
|
|
iconPath,
|
|
className = '',
|
|
labelClassName = '',
|
|
valueClassName = '',
|
|
helperClassName = '',
|
|
iconWrapperClassName = '',
|
|
iconSize = 18,
|
|
}: SectionStatCardProps) => (
|
|
<div
|
|
className={[
|
|
'rounded-2xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-700 dark:bg-slate-950/40',
|
|
className,
|
|
].filter(Boolean).join(' ')}
|
|
>
|
|
<div className='flex items-start justify-between gap-3'>
|
|
<div className='min-w-0'>
|
|
<div
|
|
className={[
|
|
'text-[11px] font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-300',
|
|
labelClassName,
|
|
].filter(Boolean).join(' ')}
|
|
>
|
|
{label}
|
|
</div>
|
|
<div
|
|
className={[
|
|
'mt-1.5 text-xl font-semibold leading-none text-slate-900 dark:text-white sm:text-2xl',
|
|
valueClassName,
|
|
].filter(Boolean).join(' ')}
|
|
>
|
|
{value}
|
|
</div>
|
|
{helper && (
|
|
<div
|
|
className={[
|
|
'mt-1.5 text-xs text-slate-500 dark:text-slate-300',
|
|
helperClassName,
|
|
].filter(Boolean).join(' ')}
|
|
>
|
|
{helper}
|
|
</div>
|
|
)}
|
|
</div>
|
|
{iconPath && (
|
|
<span
|
|
className={[
|
|
'inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-2xl bg-slate-100 text-slate-600 dark:bg-slate-900 dark:text-slate-200',
|
|
iconWrapperClassName,
|
|
].filter(Boolean).join(' ')}
|
|
>
|
|
<BaseIcon path={iconPath} size={iconSize} />
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
const SummaryPanel = ({
|
|
label,
|
|
value,
|
|
description,
|
|
aside,
|
|
children,
|
|
className = '',
|
|
labelClassName = '',
|
|
valueClassName = '',
|
|
descriptionClassName = '',
|
|
}: SummaryPanelProps) => (
|
|
<div
|
|
className={[
|
|
'rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm dark:border-slate-700 dark:bg-slate-950/40',
|
|
className,
|
|
].filter(Boolean).join(' ')}
|
|
>
|
|
<div className={aside ? 'flex items-start justify-between gap-3' : ''}>
|
|
<div className='min-w-0 flex-1'>
|
|
<div
|
|
className={[
|
|
'text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400',
|
|
labelClassName,
|
|
].filter(Boolean).join(' ')}
|
|
>
|
|
{label}
|
|
</div>
|
|
{value && (
|
|
<div
|
|
className={[
|
|
'mt-2 text-sm font-semibold text-slate-900 dark:text-white',
|
|
valueClassName,
|
|
].filter(Boolean).join(' ')}
|
|
>
|
|
{value}
|
|
</div>
|
|
)}
|
|
{description && (
|
|
<div
|
|
className={[
|
|
value ? 'mt-2' : 'mt-1',
|
|
'text-sm leading-6 text-slate-500 dark:text-slate-300',
|
|
descriptionClassName,
|
|
].filter(Boolean).join(' ')}
|
|
>
|
|
{description}
|
|
</div>
|
|
)}
|
|
</div>
|
|
{aside && <div className='shrink-0'>{aside}</div>}
|
|
</div>
|
|
{children && <div className={description || value || aside ? 'mt-3' : 'mt-2'}>{children}</div>}
|
|
</div>
|
|
);
|
|
|
|
const SchemaAnalyzerPage = () => {
|
|
const { currentUser } = useAppSelector((state) => state.auth);
|
|
const [url, setUrl] = React.useState('');
|
|
const [requestedPages, setRequestedPages] = React.useState(1);
|
|
const [includeTargets, setIncludeTargets] = React.useState('');
|
|
const [excludeTargets, setExcludeTargets] = React.useState('');
|
|
const [selectedPlatform, setSelectedPlatform] = React.useState('wordpress');
|
|
const [emailTo, setEmailTo] = React.useState(currentUser?.email || '');
|
|
const [report, setReport] = React.useState<ReportResponse | null>(null);
|
|
const [isAnalyzing, setIsAnalyzing] = React.useState(false);
|
|
const [isExportingAll, setIsExportingAll] = React.useState(false);
|
|
const [emailingId, setEmailingId] = React.useState<string | null>(null);
|
|
const [exportingId, setExportingId] = React.useState<string | null>(null);
|
|
const [isCheckingPlatformOutput, setIsCheckingPlatformOutput] = React.useState(false);
|
|
const [isSimulatingPlatformSend, setIsSimulatingPlatformSend] = React.useState(false);
|
|
const [lastPlatformSimulationAt, setLastPlatformSimulationAt] = React.useState<string | null>(null);
|
|
const [openSections, setOpenSections] = React.useState<Record<SetupSectionId, boolean>>({
|
|
targeting: false,
|
|
options: false,
|
|
limits: false,
|
|
});
|
|
const [activeResultsTab, setActiveResultsTab] = React.useState<ResultsTabId>('overview');
|
|
const [activePageFilter, setActivePageFilter] = React.useState<PageResultsFilterId>('all');
|
|
const [activeRecommendationFilter, setActiveRecommendationFilter] = React.useState<RecommendationQuickFilterId>('all');
|
|
const [isFailedPagesExpanded, setIsFailedPagesExpanded] = React.useState(false);
|
|
const [expandedRecommendationIds, setExpandedRecommendationIds] = React.useState<Record<string, boolean>>({});
|
|
const resultsRef = React.useRef<HTMLDivElement | null>(null);
|
|
|
|
const scrollToResults = React.useCallback(() => {
|
|
resultsRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
}, []);
|
|
|
|
React.useEffect(() => {
|
|
if (currentUser?.email) {
|
|
setEmailTo(currentUser.email);
|
|
}
|
|
}, [currentUser?.email]);
|
|
|
|
React.useEffect(() => {
|
|
setLastPlatformSimulationAt(null);
|
|
}, [selectedPlatform]);
|
|
|
|
React.useEffect(() => {
|
|
if (!report?.analysis) {
|
|
return undefined;
|
|
}
|
|
|
|
setActiveResultsTab('overview');
|
|
setActivePageFilter('all');
|
|
setActiveRecommendationFilter('all');
|
|
setIsFailedPagesExpanded(false);
|
|
setExpandedRecommendationIds({});
|
|
setLastPlatformSimulationAt(null);
|
|
|
|
const timeoutId = window.setTimeout(() => {
|
|
scrollToResults();
|
|
}, 150);
|
|
|
|
return () => window.clearTimeout(timeoutId);
|
|
}, [report?.analysis?.analyzedUrl, report?.analysis?.fetchedAt, scrollToResults]);
|
|
|
|
const notify = React.useCallback((type: 'success' | 'error' | 'info', message: string) => {
|
|
toast(message, { type, position: 'bottom-center' });
|
|
}, []);
|
|
|
|
const fallbackEntitlements = React.useMemo(
|
|
() => getSiteEntitlements(currentUser),
|
|
[currentUser],
|
|
);
|
|
const entitlements = report?.entitlements || report?.analysis?.entitlements || fallbackEntitlements;
|
|
const maxPagesPerCrawl = entitlements?.maxPagesPerCrawl || fallbackEntitlements.maxPagesPerCrawl || 25;
|
|
const recommendations = report?.recommendations || [];
|
|
const exportableRecommendations = recommendations.filter(
|
|
(recommendation) => recommendation.suggested_schema,
|
|
);
|
|
const sortedRecommendations = React.useMemo(() => (
|
|
[...recommendations].sort((leftRecommendation, rightRecommendation) => {
|
|
const leftPriority = getRecommendationPriorityMeta(leftRecommendation.priority);
|
|
const rightPriority = getRecommendationPriorityMeta(rightRecommendation.priority);
|
|
|
|
if (leftPriority.sortOrder !== rightPriority.sortOrder) {
|
|
return leftPriority.sortOrder - rightPriority.sortOrder;
|
|
}
|
|
|
|
const leftScopeOrder = getRecommendationScopeSortOrder(leftRecommendation.page_scope);
|
|
const rightScopeOrder = getRecommendationScopeSortOrder(rightRecommendation.page_scope);
|
|
if (leftScopeOrder !== rightScopeOrder) {
|
|
return leftScopeOrder - rightScopeOrder;
|
|
}
|
|
|
|
const leftHasCode = Number(Boolean(leftRecommendation.suggested_schema));
|
|
const rightHasCode = Number(Boolean(rightRecommendation.suggested_schema));
|
|
if (leftHasCode !== rightHasCode) {
|
|
return rightHasCode - leftHasCode;
|
|
}
|
|
|
|
return leftRecommendation.title.localeCompare(rightRecommendation.title);
|
|
})
|
|
), [recommendations]);
|
|
const crawlPlan = report?.analysis?.crawlPlan;
|
|
const isRequestedPagesOverLimit = requestedPages > maxPagesPerCrawl;
|
|
const draftIncludeTargets = React.useMemo(() => parseTargetLines(includeTargets), [includeTargets]);
|
|
const draftExcludeTargets = React.useMemo(() => parseTargetLines(excludeTargets), [excludeTargets]);
|
|
const appliedIncludeTargets = crawlPlan?.includeTargets || draftIncludeTargets;
|
|
const appliedExcludeTargets = crawlPlan?.excludeTargets || draftExcludeTargets;
|
|
const analyzedPages = report?.analysis?.pages || [];
|
|
const failedPages = report?.analysis?.failedPages || [];
|
|
const detectedSchemaTypes = report?.analysis?.schema?.types || report?.analysis?.schema?.jsonLd?.types || [];
|
|
const wordpressAnalysis = report?.analysis?.wordpress || null;
|
|
const detectedWordPressPlugins = wordpressAnalysis?.plugins || [];
|
|
const wordpressSchemaOwnership = wordpressAnalysis?.schemaOwnership || null;
|
|
const wordpressPluginRecommendations = wordpressAnalysis?.pluginRecommendations || [];
|
|
const wordpressDuplicateRisk = wordpressAnalysis?.duplicateRisk || null;
|
|
const wordpressDuplicateRiskToneClassName = wordpressDuplicateRisk?.level === 'high'
|
|
? 'border-rose-200 bg-rose-50 text-rose-800 dark:border-rose-500/40 dark:bg-rose-500/10 dark:text-rose-100'
|
|
: wordpressDuplicateRisk?.level === 'medium'
|
|
? 'border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-100'
|
|
: 'border-emerald-200 bg-emerald-50 text-emerald-800 dark:border-emerald-500/40 dark:bg-emerald-500/10 dark:text-emerald-100';
|
|
const wordpressDuplicateRiskBadgeClassName = wordpressDuplicateRisk?.level === 'high'
|
|
? 'bg-rose-100 text-rose-700 dark:bg-rose-500/10 dark:text-rose-200'
|
|
: wordpressDuplicateRisk?.level === 'medium'
|
|
? 'bg-amber-100 text-amber-700 dark:bg-amber-500/10 dark:text-amber-200'
|
|
: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-200';
|
|
const invalidJsonLdBlocks = report?.analysis?.schema?.jsonLd?.invalidBlocks || [];
|
|
const hasTargetingRules = appliedIncludeTargets.length > 0 || appliedExcludeTargets.length > 0;
|
|
const selectedPlatformLabel = PLATFORM_OPTIONS.find(
|
|
(platformOption) => platformOption.value === selectedPlatform,
|
|
)?.label || 'Custom / Other';
|
|
const selectedPlatformMeta = PLATFORM_OUTPUT_META[selectedPlatform] || PLATFORM_OUTPUT_META.custom;
|
|
const analyzedTimestamp = report?.analysis?.fetchedAt
|
|
? new Date(report.analysis.fetchedAt).toLocaleString()
|
|
: null;
|
|
const lastPlatformSimulationLabel = lastPlatformSimulationAt
|
|
? new Date(lastPlatformSimulationAt).toLocaleString()
|
|
: null;
|
|
const trimmedEmailTo = emailTo.trim();
|
|
const trimmedUrl = url.trim();
|
|
const hasEmailRecipient = trimmedEmailTo.length > 0;
|
|
const hasUrl = trimmedUrl.length > 0;
|
|
const requestedPagesForRun = crawlPlan?.requestedPages || requestedPages;
|
|
const actualPagesAnalyzed = crawlPlan?.actualPagesAnalyzed || analyzedPages.length;
|
|
const failedPagesCount = report?.analysis?.crawlSummary?.failedPages ?? failedPages.length;
|
|
const requestedPageTargetMet = actualPagesAnalyzed >= requestedPagesForRun;
|
|
const pageCountStatusLabel = requestedPageTargetMet
|
|
? `Exact ${requestedPagesForRun}-page target met`
|
|
: `Only ${actualPagesAnalyzed} eligible page${actualPagesAnalyzed === 1 ? ' was' : 's were'} found`;
|
|
const pageCountStatusDescription = requestedPageTargetMet
|
|
? `This run analyzed all ${requestedPagesForRun} requested page${requestedPagesForRun === 1 ? '' : 's'}${failedPagesCount > 0 ? `, while ${failedPagesCount} additional fetch failure${failedPagesCount === 1 ? ' was' : 's were'} logged separately.` : '.'}`
|
|
: `You requested ${requestedPagesForRun} page${requestedPagesForRun === 1 ? '' : 's'}, but only ${actualPagesAnalyzed} crawlable page${actualPagesAnalyzed === 1 ? '' : 's'} matched the site and targeting rules for this run.`;
|
|
const targetingSummary = hasTargetingRules
|
|
? `${appliedIncludeTargets.length} include · ${appliedExcludeTargets.length} exclude`
|
|
: 'No targeting rules';
|
|
const recommendationQuickFilters = [
|
|
{
|
|
id: 'all' as const,
|
|
label: 'All',
|
|
count: recommendations.length,
|
|
iconPath: icon.mdiViewListOutline,
|
|
},
|
|
{
|
|
id: 'fixFirst' as const,
|
|
label: 'Fix first',
|
|
count: sortedRecommendations.filter((recommendation) => isFixFirstRecommendation(recommendation)).length,
|
|
iconPath: icon.mdiAlertCircleOutline,
|
|
},
|
|
{
|
|
id: 'codeReady' as const,
|
|
label: 'Code ready',
|
|
count: sortedRecommendations.filter((recommendation) => recommendation.suggested_schema).length,
|
|
iconPath: icon.mdiCodeBraces,
|
|
},
|
|
{
|
|
id: 'needsCode' as const,
|
|
label: 'Needs code',
|
|
count: sortedRecommendations.filter((recommendation) => !recommendation.suggested_schema).length,
|
|
iconPath: icon.mdiLightbulbOutline,
|
|
},
|
|
];
|
|
const pageFilterOptions = [
|
|
{
|
|
id: 'all' as const,
|
|
label: 'All',
|
|
count: analyzedPages.length + failedPages.length,
|
|
iconPath: icon.mdiViewListOutline,
|
|
},
|
|
{
|
|
id: 'withSchema' as const,
|
|
label: 'With schema',
|
|
count: analyzedPages.filter((page) => page.hasStructuredData).length,
|
|
iconPath: icon.mdiCheckCircleOutline,
|
|
},
|
|
{
|
|
id: 'missingSchema' as const,
|
|
label: 'Missing schema',
|
|
count: analyzedPages.filter((page) => !page.hasStructuredData).length,
|
|
iconPath: icon.mdiAlertCircleOutline,
|
|
},
|
|
{
|
|
id: 'failed' as const,
|
|
label: 'Failed',
|
|
count: failedPages.length,
|
|
iconPath: icon.mdiCloseCircleOutline,
|
|
},
|
|
];
|
|
const filteredRecommendations = React.useMemo(() => {
|
|
if (activeRecommendationFilter === 'fixFirst') {
|
|
return sortedRecommendations.filter((recommendation) => isFixFirstRecommendation(recommendation));
|
|
}
|
|
|
|
if (activeRecommendationFilter === 'codeReady') {
|
|
return sortedRecommendations.filter((recommendation) => recommendation.suggested_schema);
|
|
}
|
|
|
|
if (activeRecommendationFilter === 'needsCode') {
|
|
return sortedRecommendations.filter((recommendation) => !recommendation.suggested_schema);
|
|
}
|
|
|
|
return sortedRecommendations;
|
|
}, [activeRecommendationFilter, sortedRecommendations]);
|
|
const recommendationGroups = React.useMemo(() => recommendationPriorityOrder
|
|
.map((priorityId) => ({
|
|
meta: recommendationPriorityMetaMap[priorityId],
|
|
recommendations: filteredRecommendations.filter(
|
|
(recommendation) => normalizeRecommendationPriority(recommendation.priority) === priorityId,
|
|
),
|
|
}))
|
|
.filter((group) => group.recommendations.length > 0), [filteredRecommendations]);
|
|
const activeRecommendationFilterLabel = recommendationQuickFilters.find(
|
|
(filterOption) => filterOption.id === activeRecommendationFilter,
|
|
)?.label || 'All';
|
|
const recommendationEmptyStateMessage = activeRecommendationFilter === 'fixFirst'
|
|
? 'No high-priority recommendations are waiting in this report.'
|
|
: activeRecommendationFilter === 'codeReady'
|
|
? 'No recommendations with generated code are available yet.'
|
|
: activeRecommendationFilter === 'needsCode'
|
|
? 'Every visible recommendation already has a code snippet attached.'
|
|
: 'No recommendations were generated for this page yet.';
|
|
|
|
const filteredAnalyzedPages = React.useMemo(() => {
|
|
if (activePageFilter === 'withSchema') {
|
|
return analyzedPages.filter((page) => page.hasStructuredData);
|
|
}
|
|
|
|
if (activePageFilter === 'missingSchema') {
|
|
return analyzedPages.filter((page) => !page.hasStructuredData);
|
|
}
|
|
|
|
if (activePageFilter === 'failed') {
|
|
return [];
|
|
}
|
|
|
|
return analyzedPages;
|
|
}, [activePageFilter, analyzedPages]);
|
|
const shouldShowFailedSection = failedPages.length > 0 && (activePageFilter === 'all' || activePageFilter === 'failed');
|
|
const emptyPagesStateMessage = activePageFilter === 'failed'
|
|
? 'No failed internal pages were recorded for this analysis run.'
|
|
: activePageFilter === 'withSchema'
|
|
? 'No analyzed pages with structured data match this filter yet.'
|
|
: activePageFilter === 'missingSchema'
|
|
? 'No analyzed pages are missing structured data for this run.'
|
|
: 'No page-level results are available yet for this analysis run.';
|
|
const step4SchemaTypes = React.useMemo(() => Array.from(new Set([
|
|
...detectedSchemaTypes,
|
|
...recommendations.map((recommendation) => recommendation.schema_type || '').filter(Boolean),
|
|
])).slice(0, 8), [detectedSchemaTypes, recommendations]);
|
|
const step4PrimarySchemaType = step4SchemaTypes[0] || 'Organization';
|
|
const step4PrimaryPageUrl = analyzedPages[0]?.url || report?.analysis?.analyzedUrl || report?.site?.base_url || trimmedUrl || 'https://example.com';
|
|
const platformPreviewArtifacts = React.useMemo<PlatformPreviewArtifact[]>(() => {
|
|
const analyzedPageLabel = actualPagesAnalyzed === 1 ? '1 analyzed page' : `${actualPagesAnalyzed} analyzed pages`;
|
|
|
|
if (selectedPlatform === 'wordpress') {
|
|
return [
|
|
{
|
|
id: 'payload',
|
|
label: 'Schema package JSON',
|
|
fileName: 'schema-package.json',
|
|
description: `Contains the Step 4 payload for all ${analyzedPageLabel} in this run.`,
|
|
iconPath: icon.mdiCodeJson,
|
|
toneClassName: 'border-sky-200 bg-sky-50/70 dark:border-sky-500/30 dark:bg-sky-500/10',
|
|
},
|
|
{
|
|
id: 'snippet',
|
|
label: 'Theme/plugin snippet',
|
|
fileName: 'wp-schema-snippet.php',
|
|
description: 'Ready for a theme hook, reusable partial, or an SEO plugin custom schema field.',
|
|
iconPath: icon.mdiWordpress,
|
|
},
|
|
{
|
|
id: 'notes',
|
|
label: 'Install notes',
|
|
fileName: 'wordpress-install-notes.md',
|
|
description: 'Explains which templates or page types each schema block should be mapped to.',
|
|
iconPath: icon.mdiTextBoxCheckOutline,
|
|
},
|
|
];
|
|
}
|
|
|
|
if (selectedPlatform === 'shopify') {
|
|
return [
|
|
{
|
|
id: 'payload',
|
|
label: 'Schema package JSON',
|
|
fileName: 'schema-package.json',
|
|
description: `Captures the exact ${analyzedPageLabel} selected for this Shopify handoff.`,
|
|
iconPath: icon.mdiCodeJson,
|
|
toneClassName: 'border-violet-200 bg-violet-50/70 dark:border-violet-500/30 dark:bg-violet-500/10',
|
|
},
|
|
{
|
|
id: 'snippet',
|
|
label: 'Liquid snippet',
|
|
fileName: 'snippets/schema-output.liquid',
|
|
description: 'Shows how product, collection, or content templates could render JSON-LD in Liquid.',
|
|
iconPath: icon.mdiShopping,
|
|
},
|
|
{
|
|
id: 'notes',
|
|
label: 'Theme checklist',
|
|
fileName: 'shopify-theme-checklist.md',
|
|
description: 'Documents the theme sections or app embed locations your developer should review.',
|
|
iconPath: icon.mdiClipboardCheckOutline,
|
|
},
|
|
];
|
|
}
|
|
|
|
if (selectedPlatform === 'webflow') {
|
|
return [
|
|
{
|
|
id: 'payload',
|
|
label: 'Schema package JSON',
|
|
fileName: 'schema-package.json',
|
|
description: `Preserves all ${analyzedPageLabel} so the Webflow handoff matches the crawl exactly.`,
|
|
iconPath: icon.mdiCodeJson,
|
|
toneClassName: 'border-emerald-200 bg-emerald-50/70 dark:border-emerald-500/30 dark:bg-emerald-500/10',
|
|
},
|
|
{
|
|
id: 'snippet',
|
|
label: 'Embed snippet',
|
|
fileName: 'webflow-embed-snippet.html',
|
|
description: 'Represents the custom code block that would be pasted into page settings or CMS templates.',
|
|
iconPath: icon.mdiMonitorDashboard,
|
|
},
|
|
{
|
|
id: 'notes',
|
|
label: 'Publish checklist',
|
|
fileName: 'webflow-publish-checklist.md',
|
|
description: 'Calls out which static pages or CMS collections need a schema embed before publish.',
|
|
iconPath: icon.mdiClipboardPulseOutline,
|
|
},
|
|
];
|
|
}
|
|
|
|
return [
|
|
{
|
|
id: 'payload',
|
|
label: 'Schema package JSON',
|
|
fileName: 'schema-package.json',
|
|
description: `Keeps the full ${analyzedPageLabel} package available for engineering review.`,
|
|
iconPath: icon.mdiCodeJson,
|
|
toneClassName: 'border-amber-200 bg-amber-50/70 dark:border-amber-500/30 dark:bg-amber-500/10',
|
|
},
|
|
{
|
|
id: 'snippet',
|
|
label: 'Implementation starter',
|
|
fileName: 'schema-loader.ts',
|
|
description: 'A generic starter that engineering can adapt to the current frontend, backend, or CMS stack.',
|
|
iconPath: icon.mdiCodeTags,
|
|
},
|
|
{
|
|
id: 'notes',
|
|
label: 'Developer notes',
|
|
fileName: 'implementation-notes.md',
|
|
description: 'Summarizes target templates, QA checks, and rollout notes for a custom implementation.',
|
|
iconPath: icon.mdiNotebookOutline,
|
|
},
|
|
];
|
|
}, [actualPagesAnalyzed, selectedPlatform]);
|
|
const platformFinalDeliverables = React.useMemo<PlatformFinalDeliverable[]>(() => {
|
|
if (selectedPlatform === 'wordpress') {
|
|
return [
|
|
{
|
|
id: 'plugin-field',
|
|
title: 'SEO plugin field',
|
|
destination: 'Yoast / Rank Math / custom schema field',
|
|
owner: 'SEO owner',
|
|
description: 'Best for singular pages where a marketer or SEO specialist can manage the final JSON-LD without editing templates.',
|
|
iconPath: icon.mdiWordpress,
|
|
statusLabel: 'Ready for config',
|
|
toneClassName: 'border-sky-200 bg-sky-50/70 dark:border-sky-500/30 dark:bg-sky-500/10',
|
|
},
|
|
{
|
|
id: 'theme-hook',
|
|
title: 'Theme hook snippet',
|
|
destination: 'wp_head hook or reusable template partial',
|
|
owner: 'Frontend developer',
|
|
description: 'Used when the schema package needs template-aware logic across posts, pages, or custom content types.',
|
|
iconPath: icon.mdiCodeBraces,
|
|
statusLabel: 'Ready for theme dev',
|
|
},
|
|
{
|
|
id: 'validation',
|
|
title: 'Validation checklist',
|
|
destination: 'Rich Results + source-code spot checks',
|
|
owner: 'QA reviewer',
|
|
description: 'Confirms the packaged schema renders on every analyzed page before the team marks the release complete.',
|
|
iconPath: icon.mdiClipboardCheckOutline,
|
|
statusLabel: 'Ready for QA',
|
|
},
|
|
];
|
|
}
|
|
|
|
if (selectedPlatform === 'shopify') {
|
|
return [
|
|
{
|
|
id: 'liquid-snippet',
|
|
title: 'Liquid theme snippet',
|
|
destination: 'snippets/schema-output.liquid',
|
|
owner: 'Theme developer',
|
|
description: 'Delivers the generated JSON-LD into product, collection, or article templates with Liquid-aware variables.',
|
|
iconPath: icon.mdiShopping,
|
|
statusLabel: 'Ready for theme merge',
|
|
toneClassName: 'border-violet-200 bg-violet-50/70 dark:border-violet-500/30 dark:bg-violet-500/10',
|
|
},
|
|
{
|
|
id: 'app-embed',
|
|
title: 'Theme app/embed slot',
|
|
destination: 'App embed block or section-level insertion point',
|
|
owner: 'Merchant ops',
|
|
description: 'Shows where a merchant or implementation partner would enable the package inside the active theme.',
|
|
iconPath: icon.mdiPackageVariantClosed,
|
|
statusLabel: 'Ready for enablement',
|
|
},
|
|
{
|
|
id: 'validation',
|
|
title: 'Storefront validation',
|
|
destination: 'Preview theme + rich result validation',
|
|
owner: 'QA reviewer',
|
|
description: 'Validates the schema output across the selected storefront pages before the theme is published live.',
|
|
iconPath: icon.mdiClipboardCheckOutline,
|
|
statusLabel: 'Ready for QA',
|
|
},
|
|
];
|
|
}
|
|
|
|
if (selectedPlatform === 'webflow') {
|
|
return [
|
|
{
|
|
id: 'page-embed',
|
|
title: 'Page embed block',
|
|
destination: 'Page settings custom code or embed element',
|
|
owner: 'Webflow editor',
|
|
description: 'Ideal for static pages that need a fast JSON-LD insert without changing the rest of the layout.',
|
|
iconPath: icon.mdiMonitorDashboard,
|
|
statusLabel: 'Ready for page paste',
|
|
toneClassName: 'border-emerald-200 bg-emerald-50/70 dark:border-emerald-500/30 dark:bg-emerald-500/10',
|
|
},
|
|
{
|
|
id: 'cms-template',
|
|
title: 'CMS template mapping',
|
|
destination: 'Collection template custom code region',
|
|
owner: 'CMS implementer',
|
|
description: 'Maps the generated package to Webflow CMS templates so repeatable schema can be applied at scale.',
|
|
iconPath: icon.mdiCodeBraces,
|
|
statusLabel: 'Ready for template wiring',
|
|
},
|
|
{
|
|
id: 'publish-review',
|
|
title: 'Publish review',
|
|
destination: 'Pre-publish checklist and live spot check',
|
|
owner: 'Publisher',
|
|
description: 'Adds a final review pass before Webflow publish so every analyzed page gets its expected schema block.',
|
|
iconPath: icon.mdiClipboardPulseOutline,
|
|
statusLabel: 'Ready for publish review',
|
|
},
|
|
];
|
|
}
|
|
|
|
return [
|
|
{
|
|
id: 'component-slot',
|
|
title: 'Component integration slot',
|
|
destination: 'Shared layout, component wrapper, or middleware hook',
|
|
owner: 'Engineering',
|
|
description: 'Provides the most flexible handoff for React, Node, headless CMS, or other custom delivery stacks.',
|
|
iconPath: icon.mdiCodeTags,
|
|
statusLabel: 'Ready for engineering',
|
|
toneClassName: 'border-amber-200 bg-amber-50/70 dark:border-amber-500/30 dark:bg-amber-500/10',
|
|
},
|
|
{
|
|
id: 'deployment-task',
|
|
title: 'Deployment task',
|
|
destination: 'Release checklist or ticketed implementation step',
|
|
owner: 'Project manager',
|
|
description: 'Turns the Step 4 package into a concrete delivery task that engineering can estimate and schedule.',
|
|
iconPath: icon.mdiTrayArrowUp,
|
|
statusLabel: 'Ready for planning',
|
|
},
|
|
{
|
|
id: 'validation',
|
|
title: 'Post-release validation',
|
|
destination: 'QA checklist and monitor pass',
|
|
owner: 'QA reviewer',
|
|
description: 'Captures the checks needed after deployment so the team can confirm production pages match the package.',
|
|
iconPath: icon.mdiClipboardCheckOutline,
|
|
statusLabel: 'Ready for sign-off',
|
|
},
|
|
];
|
|
}, [selectedPlatform]);
|
|
const platformPageMappings = React.useMemo<PlatformPageMapping[]>(() => {
|
|
const implementationArtifacts = platformPreviewArtifacts.filter((artifact) => artifact.id !== 'payload');
|
|
|
|
return analyzedPages.map((page, index) => {
|
|
const mappedDeliverable = platformFinalDeliverables[index % platformFinalDeliverables.length] || platformFinalDeliverables[0];
|
|
const mappedArtifact = implementationArtifacts[index % implementationArtifacts.length] || platformPreviewArtifacts[0];
|
|
const schemaType = page.schemaTypes?.[0] || page.jsonLdTypes?.[0] || step4SchemaTypes[index % step4SchemaTypes.length] || step4PrimarySchemaType;
|
|
const hasExistingStructuredData = Boolean(page.hasStructuredData);
|
|
|
|
return {
|
|
id: page.url || `${page.title || 'page'}-${index}`,
|
|
pageLabel: page.title || page.url || `Page ${index + 1}`,
|
|
pageUrl: page.url || null,
|
|
schemaType,
|
|
packageFile: mappedArtifact?.fileName || platformPreviewArtifacts[0]?.fileName || selectedPlatformMeta.payloadLabel,
|
|
deliverableTitle: mappedDeliverable?.title || selectedPlatformMeta.payloadLabel,
|
|
destination: mappedDeliverable?.destination || selectedPlatformMeta.developerDestination,
|
|
actionLabel: hasExistingStructuredData
|
|
? 'Review the existing markup and merge only the missing schema improvements.'
|
|
: 'Inject the generated schema output for this page during implementation.',
|
|
statusLabel: hasExistingStructuredData ? 'Existing markup detected' : 'Schema output pending',
|
|
statusClassName: hasExistingStructuredData
|
|
? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-200'
|
|
: 'bg-amber-50 text-amber-700 dark:bg-amber-500/10 dark:text-amber-200',
|
|
iconPath: hasExistingStructuredData ? icon.mdiCheckCircleOutline : icon.mdiAlertCircleOutline,
|
|
};
|
|
});
|
|
}, [
|
|
analyzedPages,
|
|
platformFinalDeliverables,
|
|
platformPreviewArtifacts,
|
|
selectedPlatformMeta.developerDestination,
|
|
selectedPlatformMeta.payloadLabel,
|
|
step4PrimarySchemaType,
|
|
step4SchemaTypes,
|
|
]);
|
|
const platformPublishTimeline = React.useMemo<PlatformTimelineStep[]>(() => {
|
|
const baseTimestamp = lastPlatformSimulationAt ? new Date(lastPlatformSimulationAt) : null;
|
|
const formatStepTime = (minuteOffset: number, fallback: string) => {
|
|
if (!baseTimestamp) {
|
|
return fallback;
|
|
}
|
|
|
|
return new Date(baseTimestamp.getTime() + (minuteOffset * 60 * 1000)).toLocaleTimeString([], {
|
|
hour: 'numeric',
|
|
minute: '2-digit',
|
|
});
|
|
};
|
|
|
|
if (!baseTimestamp) {
|
|
return [
|
|
{
|
|
id: 'queued',
|
|
label: 'Queued',
|
|
description: `${selectedPlatformLabel} handoff is ready to stage as soon as you run the demo send.`,
|
|
iconPath: icon.mdiClockOutline,
|
|
status: 'current',
|
|
timeLabel: 'Waiting for demo run',
|
|
},
|
|
{
|
|
id: 'packaged',
|
|
label: 'Packaged',
|
|
description: 'Package files, page mapping, and payload JSON will be assembled for review.',
|
|
iconPath: icon.mdiPackageVariantClosed,
|
|
status: 'upcoming',
|
|
timeLabel: 'Pending',
|
|
},
|
|
{
|
|
id: 'ready-for-dev',
|
|
label: 'Ready for dev',
|
|
description: 'The implementation handoff will be marked ready for a developer or implementation partner.',
|
|
iconPath: icon.mdiTrayArrowUp,
|
|
status: 'upcoming',
|
|
timeLabel: 'Pending',
|
|
},
|
|
{
|
|
id: 'approved',
|
|
label: 'Approved',
|
|
description: 'Stakeholder approval appears here after the Step 4 demo handoff is reviewed.',
|
|
iconPath: icon.mdiThumbUpOutline,
|
|
status: 'upcoming',
|
|
timeLabel: 'Pending',
|
|
},
|
|
];
|
|
}
|
|
|
|
return [
|
|
{
|
|
id: 'queued',
|
|
label: 'Queued',
|
|
description: `${selectedPlatformLabel} demo package entered the delivery queue.`,
|
|
iconPath: icon.mdiClockOutline,
|
|
status: 'complete',
|
|
timeLabel: formatStepTime(0, 'Queued'),
|
|
},
|
|
{
|
|
id: 'packaged',
|
|
label: 'Packaged',
|
|
description: 'Files, payload preview, and page mapping were bundled into the demo handoff.',
|
|
iconPath: icon.mdiPackageVariantClosed,
|
|
status: 'complete',
|
|
timeLabel: formatStepTime(1, 'Packaged'),
|
|
},
|
|
{
|
|
id: 'ready-for-dev',
|
|
label: 'Ready for dev',
|
|
description: 'The handoff was marked ready for engineering review and platform implementation.',
|
|
iconPath: icon.mdiTrayArrowUp,
|
|
status: 'complete',
|
|
timeLabel: formatStepTime(3, 'Ready'),
|
|
},
|
|
{
|
|
id: 'approved',
|
|
label: 'Approved',
|
|
description: 'The preview package reached the final stakeholder-approved demo state.',
|
|
iconPath: icon.mdiThumbUpOutline,
|
|
status: 'current',
|
|
timeLabel: formatStepTime(6, 'Approved'),
|
|
},
|
|
];
|
|
}, [lastPlatformSimulationAt, selectedPlatformLabel]);
|
|
const platformImplementationPreview = React.useMemo(() => {
|
|
if (selectedPlatform === 'wordpress') {
|
|
return `<?php
|
|
add_action('wp_head', function () {
|
|
if (!is_singular()) return;
|
|
?>
|
|
<script type="application/ld+json">
|
|
{
|
|
"@context": "https://schema.org",
|
|
"@type": "${step4PrimarySchemaType}",
|
|
"url": "${step4PrimaryPageUrl}"
|
|
}
|
|
</script>
|
|
<?php
|
|
}, 20);`;
|
|
}
|
|
|
|
if (selectedPlatform === 'shopify') {
|
|
return `{% if request.page_type %}
|
|
<script type="application/ld+json">
|
|
{
|
|
"@context": "https://schema.org",
|
|
"@type": "${step4PrimarySchemaType}",
|
|
"url": "{{ shop.url }}{{ request.path }}"
|
|
}
|
|
</script>
|
|
{% endif %}`;
|
|
}
|
|
|
|
if (selectedPlatform === 'webflow') {
|
|
return `<script type="application/ld+json">
|
|
{
|
|
"@context": "https://schema.org",
|
|
"@type": "${step4PrimarySchemaType}",
|
|
"url": "${step4PrimaryPageUrl}"
|
|
}
|
|
</script>`;
|
|
}
|
|
|
|
return `export const schemaPayload = {
|
|
"@context": "https://schema.org",
|
|
"@type": "${step4PrimarySchemaType}",
|
|
url: "${step4PrimaryPageUrl}",
|
|
};`;
|
|
}, [selectedPlatform, step4PrimaryPageUrl, step4PrimarySchemaType]);
|
|
const platformOutputPreviewPayload = React.useMemo(() => ({
|
|
demo: true,
|
|
livePublish: false,
|
|
platform: {
|
|
key: selectedPlatform,
|
|
label: selectedPlatformLabel,
|
|
packageType: selectedPlatformMeta.payloadLabel,
|
|
implementation: selectedPlatformMeta.implementationLabel,
|
|
destination: selectedPlatformMeta.developerDestination,
|
|
},
|
|
site: {
|
|
id: report?.site?.id || null,
|
|
name: report?.site?.name || null,
|
|
baseUrl: report?.site?.base_url || trimmedUrl || null,
|
|
detectedPlatform: report?.analysis?.platform?.label || 'Unknown',
|
|
},
|
|
analysis: {
|
|
analyzedAt: report?.analysis?.fetchedAt || new Date().toISOString(),
|
|
requestedPages: requestedPagesForRun,
|
|
actualPagesAnalyzed,
|
|
failedPages: failedPagesCount,
|
|
exactRequestedPageTargetMet: requestedPageTargetMet,
|
|
structuredDataTypes: step4SchemaTypes,
|
|
wordpress: wordpressAnalysis?.detected ? {
|
|
detected: true,
|
|
detectedPlugins: detectedWordPressPlugins.map((plugin) => plugin.label || plugin.key || 'Plugin'),
|
|
schemaOwnership: wordpressSchemaOwnership?.label || null,
|
|
implementationGuidance: wordpressSchemaOwnership?.recommendedImplementation || null,
|
|
duplicateRisk: wordpressDuplicateRisk ? {
|
|
level: wordpressDuplicateRisk.level || null,
|
|
label: wordpressDuplicateRisk.label || null,
|
|
summary: wordpressDuplicateRisk.summary || null,
|
|
warnings: wordpressDuplicateRisk.warnings || [],
|
|
} : null,
|
|
pluginRecommendations: wordpressPluginRecommendations.map((recommendation) => ({
|
|
plugin: recommendation.label || recommendation.key || 'Plugin',
|
|
title: recommendation.title || null,
|
|
recommendedApproach: recommendation.recommendedApproach || null,
|
|
applicableSchemaTypes: recommendation.applicableSchemaTypes || [],
|
|
})),
|
|
} : null,
|
|
notice: report?.analysis?.notice || null,
|
|
},
|
|
pages: analyzedPages.map((page) => ({
|
|
url: page.url || null,
|
|
title: page.title || null,
|
|
statusCode: page.statusCode || null,
|
|
hasStructuredData: Boolean(page.hasStructuredData),
|
|
schemaTypes: page.schemaTypes || page.jsonLdTypes || [],
|
|
wordpressPlugins: (page.wordpress?.plugins || []).map((plugin) => plugin.label || plugin.key || 'Plugin'),
|
|
})),
|
|
finalDeliverables: platformFinalDeliverables.map((deliverable) => ({
|
|
id: deliverable.id,
|
|
title: deliverable.title,
|
|
owner: deliverable.owner,
|
|
destination: deliverable.destination,
|
|
status: deliverable.statusLabel,
|
|
})),
|
|
pageMappings: platformPageMappings.map((mapping) => ({
|
|
page: mapping.pageLabel,
|
|
url: mapping.pageUrl,
|
|
schemaType: mapping.schemaType,
|
|
deliverable: mapping.deliverableTitle,
|
|
destination: mapping.destination,
|
|
packageFile: mapping.packageFile,
|
|
action: mapping.actionLabel,
|
|
status: mapping.statusLabel,
|
|
})),
|
|
timeline: platformPublishTimeline.map((step) => ({
|
|
id: step.id,
|
|
label: step.label,
|
|
status: step.status,
|
|
timeLabel: step.timeLabel,
|
|
})),
|
|
recommendations: exportableRecommendations.map((recommendation) => ({
|
|
id: recommendation.id,
|
|
title: recommendation.title,
|
|
priority: recommendation.priority || 'n/a',
|
|
scope: recommendation.page_scope || 'n/a',
|
|
schemaType: recommendation.schema_type || 'Schema',
|
|
})),
|
|
}), [
|
|
actualPagesAnalyzed,
|
|
analyzedPages,
|
|
exportableRecommendations,
|
|
failedPagesCount,
|
|
platformFinalDeliverables,
|
|
platformPageMappings,
|
|
platformPublishTimeline,
|
|
report?.analysis?.fetchedAt,
|
|
report?.analysis?.notice,
|
|
report?.analysis?.platform?.label,
|
|
report?.site?.base_url,
|
|
report?.site?.id,
|
|
report?.site?.name,
|
|
requestedPageTargetMet,
|
|
requestedPagesForRun,
|
|
selectedPlatform,
|
|
selectedPlatformLabel,
|
|
selectedPlatformMeta.developerDestination,
|
|
selectedPlatformMeta.implementationLabel,
|
|
selectedPlatformMeta.payloadLabel,
|
|
step4SchemaTypes,
|
|
trimmedUrl,
|
|
wordpressAnalysis?.detected,
|
|
detectedWordPressPlugins,
|
|
wordpressSchemaOwnership?.label,
|
|
wordpressSchemaOwnership?.recommendedImplementation,
|
|
wordpressDuplicateRisk,
|
|
wordpressPluginRecommendations,
|
|
]);
|
|
const platformOutputPreviewJson = React.useMemo(
|
|
() => JSON.stringify(platformOutputPreviewPayload, null, 2),
|
|
[platformOutputPreviewPayload],
|
|
);
|
|
const platformHandoffText = React.useMemo(() => [
|
|
`Step 4 demo handoff for ${selectedPlatformLabel}`,
|
|
`Site: ${report?.site?.base_url || trimmedUrl || 'Not set'}`,
|
|
`Requested pages: ${requestedPagesForRun}`,
|
|
`Pages analyzed: ${actualPagesAnalyzed}`,
|
|
`Platform package: ${selectedPlatformMeta.payloadLabel}`,
|
|
`Implementation target: ${selectedPlatformMeta.developerDestination}`,
|
|
step4SchemaTypes.length > 0 ? `Schema types: ${step4SchemaTypes.join(', ')}` : 'Schema types: To be determined from recommendations',
|
|
selectedPlatform === 'wordpress' && detectedWordPressPlugins.length > 0
|
|
? `Detected WordPress plugins: ${detectedWordPressPlugins.map((plugin) => plugin.label || plugin.key || 'Plugin').join(', ')}`
|
|
: '',
|
|
selectedPlatform === 'wordpress' && wordpressSchemaOwnership?.recommendedImplementation
|
|
? `WordPress implementation guidance: ${wordpressSchemaOwnership.recommendedImplementation}`
|
|
: '',
|
|
selectedPlatform === 'wordpress' && wordpressDuplicateRisk?.label
|
|
? `Duplicate-schema risk: ${wordpressDuplicateRisk.label}${wordpressDuplicateRisk.summary ? ` — ${wordpressDuplicateRisk.summary}` : ''}`
|
|
: '',
|
|
selectedPlatform === 'wordpress' && wordpressPluginRecommendations.length > 0
|
|
? `Plugin implementation recommendations: ${wordpressPluginRecommendations.slice(0, 2).map((recommendation) => `${recommendation.label || recommendation.key || 'Plugin'} — ${recommendation.recommendedApproach || recommendation.summary || 'Review detected plugin output before coding.'}`).join(' | ')}`
|
|
: '',
|
|
'',
|
|
'Demo workflow preview:',
|
|
...selectedPlatformMeta.steps.map((step, index) => `${index + 1}. ${step}`),
|
|
].filter(Boolean).join('\n'), [
|
|
actualPagesAnalyzed,
|
|
report?.site?.base_url,
|
|
requestedPagesForRun,
|
|
selectedPlatformLabel,
|
|
selectedPlatformMeta.developerDestination,
|
|
selectedPlatformMeta.payloadLabel,
|
|
selectedPlatformMeta.steps,
|
|
selectedPlatform,
|
|
step4SchemaTypes,
|
|
trimmedUrl,
|
|
detectedWordPressPlugins,
|
|
wordpressSchemaOwnership?.recommendedImplementation,
|
|
wordpressDuplicateRisk?.label,
|
|
wordpressDuplicateRisk?.summary,
|
|
wordpressPluginRecommendations,
|
|
]);
|
|
const deliverySummaryCards = [
|
|
{
|
|
label: 'Code-ready fixes',
|
|
value: exportableRecommendations.length,
|
|
helper: exportableRecommendations.length > 0
|
|
? `${exportableRecommendations.length} recommendation${exportableRecommendations.length === 1 ? '' : 's'} can be exported right now.`
|
|
: 'No code-ready recommendations yet. Use the Recommendations tab to refine the handoff.',
|
|
iconPath: icon.mdiCodeBraces,
|
|
toneClassName: exportableRecommendations.length > 0
|
|
? 'border-emerald-200 bg-emerald-50/80 dark:border-emerald-500/30 dark:bg-emerald-500/10'
|
|
: 'border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-950/40',
|
|
},
|
|
{
|
|
label: 'Email recipient',
|
|
value: hasEmailRecipient ? 'Ready' : 'Missing',
|
|
helper: hasEmailRecipient ? trimmedEmailTo : 'Add a developer email before sending the handoff.',
|
|
iconPath: hasEmailRecipient ? icon.mdiEmailOutline : icon.mdiAlertCircleOutline,
|
|
toneClassName: hasEmailRecipient
|
|
? 'border-sky-200 bg-sky-50/80 dark:border-sky-500/30 dark:bg-sky-500/10'
|
|
: 'border-amber-200 bg-amber-50/80 dark:border-amber-500/30 dark:bg-amber-500/10',
|
|
},
|
|
{
|
|
label: 'Platform output',
|
|
value: entitlements?.canPlatformOutput ? 'Unlocked' : 'Premium',
|
|
helper: entitlements?.canPlatformOutput
|
|
? `${selectedPlatformLabel} output can be checked in Step 4.`
|
|
: 'Premium is required for Step 4 platform-specific output.',
|
|
iconPath: entitlements?.canPlatformOutput ? icon.mdiCheckCircleOutline : icon.mdiLockOutline,
|
|
toneClassName: entitlements?.canPlatformOutput
|
|
? 'border-violet-200 bg-violet-50/80 dark:border-violet-500/30 dark:bg-violet-500/10'
|
|
: 'border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-950/40',
|
|
},
|
|
];
|
|
const deliveryChecklist = [
|
|
{
|
|
id: 'recipient',
|
|
label: 'Recipient email',
|
|
value: hasEmailRecipient ? trimmedEmailTo : 'Add an email to send the handoff.',
|
|
isReady: hasEmailRecipient,
|
|
},
|
|
{
|
|
id: 'export',
|
|
label: 'Export package',
|
|
value: exportableRecommendations.length > 0
|
|
? `${exportableRecommendations.length} code-ready recommendation${exportableRecommendations.length === 1 ? '' : 's'} available.`
|
|
: 'Export all still works, but no generated code is attached yet.',
|
|
isReady: Boolean(report?.site?.id),
|
|
},
|
|
{
|
|
id: 'platform',
|
|
label: 'Step 4 output',
|
|
value: entitlements?.canPlatformOutput
|
|
? `${selectedPlatformLabel} output is available for this workspace.`
|
|
: `${selectedPlatformLabel} output requires Premium access.`,
|
|
isReady: Boolean(entitlements?.canPlatformOutput),
|
|
},
|
|
];
|
|
const siteSnapshotStats = [
|
|
{
|
|
label: 'Platform',
|
|
value: report?.analysis?.platform?.label || 'Unknown platform',
|
|
},
|
|
{
|
|
label: 'Last updated',
|
|
value: analyzedTimestamp || 'Just now',
|
|
},
|
|
];
|
|
const planDetailStats = [
|
|
{
|
|
label: 'Access level',
|
|
value: entitlements?.canPlatformOutput ? 'Premium' : 'Basic',
|
|
},
|
|
{
|
|
label: 'Pages allowed',
|
|
value: `Up to ${maxPagesPerCrawl} pages per crawl`,
|
|
},
|
|
{
|
|
label: 'Step 4 output',
|
|
value: entitlements?.canPlatformOutput ? 'Available to check' : 'Premium only',
|
|
},
|
|
];
|
|
const overviewStats = [
|
|
{
|
|
label: 'Pages analyzed',
|
|
value: crawlPlan?.actualPagesAnalyzed || analyzedPages.length || 0,
|
|
helper: 'Crawl total',
|
|
iconPath: icon.mdiFileDocumentOutline,
|
|
},
|
|
{
|
|
label: 'Recommendations',
|
|
value: recommendations.length,
|
|
helper: 'Next actions',
|
|
iconPath: icon.mdiLightbulbOutline,
|
|
},
|
|
{
|
|
label: 'Structured data',
|
|
value: report?.analysis?.crawlSummary?.pagesWithStructuredData ?? (report?.analysis?.schema?.hasStructuredData ? 1 : 0),
|
|
helper: 'Pages with schema',
|
|
iconPath: icon.mdiCheckCircleOutline,
|
|
},
|
|
{
|
|
label: 'JSON-LD blocks',
|
|
value: report?.analysis?.schema?.jsonLd?.count || 0,
|
|
helper: 'Detected snippets',
|
|
iconPath: icon.mdiCodeJson,
|
|
},
|
|
{
|
|
label: 'Failed fetches',
|
|
value: report?.analysis?.crawlSummary?.failedPages ?? failedPages.length,
|
|
helper: 'Needs follow-up',
|
|
iconPath: icon.mdiAlertCircleOutline,
|
|
},
|
|
{
|
|
label: 'Invalid blocks',
|
|
value: invalidJsonLdBlocks.length,
|
|
helper: 'Needs cleanup',
|
|
iconPath: icon.mdiAlertOutline,
|
|
},
|
|
];
|
|
const crawlPlanStats = [
|
|
{
|
|
label: 'Requested',
|
|
value: crawlPlan?.requestedPages || 1,
|
|
},
|
|
{
|
|
label: 'Plan limit',
|
|
value: crawlPlan?.allowedPages || maxPagesPerCrawl,
|
|
},
|
|
{
|
|
label: 'Analyzed',
|
|
value: crawlPlan?.actualPagesAnalyzed || 0,
|
|
},
|
|
{
|
|
label: 'Failed',
|
|
value: report?.analysis?.crawlSummary?.failedPages ?? failedPages.length,
|
|
},
|
|
];
|
|
const crawlSummaryStats = report?.analysis?.crawlSummary
|
|
? [
|
|
{
|
|
label: 'Pages without structured data',
|
|
value: report.analysis.crawlSummary.pagesWithoutStructuredData ?? 0,
|
|
},
|
|
{
|
|
label: 'Discovered internal pages',
|
|
value: report.analysis.crawlSummary.discoveredInternalPages ?? analyzedPages.length,
|
|
},
|
|
]
|
|
: [];
|
|
|
|
React.useEffect(() => {
|
|
if ((draftIncludeTargets.length > 0 || draftExcludeTargets.length > 0) && !openSections.targeting) {
|
|
setOpenSections((currentSections) => ({
|
|
...currentSections,
|
|
targeting: true,
|
|
}));
|
|
}
|
|
}, [draftExcludeTargets.length, draftIncludeTargets.length, openSections.targeting]);
|
|
|
|
React.useEffect(() => {
|
|
if (activePageFilter === 'failed' && failedPages.length > 0) {
|
|
setIsFailedPagesExpanded(true);
|
|
}
|
|
}, [activePageFilter, failedPages.length]);
|
|
|
|
const toggleSection = (section: SetupSectionId) => {
|
|
setOpenSections((currentSections) => ({
|
|
...currentSections,
|
|
[section]: !currentSections[section],
|
|
}));
|
|
};
|
|
|
|
const toggleRecommendationCode = (recommendationId: string) => {
|
|
setExpandedRecommendationIds((currentIds) => ({
|
|
...currentIds,
|
|
[recommendationId]: !currentIds[recommendationId],
|
|
}));
|
|
};
|
|
|
|
const handleAnalyze = async () => {
|
|
if (!trimmedUrl) {
|
|
notify('error', 'Enter a website URL first.');
|
|
return;
|
|
}
|
|
|
|
if (isRequestedPagesOverLimit) {
|
|
notify(
|
|
'error',
|
|
`This analyzer supports up to ${maxPagesPerCrawl} page${maxPagesPerCrawl === 1 ? '' : 's'} per crawl. Reduce the page count to continue.`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setIsAnalyzing(true);
|
|
const response = await axios.post<ReportResponse>('/sites/analyze', {
|
|
url: trimmedUrl,
|
|
requestedPages,
|
|
includeTargets,
|
|
excludeTargets,
|
|
});
|
|
setReport(response.data);
|
|
|
|
if (response.data.error) {
|
|
notify('error', response.data.error);
|
|
} else if (response.data.analysis?.notice) {
|
|
notify('info', response.data.analysis.notice);
|
|
} else {
|
|
notify('success', 'Site analyzed successfully.');
|
|
}
|
|
} catch (error: any) {
|
|
console.error('Schema analyze failed:', error);
|
|
notify('error', error?.response?.data || 'Failed to analyze the site.');
|
|
} finally {
|
|
setIsAnalyzing(false);
|
|
}
|
|
};
|
|
|
|
const handleCopyCode = async (recommendation: Recommendation) => {
|
|
if (!recommendation.suggested_schema) {
|
|
notify('info', 'This recommendation does not include code yet.');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await navigator.clipboard.writeText(recommendation.suggested_schema);
|
|
notify('success', 'Schema code copied to clipboard.');
|
|
} catch (error) {
|
|
console.error('Copy schema failed:', error);
|
|
notify('error', 'Unable to copy code in this browser.');
|
|
}
|
|
};
|
|
|
|
const handleCopyText = async (value: string, successMessage: string) => {
|
|
try {
|
|
await navigator.clipboard.writeText(value);
|
|
notify('success', successMessage);
|
|
} catch (error) {
|
|
console.error('Copy text failed:', error);
|
|
notify('error', 'Unable to copy text in this browser.');
|
|
}
|
|
};
|
|
|
|
const handleSimulatePlatformSend = async () => {
|
|
if (!report?.site?.id) {
|
|
notify('error', 'Analyze a site first.');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setIsSimulatingPlatformSend(true);
|
|
await new Promise((resolve) => {
|
|
window.setTimeout(resolve, 900);
|
|
});
|
|
|
|
const simulatedAt = new Date().toISOString();
|
|
setLastPlatformSimulationAt(simulatedAt);
|
|
notify('success', `Demo output prepared for ${selectedPlatformLabel}. No live publish was performed.`);
|
|
} catch (error) {
|
|
console.error('Simulate Step 4 send failed:', error);
|
|
notify('error', 'Unable to simulate Step 4 output right now.');
|
|
} finally {
|
|
setIsSimulatingPlatformSend(false);
|
|
}
|
|
};
|
|
|
|
const downloadBlob = (blob: Blob, filename: string) => {
|
|
const blobUrl = window.URL.createObjectURL(blob);
|
|
const link = document.createElement('a');
|
|
link.href = blobUrl;
|
|
link.download = filename;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
link.remove();
|
|
window.URL.revokeObjectURL(blobUrl);
|
|
};
|
|
|
|
const parseFilename = (contentDisposition?: string) => {
|
|
const match = contentDisposition?.match(/filename="?([^";]+)"?/i);
|
|
return match?.[1] || 'schema-export.txt';
|
|
};
|
|
|
|
const extractBlobError = async (error: any) => {
|
|
if (error?.response?.data instanceof Blob) {
|
|
return error.response.data.text();
|
|
}
|
|
|
|
return error?.response?.data || 'Request failed.';
|
|
};
|
|
|
|
const handleExportRecommendation = async (recommendation: Recommendation) => {
|
|
try {
|
|
setExportingId(recommendation.id);
|
|
const response = await axios.post('/sites/export', {
|
|
recommendationId: recommendation.id,
|
|
}, {
|
|
responseType: 'blob',
|
|
});
|
|
const filename = parseFilename(response.headers['content-disposition']);
|
|
downloadBlob(response.data, filename);
|
|
notify('success', 'Recommendation exported.');
|
|
} catch (error: any) {
|
|
console.error('Export recommendation failed:', error);
|
|
notify('error', await extractBlobError(error));
|
|
} finally {
|
|
setExportingId(null);
|
|
}
|
|
};
|
|
|
|
const handleExportAll = async () => {
|
|
if (!report?.site?.id) {
|
|
notify('error', 'Analyze a site first.');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setIsExportingAll(true);
|
|
const response = await axios.post('/sites/export', {
|
|
siteId: report.site.id,
|
|
}, {
|
|
responseType: 'blob',
|
|
});
|
|
const filename = parseFilename(response.headers['content-disposition']);
|
|
downloadBlob(response.data, filename);
|
|
notify('success', 'Full recommendation export downloaded.');
|
|
} catch (error: any) {
|
|
console.error('Export all failed:', error);
|
|
notify('error', await extractBlobError(error));
|
|
} finally {
|
|
setIsExportingAll(false);
|
|
}
|
|
};
|
|
|
|
const handleEmailCode = async (recommendationId?: string) => {
|
|
if (!report?.site?.id) {
|
|
notify('error', 'Analyze a site first.');
|
|
return;
|
|
}
|
|
|
|
if (!trimmedEmailTo) {
|
|
notify('error', 'Add a recipient email first.');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setEmailingId(recommendationId || 'all');
|
|
await axios.post('/sites/email-code', recommendationId
|
|
? {
|
|
recommendationId,
|
|
to: trimmedEmailTo,
|
|
}
|
|
: {
|
|
siteId: report.site.id,
|
|
to: trimmedEmailTo,
|
|
});
|
|
notify('success', recommendationId ? 'Schema code emailed.' : 'Full recommendation report emailed.');
|
|
} catch (error: any) {
|
|
console.error('Email schema failed:', error);
|
|
notify('error', error?.response?.data || 'Failed to send email.');
|
|
} finally {
|
|
setEmailingId(null);
|
|
}
|
|
};
|
|
|
|
const handlePlatformOutputCheck = async () => {
|
|
if (!report?.site?.id) {
|
|
notify('error', 'Analyze a site first.');
|
|
return;
|
|
}
|
|
|
|
if (!entitlements?.canPlatformOutput) {
|
|
notify('info', 'Premium unlocks Step 4 platform-specific schema output.');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setIsCheckingPlatformOutput(true);
|
|
await axios.post('/sites/export', {
|
|
siteId: report.site.id,
|
|
outputMode: 'platform',
|
|
platform: selectedPlatform,
|
|
}, {
|
|
responseType: 'blob',
|
|
});
|
|
} catch (error: any) {
|
|
console.error('Platform output check failed:', error);
|
|
notify('info', await extractBlobError(error));
|
|
} finally {
|
|
setIsCheckingPlatformOutput(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<Head>
|
|
<title>{getPageTitle('Schema Analyzer')}</title>
|
|
</Head>
|
|
<SectionMain>
|
|
<SectionTitleLineWithButton
|
|
icon={icon.mdiChartTimelineVariant}
|
|
title='Schema Analyzer'
|
|
main
|
|
>
|
|
{''}
|
|
</SectionTitleLineWithButton>
|
|
|
|
<CardBox className='mb-6 pb-24 sm:pb-0'>
|
|
<div className='space-y-5 sm:space-y-6'>
|
|
<div>
|
|
<h2 className='text-xl font-semibold text-slate-900 dark:text-white'>Analyze a customer site</h2>
|
|
<p className='mt-2 max-w-3xl text-sm leading-6 text-slate-500 dark:text-slate-300'>
|
|
Enter a domain or full URL, choose how many pages to review, and optionally focus the report on the
|
|
folders, categories, or pages that matter most. This setup keeps the page cleaner on mobile while still
|
|
supporting up to {maxPagesPerCrawl} pages per crawl.
|
|
</p>
|
|
</div>
|
|
|
|
<div className='grid gap-3 sm:gap-4 lg:grid-cols-[minmax(0,1.35fr),minmax(240px,0.65fr)]'>
|
|
<FormField
|
|
label='Website URL'
|
|
labelFor='schema-site-url'
|
|
help='Examples: example.com or https://www.example.com'
|
|
>
|
|
<input
|
|
id='schema-site-url'
|
|
name='schema-site-url'
|
|
placeholder='https://example.com'
|
|
value={url}
|
|
onChange={(event) => setUrl(event.target.value)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Enter') {
|
|
event.preventDefault();
|
|
void handleAnalyze();
|
|
}
|
|
}}
|
|
/>
|
|
</FormField>
|
|
|
|
<div className='flex flex-col gap-3 sm:gap-4'>
|
|
<FormField
|
|
label='Pages to analyze'
|
|
labelFor='schema-requested-pages'
|
|
help={`Current plan limit: ${maxPagesPerCrawl} page${maxPagesPerCrawl === 1 ? '' : 's'} per crawl`}
|
|
>
|
|
<input
|
|
id='schema-requested-pages'
|
|
name='schema-requested-pages'
|
|
type='number'
|
|
min={1}
|
|
value={requestedPages}
|
|
onChange={(event) => {
|
|
const nextValue = Number(event.target.value);
|
|
setRequestedPages(Number.isInteger(nextValue) && nextValue > 0 ? nextValue : 1);
|
|
}}
|
|
/>
|
|
</FormField>
|
|
|
|
<div className='rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3.5 text-sm text-slate-600 dark:border-slate-700 dark:bg-slate-900/40 dark:text-slate-300'>
|
|
<div className='font-semibold text-slate-900 dark:text-white'>Quick setup</div>
|
|
<div className='mt-1 text-sm text-slate-500 dark:text-slate-300'>Pick a page count here, then use Target pages below if you want a more focused report.</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{isRequestedPagesOverLimit && (
|
|
<div className='rounded-2xl border border-amber-300 bg-amber-50 p-3 text-sm text-amber-800 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-100'>
|
|
You requested {requestedPages} pages, but this analyzer is capped at {maxPagesPerCrawl}. Reduce the page count to continue.
|
|
</div>
|
|
)}
|
|
|
|
<div className='space-y-3'>
|
|
<SetupAccordionSection
|
|
title='Target pages'
|
|
description='Include only the folders or categories you want reviewed, and exclude pages you do not want reflected in the final report.'
|
|
iconPath={icon.mdiTarget}
|
|
badge={hasTargetingRules ? `${appliedIncludeTargets.length} include · ${appliedExcludeTargets.length} exclude` : 'Optional'}
|
|
isOpen={openSections.targeting}
|
|
onToggle={() => toggleSection('targeting')}
|
|
>
|
|
<div className='grid gap-3 sm:gap-4 md:grid-cols-2'>
|
|
<FormField
|
|
label='Include only these pages or folders'
|
|
labelFor='schema-include-targets'
|
|
help='Optional. Enter one full URL or path per line, for example /blog or /services/seo.'
|
|
hasTextareaHeight
|
|
>
|
|
<textarea
|
|
id='schema-include-targets'
|
|
name='schema-include-targets'
|
|
rows={4}
|
|
placeholder={`/blog
|
|
/services
|
|
https://example.com/pricing`}
|
|
value={includeTargets}
|
|
onChange={(event) => setIncludeTargets(event.target.value)}
|
|
/>
|
|
</FormField>
|
|
|
|
<FormField
|
|
label='Exclude these pages from the report'
|
|
labelFor='schema-exclude-targets'
|
|
help='Optional. Enter one full URL or path per line to leave pages or sections out of the final report.'
|
|
hasTextareaHeight
|
|
>
|
|
<textarea
|
|
id='schema-exclude-targets'
|
|
name='schema-exclude-targets'
|
|
rows={4}
|
|
placeholder={`/tag
|
|
/cart
|
|
/thank-you`}
|
|
value={excludeTargets}
|
|
onChange={(event) => setExcludeTargets(event.target.value)}
|
|
/>
|
|
</FormField>
|
|
</div>
|
|
|
|
<div className='grid gap-3 sm:grid-cols-2'>
|
|
<div className='rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm dark:border-slate-700 dark:bg-slate-950/40'>
|
|
<div className='font-semibold text-slate-900 dark:text-white'>Accepted formats</div>
|
|
<div className='mt-1 text-slate-500 dark:text-slate-300'>Full URLs or path rules like /blog, /pricing, or /category/shoes.</div>
|
|
</div>
|
|
<div className='rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm dark:border-slate-700 dark:bg-slate-950/40'>
|
|
<div className='font-semibold text-slate-900 dark:text-white'>Report behavior</div>
|
|
<div className='mt-1 text-slate-500 dark:text-slate-300'>Excluded pages stay out of the analyzed page set and the final recommendations.</div>
|
|
</div>
|
|
</div>
|
|
|
|
{hasTargetingRules && (
|
|
<div className='mt-4 space-y-3'>
|
|
{appliedIncludeTargets.length > 0 && (
|
|
<div>
|
|
<div className='mb-2 text-xs font-semibold uppercase tracking-wide text-slate-500'>Include targets</div>
|
|
<div className='flex flex-wrap gap-2'>
|
|
{appliedIncludeTargets.map((target) => (
|
|
<StatusBadge
|
|
key={`include-${target}`}
|
|
className='bg-emerald-100 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-200'
|
|
breakAll
|
|
>
|
|
{target}
|
|
</StatusBadge>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{appliedExcludeTargets.length > 0 && (
|
|
<div>
|
|
<div className='mb-2 text-xs font-semibold uppercase tracking-wide text-slate-500'>Exclude targets</div>
|
|
<div className='flex flex-wrap gap-2'>
|
|
{appliedExcludeTargets.map((target) => (
|
|
<StatusBadge
|
|
key={`exclude-${target}`}
|
|
className='bg-amber-100 text-amber-700 dark:bg-amber-500/10 dark:text-amber-200'
|
|
breakAll
|
|
>
|
|
{target}
|
|
</StatusBadge>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</SetupAccordionSection>
|
|
|
|
<div className='rounded-3xl border border-sky-200 bg-gradient-to-br from-sky-50 to-white p-4 shadow-sm dark:border-sky-500/30 dark:from-slate-950 dark:to-slate-900/70 sm:p-5'>
|
|
<div className='flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between'>
|
|
<div className='space-y-3'>
|
|
<div>
|
|
<div className='text-xs font-semibold uppercase tracking-[0.2em] text-sky-700 dark:text-sky-200'>Ready to analyze</div>
|
|
<div className='mt-1 text-lg font-semibold text-slate-900 dark:text-white'>Run a focused crawl when you are ready.</div>
|
|
<p className='mt-2 text-sm leading-6 text-slate-600 dark:text-slate-300'>
|
|
Review the target pages above, then launch the next crawl. You can analyze up to {maxPagesPerCrawl} pages per run.
|
|
</p>
|
|
</div>
|
|
<div className='flex flex-wrap gap-2 text-xs font-semibold'>
|
|
<span className='rounded-full bg-white px-3 py-1 text-slate-700 shadow-sm dark:bg-slate-950/70 dark:text-slate-200'>
|
|
{requestedPages} requested
|
|
</span>
|
|
<span className='rounded-full bg-white px-3 py-1 text-slate-700 shadow-sm dark:bg-slate-950/70 dark:text-slate-200'>
|
|
{targetingSummary}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div className='w-full lg:w-auto'>
|
|
<BaseButtons
|
|
type='justify-start lg:justify-end'
|
|
className='mb-0 w-full lg:w-auto'
|
|
classAddon='w-full mb-0 sm:w-auto'
|
|
mb='mb-0'
|
|
>
|
|
{report?.analysis && (
|
|
<BaseButton
|
|
color='whiteDark'
|
|
outline
|
|
icon={icon.mdiArrowDownCircleOutline}
|
|
label='Jump to results'
|
|
className='w-full justify-center px-4 py-3 text-sm sm:w-auto'
|
|
onClick={scrollToResults}
|
|
/>
|
|
)}
|
|
<BaseButton
|
|
color='info'
|
|
icon={icon.mdiMagnify}
|
|
label={isAnalyzing ? 'Analyzing…' : 'Analyze site'}
|
|
className='w-full justify-center px-4 py-3 text-sm shadow-sm sm:w-auto'
|
|
disabled={!hasUrl || isAnalyzing || isRequestedPagesOverLimit}
|
|
onClick={() => {
|
|
void handleAnalyze();
|
|
}}
|
|
/>
|
|
</BaseButtons>
|
|
<p className='mt-2 text-xs text-slate-500 dark:text-slate-300'>
|
|
{isRequestedPagesOverLimit
|
|
? `Reduce to ${maxPagesPerCrawl} pages to continue.`
|
|
: report?.analysis
|
|
? 'Runs the latest crawl, then jumps you straight to the refreshed results.'
|
|
: hasUrl
|
|
? 'Runs the latest crawl and opens the refreshed results below.'
|
|
: 'Add a website URL above to enable the analyzer.'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<SetupAccordionSection
|
|
title='Report options'
|
|
description='Choose the target platform for Step 4 output. This does not change what pages are reviewed; it only prepares the preferred output format for a later export step.'
|
|
iconPath={icon.mdiTuneVertical}
|
|
badge={selectedPlatformLabel}
|
|
isOpen={openSections.options}
|
|
onToggle={() => toggleSection('options')}
|
|
>
|
|
<div className='grid gap-4 lg:grid-cols-[minmax(0,1fr),minmax(220px,0.7fr)]'>
|
|
<FormField
|
|
label='Step 4 target platform'
|
|
labelFor='schema-platform-output'
|
|
help={entitlements?.canPlatformOutput
|
|
? 'Premium access detected. Step 4 platform output is reserved for the next phase.'
|
|
: 'Premium-only feature: platform-specific code output.'}
|
|
>
|
|
<select
|
|
id='schema-platform-output'
|
|
name='schema-platform-output'
|
|
value={selectedPlatform}
|
|
onChange={(event) => setSelectedPlatform(event.target.value)}
|
|
>
|
|
{PLATFORM_OPTIONS.map((platformOption) => (
|
|
<option key={platformOption.value} value={platformOption.value}>
|
|
{platformOption.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</FormField>
|
|
|
|
<SummaryPanel
|
|
label='Selected output target'
|
|
value={selectedPlatformLabel}
|
|
description='Keep this aligned with the CMS or platform your developer will implement against.'
|
|
className='rounded-xl p-4'
|
|
valueClassName='text-base'
|
|
descriptionClassName='leading-normal'
|
|
/>
|
|
</div>
|
|
</SetupAccordionSection>
|
|
|
|
<SetupAccordionSection
|
|
title='Plan details'
|
|
description='A simple summary of what this analyzer includes for the current user.'
|
|
iconPath={icon.mdiShieldOutline}
|
|
badge={`${maxPagesPerCrawl} pages per crawl`}
|
|
isOpen={openSections.limits}
|
|
onToggle={() => toggleSection('limits')}
|
|
>
|
|
<div className='grid gap-3 md:grid-cols-3'>
|
|
{planDetailStats.map((stat) => (
|
|
<SectionStatCard
|
|
key={stat.label}
|
|
label={stat.label}
|
|
value={stat.value}
|
|
className='rounded-xl px-4 py-3 text-sm shadow-none'
|
|
labelClassName='text-xs normal-case tracking-normal'
|
|
valueClassName='mt-1 text-base leading-6 sm:text-base'
|
|
/>
|
|
))}
|
|
</div>
|
|
</SetupAccordionSection>
|
|
</div>
|
|
</div>
|
|
</CardBox>
|
|
|
|
<div className='fixed inset-x-0 bottom-0 z-20 border-t border-slate-200 bg-white/95 px-4 py-3 shadow-[0_-8px_24px_rgba(15,23,42,0.08)] backdrop-blur sm:hidden dark:border-slate-700 dark:bg-slate-950/95'>
|
|
<div className='mx-auto flex max-w-md items-center gap-3'>
|
|
<div className='min-w-0 flex-1'>
|
|
<div className='truncate text-sm font-semibold text-slate-900 dark:text-white'>
|
|
{hasUrl ? trimmedUrl : 'Enter a website URL'}
|
|
</div>
|
|
<div className='mt-0.5 text-xs text-slate-500 dark:text-slate-300'>
|
|
{requestedPages} page{requestedPages === 1 ? '' : 's'} requested · {targetingSummary}
|
|
</div>
|
|
</div>
|
|
<BaseButton
|
|
color='info'
|
|
icon={icon.mdiMagnify}
|
|
label={isAnalyzing ? 'Analyzing…' : 'Analyze'}
|
|
className='shrink-0 justify-center px-4 py-3 text-sm shadow-sm'
|
|
disabled={!hasUrl || isAnalyzing || isRequestedPagesOverLimit}
|
|
onClick={() => {
|
|
void handleAnalyze();
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{report?.analysis && (
|
|
<div ref={resultsRef} id='schema-analysis-results'>
|
|
<CardBox>
|
|
<div className='flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between'>
|
|
<div>
|
|
<h3 className='text-lg font-semibold text-slate-900 dark:text-white'>Analysis results</h3>
|
|
<p className='mt-1 text-sm text-slate-500 dark:text-slate-300'>
|
|
Review the latest crawl summary, page-level findings, prioritized recommendations, and delivery actions from one mobile-friendly workspace.
|
|
</p>
|
|
</div>
|
|
<div className='flex flex-wrap items-center gap-2'>
|
|
<StatusBadge
|
|
className='bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-200'
|
|
uppercase
|
|
>
|
|
{report.analysis.platform?.label || 'Unknown platform'}
|
|
</StatusBadge>
|
|
{analyzedTimestamp && (
|
|
<StatusBadge className='bg-sky-50 text-sky-700 dark:bg-sky-500/10 dark:text-sky-200'>
|
|
{analyzedTimestamp}
|
|
</StatusBadge>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className='sticky top-2 z-10 mt-5 -mx-4 border-y border-slate-200 bg-white/95 px-4 py-2 backdrop-blur sm:top-4 sm:mx-0 sm:rounded-2xl sm:border dark:border-slate-700 dark:bg-slate-950/95'>
|
|
<div className='flex min-w-full gap-2 overflow-x-auto pb-1 sm:inline-flex sm:min-w-max sm:pb-0'>
|
|
<ResultsTabButton
|
|
label='Overview'
|
|
iconPath={icon.mdiViewDashboardOutline}
|
|
isActive={activeResultsTab === 'overview'}
|
|
onClick={() => setActiveResultsTab('overview')}
|
|
/>
|
|
<ResultsTabButton
|
|
label='Pages'
|
|
iconPath={icon.mdiFileDocumentOutline}
|
|
count={analyzedPages.length + failedPages.length}
|
|
isActive={activeResultsTab === 'pages'}
|
|
onClick={() => setActiveResultsTab('pages')}
|
|
/>
|
|
<ResultsTabButton
|
|
label='Recommendations'
|
|
iconPath={icon.mdiLightbulbOutline}
|
|
count={recommendations.length}
|
|
isActive={activeResultsTab === 'recommendations'}
|
|
onClick={() => setActiveResultsTab('recommendations')}
|
|
/>
|
|
<ResultsTabButton
|
|
label='Delivery'
|
|
iconPath={icon.mdiEmailOutline}
|
|
isActive={activeResultsTab === 'delivery'}
|
|
onClick={() => setActiveResultsTab('delivery')}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className='mt-6'>
|
|
{activeResultsTab === 'overview' && (
|
|
<div className='space-y-5'>
|
|
<div className='grid gap-4 xl:grid-cols-[minmax(0,1.15fr),minmax(0,1fr)]'>
|
|
<div className='rounded-3xl border border-slate-200 bg-slate-50/80 p-4 dark:border-slate-700 dark:bg-slate-900/40 sm:p-5'>
|
|
<div className='flex items-start gap-3'>
|
|
<span className='inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl bg-white text-slate-600 shadow-sm dark:bg-slate-950/70 dark:text-slate-100'>
|
|
<BaseIcon path={icon.mdiWeb} size={20} />
|
|
</span>
|
|
<div className='min-w-0'>
|
|
<div className='text-xs font-semibold uppercase tracking-[0.2em] text-slate-500'>Site snapshot</div>
|
|
<div className='mt-2 break-all text-sm font-semibold text-slate-900 dark:text-white'>
|
|
{report.analysis.analyzedUrl || report.site?.base_url || '—'}
|
|
</div>
|
|
<div className='mt-2 text-sm text-slate-500 dark:text-slate-300'>
|
|
{report.analysis.pageTitle || 'No page title found for the analyzed page yet.'}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className='mt-4 grid gap-3 sm:grid-cols-2'>
|
|
{siteSnapshotStats.map((stat) => (
|
|
<SectionStatCard
|
|
key={stat.label}
|
|
label={stat.label}
|
|
value={stat.value}
|
|
className='border-0 bg-white px-4 py-3 shadow-sm dark:bg-slate-950/70'
|
|
valueClassName='mt-1 text-sm leading-6 sm:text-sm'
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className='grid grid-cols-2 gap-3 sm:gap-4'>
|
|
{overviewStats.map((stat) => (
|
|
<SectionStatCard
|
|
key={stat.label}
|
|
label={stat.label}
|
|
value={stat.value}
|
|
helper={stat.helper}
|
|
iconPath={stat.iconPath}
|
|
className='p-3.5 sm:p-4'
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{crawlPlan && (
|
|
<div className='rounded-2xl border border-sky-200 bg-sky-50 p-4 text-sm text-sky-900 dark:border-sky-500/30 dark:bg-sky-500/10 dark:text-sky-100'>
|
|
<div className='flex flex-wrap items-center justify-between gap-2'>
|
|
<div className='font-semibold'>Crawl summary</div>
|
|
<div className='text-xs font-semibold uppercase tracking-wide text-sky-700 dark:text-sky-100'>
|
|
Focused for mobile review
|
|
</div>
|
|
</div>
|
|
<div className='mt-3 grid grid-cols-2 gap-3 md:grid-cols-4'>
|
|
{crawlPlanStats.map((stat) => (
|
|
<SectionStatCard
|
|
key={stat.label}
|
|
label={stat.label}
|
|
value={stat.value}
|
|
className='rounded-xl border-0 bg-white/70 px-3 py-2 shadow-none dark:bg-slate-950/30'
|
|
valueClassName='mt-1 text-base leading-6 sm:text-base'
|
|
/>
|
|
))}
|
|
</div>
|
|
{report.analysis.notice && <div className='mt-3 text-sm'>{report.analysis.notice}</div>}
|
|
</div>
|
|
)}
|
|
|
|
{report.analysis.crawlSummary && (
|
|
<div className='grid gap-3 sm:grid-cols-2 sm:gap-4'>
|
|
{crawlSummaryStats.map((stat) => (
|
|
<SectionStatCard
|
|
key={stat.label}
|
|
label={stat.label}
|
|
value={stat.value}
|
|
className='shadow-none'
|
|
valueClassName='mt-2 text-sm leading-6 sm:text-sm'
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{hasTargetingRules && (
|
|
<div className='rounded-2xl border border-slate-200 p-4 dark:border-slate-700'>
|
|
<div className='text-xs font-semibold uppercase tracking-wide text-slate-500'>Applied targeting</div>
|
|
<div className='mt-3 space-y-3'>
|
|
{appliedIncludeTargets.length > 0 && (
|
|
<div>
|
|
<div className='mb-2 text-sm font-semibold text-slate-900 dark:text-white'>Included</div>
|
|
<div className='flex flex-wrap gap-2'>
|
|
{appliedIncludeTargets.map((target) => (
|
|
<StatusBadge
|
|
key={`overview-include-${target}`}
|
|
className='bg-emerald-100 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-200'
|
|
>
|
|
{target}
|
|
</StatusBadge>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{appliedExcludeTargets.length > 0 && (
|
|
<div>
|
|
<div className='mb-2 text-sm font-semibold text-slate-900 dark:text-white'>Excluded</div>
|
|
<div className='flex flex-wrap gap-2'>
|
|
{appliedExcludeTargets.map((target) => (
|
|
<StatusBadge
|
|
key={`overview-exclude-${target}`}
|
|
className='bg-amber-100 text-amber-700 dark:bg-amber-500/10 dark:text-amber-200'
|
|
>
|
|
{target}
|
|
</StatusBadge>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{detectedSchemaTypes.length > 0 && (
|
|
<div>
|
|
<div className='mb-2 text-xs font-semibold uppercase tracking-wide text-slate-500'>Detected schema types</div>
|
|
<div className='flex flex-wrap gap-2'>
|
|
{detectedSchemaTypes.map((typeName) => (
|
|
<StatusBadge
|
|
key={typeName}
|
|
className='bg-emerald-100 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-200'
|
|
>
|
|
{typeName}
|
|
</StatusBadge>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{wordpressAnalysis?.detected && (
|
|
<div className='rounded-2xl border border-slate-200 p-4 dark:border-slate-700'>
|
|
<div className='flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between'>
|
|
<div className='space-y-3'>
|
|
<div>
|
|
<div className='text-xs font-semibold uppercase tracking-wide text-slate-500'>WordPress plugin detection</div>
|
|
<div className='mt-1 text-sm text-slate-500 dark:text-slate-300'>
|
|
{wordpressAnalysis.detectedPageCount || 0} analyzed page{(wordpressAnalysis.detectedPageCount || 0) === 1 ? '' : 's'} showed WordPress signals.
|
|
</div>
|
|
</div>
|
|
<div className='flex flex-wrap gap-2'>
|
|
{detectedWordPressPlugins.length > 0 ? detectedWordPressPlugins.map((plugin) => (
|
|
<StatusBadge
|
|
key={plugin.key || plugin.label}
|
|
className='bg-sky-100 text-sky-700 dark:bg-sky-500/10 dark:text-sky-200'
|
|
>
|
|
{(plugin.label || plugin.key || 'Plugin')}
|
|
{plugin.confidence ? ` (${plugin.confidence})` : ''}
|
|
</StatusBadge>
|
|
)) : (
|
|
<StatusBadge className='bg-slate-100 text-slate-600 dark:bg-slate-900 dark:text-slate-300'>
|
|
WordPress detected, but no common plugins were identified yet
|
|
</StatusBadge>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className='max-w-3xl space-y-3'>
|
|
{wordpressSchemaOwnership && (
|
|
<div className='rounded-2xl bg-slate-50 px-4 py-3 text-sm text-slate-600 dark:bg-slate-900/60 dark:text-slate-300'>
|
|
<div className='text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400'>Schema ownership guidance</div>
|
|
<div className='mt-1 font-semibold text-slate-900 dark:text-white'>{wordpressSchemaOwnership.label}</div>
|
|
<p className='mt-2 leading-6'>{wordpressSchemaOwnership.summary}</p>
|
|
{wordpressSchemaOwnership.recommendedImplementation && (
|
|
<p className='mt-2 leading-6 text-slate-700 dark:text-slate-200'>
|
|
Recommended path: {wordpressSchemaOwnership.recommendedImplementation}
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{wordpressDuplicateRisk && (
|
|
<div className={`rounded-2xl border px-4 py-3 text-sm ${wordpressDuplicateRiskToneClassName}`}>
|
|
<div className='flex flex-wrap items-center gap-2'>
|
|
<div className='text-xs font-semibold uppercase tracking-wide'>Duplicate-schema risk</div>
|
|
<StatusBadge className={wordpressDuplicateRiskBadgeClassName} compact>
|
|
{wordpressDuplicateRisk.label || 'Risk review recommended'}
|
|
</StatusBadge>
|
|
</div>
|
|
{wordpressDuplicateRisk.summary && (
|
|
<p className='mt-2 leading-6'>{wordpressDuplicateRisk.summary}</p>
|
|
)}
|
|
{(wordpressDuplicateRisk.warnings || []).length > 0 && (
|
|
<ul className='mt-2 list-disc space-y-1 pl-5'>
|
|
{(wordpressDuplicateRisk.warnings || []).map((warning) => (
|
|
<li key={warning}>{warning}</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{wordpressPluginRecommendations.length > 0 && (
|
|
<div className='rounded-2xl border border-slate-200 bg-white p-4 dark:border-slate-700 dark:bg-slate-950/40'>
|
|
<div className='text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400'>Plugin-specific implementation recommendations</div>
|
|
<div className='mt-3 grid gap-3'>
|
|
{wordpressPluginRecommendations.map((recommendation) => (
|
|
<div key={recommendation.key || recommendation.title} className='rounded-2xl border border-slate-200 bg-slate-50/80 p-3 dark:border-slate-700 dark:bg-slate-900/40'>
|
|
<div className='flex flex-wrap items-center gap-2'>
|
|
<div className='font-semibold text-slate-900 dark:text-white'>{recommendation.title || recommendation.label || recommendation.key || 'Plugin guidance'}</div>
|
|
{recommendation.priority && (
|
|
<StatusBadge className={recommendation.priority === 'high' ? 'bg-rose-100 text-rose-700 dark:bg-rose-500/10 dark:text-rose-200' : 'bg-slate-100 text-slate-700 dark:bg-slate-900 dark:text-slate-200'} compact>
|
|
{recommendation.priority}
|
|
</StatusBadge>
|
|
)}
|
|
</div>
|
|
{recommendation.summary && (
|
|
<p className='mt-2 text-sm leading-6 text-slate-600 dark:text-slate-300'>{recommendation.summary}</p>
|
|
)}
|
|
{recommendation.recommendedApproach && (
|
|
<p className='mt-2 text-sm leading-6 text-slate-700 dark:text-slate-200'>
|
|
Recommended approach: {recommendation.recommendedApproach}
|
|
</p>
|
|
)}
|
|
{(recommendation.applicableSchemaTypes || []).length > 0 && (
|
|
<div className='mt-3 flex flex-wrap gap-2'>
|
|
{(recommendation.applicableSchemaTypes || []).map((schemaType) => (
|
|
<StatusBadge key={`${recommendation.key || recommendation.title}-${schemaType}`} className='bg-slate-100 text-slate-700 dark:bg-slate-900 dark:text-slate-200' compact>
|
|
{schemaType}
|
|
</StatusBadge>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{invalidJsonLdBlocks.length > 0 && (
|
|
<div className='rounded-2xl border border-amber-300 bg-amber-50 p-4 text-sm text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100'>
|
|
<div className='font-semibold'>Invalid JSON-LD detected</div>
|
|
<ul className='mt-2 list-disc space-y-1 pl-5'>
|
|
{invalidJsonLdBlocks.map((block) => (
|
|
<li key={`${block.index}-${block.message}`}>
|
|
Block {block.index + 1}: {block.message}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
|
|
{report.analysis.error && (
|
|
<div className='rounded-xl border border-rose-200 bg-rose-50 p-3 text-rose-700 dark:border-rose-500/40 dark:bg-rose-500/10 dark:text-rose-200'>
|
|
{report.analysis.error}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{activeResultsTab === 'pages' && (
|
|
<div className='space-y-5'>
|
|
<div className='rounded-2xl border border-slate-200 bg-slate-50/80 p-3 dark:border-slate-700 dark:bg-slate-950/30 sm:p-4'>
|
|
<div className='flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between'>
|
|
<div>
|
|
<div className='text-xs font-semibold uppercase tracking-wide text-slate-500'>Quick filters</div>
|
|
<p className='mt-1 text-sm text-slate-500 dark:text-slate-300'>
|
|
Narrow the page list to the pages that need attention most on mobile.
|
|
</p>
|
|
</div>
|
|
<div className='grid grid-cols-2 gap-2 sm:hidden'>
|
|
{pageFilterOptions.map((filterOption) => (
|
|
<PageFilterChip
|
|
key={filterOption.id}
|
|
label={filterOption.label}
|
|
count={filterOption.count}
|
|
iconPath={filterOption.iconPath}
|
|
isActive={activePageFilter === filterOption.id}
|
|
onClick={() => setActivePageFilter(filterOption.id)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className='-mx-1 mt-3 hidden gap-2 overflow-x-auto px-1 pb-1 sm:flex'>
|
|
{pageFilterOptions.map((filterOption) => (
|
|
<PageFilterChip
|
|
key={filterOption.id}
|
|
label={filterOption.label}
|
|
count={filterOption.count}
|
|
iconPath={filterOption.iconPath}
|
|
isActive={activePageFilter === filterOption.id}
|
|
onClick={() => setActivePageFilter(filterOption.id)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{filteredAnalyzedPages.length === 0 && !shouldShowFailedSection && (
|
|
<div className='rounded-2xl border border-dashed border-slate-300 p-6 text-sm text-slate-500 dark:border-slate-700 dark:text-slate-300'>
|
|
{emptyPagesStateMessage}
|
|
</div>
|
|
)}
|
|
|
|
{filteredAnalyzedPages.length > 0 && (
|
|
<div>
|
|
<div className='mb-3 flex items-center justify-between gap-3'>
|
|
<div className='text-xs font-semibold uppercase tracking-wide text-slate-500'>
|
|
{activePageFilter === 'withSchema'
|
|
? 'Pages with structured data'
|
|
: activePageFilter === 'missingSchema'
|
|
? 'Pages missing structured data'
|
|
: 'Analyzed pages'}
|
|
</div>
|
|
<div className='text-xs text-slate-500 dark:text-slate-300'>
|
|
{filteredAnalyzedPages.length} result{filteredAnalyzedPages.length === 1 ? '' : 's'}
|
|
</div>
|
|
</div>
|
|
<div className='space-y-3'>
|
|
{filteredAnalyzedPages.map((page) => (
|
|
<div
|
|
key={page.url}
|
|
className='rounded-3xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-700 dark:bg-slate-950/20 sm:p-5'
|
|
>
|
|
<div className='flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between'>
|
|
<div className='min-w-0 flex-1'>
|
|
<div className='text-xs font-semibold uppercase tracking-wide text-slate-500'>
|
|
{page.title || 'Untitled page'}
|
|
</div>
|
|
<div className='mt-1 break-all text-sm font-medium leading-6 text-slate-900 dark:text-white'>
|
|
{page.url}
|
|
</div>
|
|
</div>
|
|
<div className='flex flex-wrap gap-2 text-xs'>
|
|
<StatusBadge className='bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-200'>
|
|
Status {page.statusCode || '—'}
|
|
</StatusBadge>
|
|
<StatusBadge
|
|
className={page.hasStructuredData
|
|
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-200'
|
|
: 'bg-amber-100 text-amber-700 dark:bg-amber-500/10 dark:text-amber-200'}
|
|
>
|
|
{page.hasStructuredData ? 'Structured data found' : 'Needs schema'}
|
|
</StatusBadge>
|
|
</div>
|
|
</div>
|
|
|
|
<div className='mt-4 grid gap-3 sm:grid-cols-2'>
|
|
<SummaryPanel
|
|
label='Page status'
|
|
description={page.hasStructuredData
|
|
? 'Structured data was detected on this page.'
|
|
: 'No structured data was detected yet for this page.'}
|
|
className='border-0 bg-slate-50 px-3 py-3 text-sm text-slate-600 dark:bg-slate-900/60 dark:text-slate-300'
|
|
descriptionClassName='leading-normal text-inherit dark:text-inherit'
|
|
/>
|
|
<SummaryPanel
|
|
label='Schema types'
|
|
className='border-0 bg-slate-50 px-3 py-3 text-sm text-slate-600 dark:bg-slate-900/60 dark:text-slate-300'
|
|
descriptionClassName='leading-normal text-inherit dark:text-inherit'
|
|
>
|
|
{(page.schemaTypes || page.jsonLdTypes || []).length > 0 ? (
|
|
<div className='flex flex-wrap gap-2'>
|
|
{(page.schemaTypes || page.jsonLdTypes || []).slice(0, 4).map((typeName) => (
|
|
<StatusBadge
|
|
key={`${page.url}-${typeName}`}
|
|
className='bg-sky-100 text-sky-700 dark:bg-sky-500/10 dark:text-sky-200'
|
|
>
|
|
{typeName}
|
|
</StatusBadge>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className='text-sm leading-normal'>No schema types were identified on this page yet.</div>
|
|
)}
|
|
</SummaryPanel>
|
|
<SummaryPanel
|
|
label='WordPress plugins'
|
|
className='border-0 bg-slate-50 px-3 py-3 text-sm text-slate-600 dark:bg-slate-900/60 dark:text-slate-300'
|
|
descriptionClassName='leading-normal text-inherit dark:text-inherit'
|
|
>
|
|
{(page.wordpress?.plugins || []).length > 0 ? (
|
|
<div className='flex flex-wrap gap-2'>
|
|
{(page.wordpress?.plugins || []).slice(0, 4).map((plugin) => (
|
|
<StatusBadge
|
|
key={`${page.url}-${plugin.key || plugin.label}`}
|
|
className='bg-violet-100 text-violet-700 dark:bg-violet-500/10 dark:text-violet-200'
|
|
>
|
|
{plugin.label || plugin.key || 'Plugin'}
|
|
{plugin.confidence ? ` (${plugin.confidence})` : ''}
|
|
</StatusBadge>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className='text-sm leading-normal'>No common WordPress plugins were identified on this page.</div>
|
|
)}
|
|
</SummaryPanel>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{shouldShowFailedSection && (
|
|
<div className='rounded-3xl border border-amber-300 bg-amber-50/90 p-4 text-amber-950 shadow-sm dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100 sm:p-5'>
|
|
<button
|
|
type='button'
|
|
className='flex w-full items-center justify-between gap-3 text-left'
|
|
onClick={() => setIsFailedPagesExpanded((currentValue) => !currentValue)}
|
|
aria-expanded={isFailedPagesExpanded}
|
|
>
|
|
<div>
|
|
<div className='text-xs font-semibold uppercase tracking-wide text-amber-700 dark:text-amber-200'>Failed page fetches</div>
|
|
<div className='mt-1 text-base font-semibold'>
|
|
{failedPages.length} internal page{failedPages.length === 1 ? '' : 's'} could not be fetched
|
|
</div>
|
|
<p className='mt-1 text-sm text-amber-800/90 dark:text-amber-100/80'>
|
|
Keep this section collapsed until you need the troubleshooting details.
|
|
</p>
|
|
</div>
|
|
<BaseIcon
|
|
path={isFailedPagesExpanded ? icon.mdiChevronUp : icon.mdiChevronDown}
|
|
className='shrink-0 text-amber-700 dark:text-amber-200'
|
|
/>
|
|
</button>
|
|
|
|
{isFailedPagesExpanded && (
|
|
<div className='mt-4 space-y-3'>
|
|
{failedPages.map((page) => (
|
|
<div
|
|
key={`${page.url}-${page.error}`}
|
|
className='rounded-2xl border border-amber-200 bg-white/80 p-4 text-sm dark:border-amber-500/20 dark:bg-slate-950/40'
|
|
>
|
|
<div className='break-all font-medium text-slate-900 dark:text-white'>{page.url}</div>
|
|
<div className='mt-2 text-amber-900 dark:text-amber-100'>{page.error}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{activeResultsTab === 'recommendations' && (
|
|
<div className='space-y-5'>
|
|
<div className='flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between'>
|
|
<div className='space-y-1'>
|
|
<div className='text-sm text-slate-500 dark:text-slate-300'>
|
|
{recommendations.length} recommendation{recommendations.length === 1 ? '' : 's'} generated from the latest analysis.
|
|
</div>
|
|
<div className='text-xs font-semibold uppercase tracking-wide text-slate-400 dark:text-slate-500'>
|
|
Fix-first order puts higher priority and broader-scope recommendations at the top.
|
|
</div>
|
|
</div>
|
|
<BaseButtons type='justify-start sm:justify-end' className='mb-0 w-full sm:w-auto' classAddon='w-full mb-2 sm:w-auto sm:mr-3 sm:mb-3' mb='mb-0'>
|
|
<BaseButton
|
|
color='whiteDark'
|
|
outline
|
|
icon={icon.mdiContentCopy}
|
|
label='Copy all code'
|
|
className='w-full justify-center sm:w-auto'
|
|
disabled={exportableRecommendations.length === 0}
|
|
onClick={() => {
|
|
const combined = exportableRecommendations
|
|
.map((recommendation) => recommendation.suggested_schema)
|
|
.filter(Boolean)
|
|
.join('\n\n');
|
|
navigator.clipboard
|
|
.writeText(combined)
|
|
.then(() => notify('success', 'All schema code copied to clipboard.'))
|
|
.catch((error) => {
|
|
console.error('Copy all code failed:', error);
|
|
notify('error', 'Unable to copy the combined code.');
|
|
});
|
|
}}
|
|
/>
|
|
</BaseButtons>
|
|
</div>
|
|
|
|
{recommendations.length > 0 && (
|
|
<div className='sticky top-[72px] z-[5] -mx-4 border-y border-slate-200 bg-white/95 px-4 py-3 backdrop-blur sm:top-[92px] sm:mx-0 sm:rounded-2xl sm:border dark:border-slate-700 dark:bg-slate-950/95'>
|
|
<div className='flex min-w-full gap-2 overflow-x-auto pb-1 sm:inline-flex sm:min-w-max sm:pb-0'>
|
|
{recommendationQuickFilters.map((filterOption) => (
|
|
<PageFilterChip
|
|
key={filterOption.id}
|
|
label={filterOption.label}
|
|
count={filterOption.count}
|
|
iconPath={filterOption.iconPath}
|
|
isActive={activeRecommendationFilter === filterOption.id}
|
|
onClick={() => setActiveRecommendationFilter(filterOption.id)}
|
|
/>
|
|
))}
|
|
</div>
|
|
<div className='mt-2 text-xs text-slate-500 dark:text-slate-300'>
|
|
Showing <span className='font-semibold text-slate-900 dark:text-white'>{filteredRecommendations.length}</span> item{filteredRecommendations.length === 1 ? '' : 's'} in the <span className='font-semibold text-slate-900 dark:text-white'>{activeRecommendationFilterLabel}</span> view.
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{filteredRecommendations.length === 0 && (
|
|
<div className='rounded-2xl border border-dashed border-slate-300 p-6 text-sm text-slate-500 dark:border-slate-700 dark:text-slate-300'>
|
|
{recommendationEmptyStateMessage}
|
|
</div>
|
|
)}
|
|
|
|
{recommendationGroups.map((recommendationGroup) => (
|
|
<div key={recommendationGroup.meta.id} className='space-y-4'>
|
|
<div className={`rounded-3xl border p-4 shadow-sm sm:p-5 ${recommendationGroup.meta.accentClassName}`}>
|
|
<div className='flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between'>
|
|
<div>
|
|
<div className='flex flex-wrap items-center gap-3'>
|
|
<span className='inline-flex h-10 w-10 items-center justify-center rounded-2xl bg-white text-slate-700 shadow-sm dark:bg-slate-950/70 dark:text-slate-100'>
|
|
<BaseIcon path={recommendationGroup.meta.iconPath} size={18} />
|
|
</span>
|
|
<div>
|
|
<h4 className='text-base font-semibold text-slate-900 dark:text-white'>{recommendationGroup.meta.sectionTitle}</h4>
|
|
<p className='mt-1 text-sm leading-6 text-slate-600 dark:text-slate-200'>{recommendationGroup.meta.sectionDescription}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<StatusBadge
|
|
className='bg-white text-slate-700 dark:bg-slate-950/70 dark:text-slate-100'
|
|
uppercase
|
|
shadow
|
|
>
|
|
{recommendationGroup.recommendations.length} item{recommendationGroup.recommendations.length === 1 ? '' : 's'}
|
|
</StatusBadge>
|
|
</div>
|
|
</div>
|
|
|
|
<div className='space-y-4'>
|
|
{recommendationGroup.recommendations.map((recommendation) => {
|
|
const isCodeExpanded = !!expandedRecommendationIds[recommendation.id];
|
|
const suggestedSchema = recommendation.suggested_schema || '';
|
|
const codePreview = suggestedSchema.split('\n').slice(0, 4).join('\n');
|
|
const codeLineCount = suggestedSchema ? suggestedSchema.split('\n').length : 0;
|
|
const priorityMeta = getRecommendationPriorityMeta(recommendation.priority);
|
|
|
|
return (
|
|
<div
|
|
key={recommendation.id}
|
|
className='rounded-3xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-700 dark:bg-slate-950/30 sm:p-5'
|
|
>
|
|
<div className='flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between'>
|
|
<div>
|
|
<div className='flex flex-wrap items-center gap-2'>
|
|
<StatusBadge className={priorityMeta.badgeClassName} uppercase>
|
|
{priorityMeta.label}
|
|
</StatusBadge>
|
|
{recommendation.schema_type && (
|
|
<StatusBadge className='bg-sky-100 text-sky-700 dark:bg-sky-500/10 dark:text-sky-200'>
|
|
{recommendation.schema_type}
|
|
</StatusBadge>
|
|
)}
|
|
{recommendation.page_scope && (
|
|
<StatusBadge className='bg-emerald-100 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-200'>
|
|
{recommendation.page_scope}
|
|
</StatusBadge>
|
|
)}
|
|
<StatusBadge
|
|
className={recommendation.suggested_schema
|
|
? 'bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900'
|
|
: 'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-200'}
|
|
>
|
|
{recommendation.suggested_schema ? 'Code ready' : 'Needs code'}
|
|
</StatusBadge>
|
|
</div>
|
|
<h4 className='mt-3 text-base font-semibold text-slate-900 dark:text-white'>
|
|
{recommendation.title}
|
|
</h4>
|
|
<p className='mt-2 text-sm leading-6 text-slate-500 dark:text-slate-300'>
|
|
{recommendation.reason}
|
|
</p>
|
|
{recommendation.expected_impact && (
|
|
<p className='mt-2 text-sm leading-6 text-slate-600 dark:text-slate-200'>
|
|
<span className='font-semibold text-slate-900 dark:text-white'>Expected impact:</span>{' '}
|
|
{recommendation.expected_impact}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<BaseButtons type='justify-start lg:justify-end' className='w-full lg:w-auto' classAddon='w-full mb-2 sm:w-auto sm:mr-3 sm:mb-3'>
|
|
<BaseButton
|
|
color='info'
|
|
small
|
|
icon={icon.mdiContentCopy}
|
|
label='Copy'
|
|
className='w-full justify-center sm:w-auto'
|
|
disabled={!recommendation.suggested_schema}
|
|
onClick={() => {
|
|
void handleCopyCode(recommendation);
|
|
}}
|
|
/>
|
|
<BaseButton
|
|
color='success'
|
|
small
|
|
icon={icon.mdiDownload}
|
|
label={exportingId === recommendation.id ? 'Exporting…' : 'Export'}
|
|
className='w-full justify-center sm:w-auto'
|
|
disabled={!recommendation.suggested_schema || exportingId === recommendation.id}
|
|
onClick={() => {
|
|
void handleExportRecommendation(recommendation);
|
|
}}
|
|
/>
|
|
<BaseButton
|
|
color='warning'
|
|
small
|
|
icon={icon.mdiEmailOutline}
|
|
label={emailingId === recommendation.id ? 'Emailing…' : 'Email'}
|
|
className='w-full justify-center sm:w-auto'
|
|
disabled={emailingId === recommendation.id}
|
|
onClick={() => {
|
|
void handleEmailCode(recommendation.id);
|
|
}}
|
|
/>
|
|
</BaseButtons>
|
|
</div>
|
|
|
|
<div className='mt-4 rounded-2xl border border-slate-200 bg-slate-50/80 p-3 dark:border-slate-700 dark:bg-slate-900/40 sm:p-4'>
|
|
<div className='flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between'>
|
|
<div>
|
|
<div className='text-xs font-semibold uppercase tracking-wide text-slate-500'>Suggested code</div>
|
|
<div className='mt-1 text-xs text-slate-500 dark:text-slate-300'>
|
|
{suggestedSchema
|
|
? isCodeExpanded
|
|
? 'Expanded for review and copy/paste.'
|
|
: `Collapsed for mobile reading${codeLineCount > 4 ? ` · ${codeLineCount} lines available` : ''}.`
|
|
: 'No code snippet generated for this recommendation yet.'}
|
|
</div>
|
|
</div>
|
|
{suggestedSchema && (
|
|
<BaseButton
|
|
color='whiteDark'
|
|
outline
|
|
small
|
|
icon={isCodeExpanded ? icon.mdiChevronUp : icon.mdiChevronDown}
|
|
label={isCodeExpanded ? 'Hide code' : 'Show code'}
|
|
className='w-full justify-center sm:w-auto'
|
|
onClick={() => toggleRecommendationCode(recommendation.id)}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{suggestedSchema ? (
|
|
isCodeExpanded ? (
|
|
<pre className='mt-3 overflow-x-auto rounded-2xl bg-slate-950 p-3 text-xs leading-6 text-slate-100 sm:p-4'>
|
|
<code>{suggestedSchema}</code>
|
|
</pre>
|
|
) : (
|
|
<div className='mt-3 overflow-hidden rounded-2xl bg-slate-950 p-3 text-xs leading-6 text-slate-100 sm:p-4'>
|
|
<code className='whitespace-pre-wrap break-words'>{codePreview}</code>
|
|
<div className='mt-3 border-t border-slate-800 pt-2 text-[11px] font-semibold uppercase tracking-wide text-slate-400'>
|
|
{codeLineCount > 4 ? `Show code to view ${codeLineCount - 4} more line${codeLineCount - 4 === 1 ? '' : 's'}.` : 'Show code to expand this snippet.'}
|
|
</div>
|
|
</div>
|
|
)
|
|
) : (
|
|
<div className='mt-3 rounded-2xl border border-dashed border-slate-300 bg-white px-4 py-3 text-sm text-slate-500 dark:border-slate-700 dark:bg-slate-950/50 dark:text-slate-300'>
|
|
No code snippet generated for this recommendation.
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{activeResultsTab === 'delivery' && (
|
|
<div className='space-y-5'>
|
|
<div className='rounded-2xl border border-slate-200 bg-slate-50 p-4 dark:border-slate-700 dark:bg-slate-900/40 sm:p-5'>
|
|
<div className='flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between'>
|
|
<div>
|
|
<h4 className='text-base font-semibold text-slate-900 dark:text-white'>Delivery actions</h4>
|
|
<p className='mt-2 text-sm leading-6 text-slate-500 dark:text-slate-300'>
|
|
Package the latest recommendations for your developer, email a handoff, or verify Step 4 output for the selected platform.
|
|
</p>
|
|
</div>
|
|
<StatusBadge
|
|
className='self-start bg-white text-slate-600 dark:bg-slate-950/70 dark:text-slate-200'
|
|
iconPath={icon.mdiCellphoneText}
|
|
iconSize={16}
|
|
shadow
|
|
>
|
|
Mobile handoff
|
|
</StatusBadge>
|
|
</div>
|
|
|
|
<div className='mt-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-3'>
|
|
{deliverySummaryCards.map((card) => (
|
|
<DeliverySummaryCard
|
|
key={card.label}
|
|
label={card.label}
|
|
value={card.value}
|
|
helper={card.helper}
|
|
iconPath={card.iconPath}
|
|
toneClassName={card.toneClassName}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className='grid gap-4 xl:grid-cols-[minmax(0,1.4fr)_minmax(280px,0.9fr)]'>
|
|
<div className='space-y-4'>
|
|
<DeliveryActionCard
|
|
title='Export developer handoff'
|
|
description='Download a single package with the latest recommendations so it is easy to forward or attach in your workflow.'
|
|
iconPath={icon.mdiDownload}
|
|
badge={(
|
|
<StatusBadge className='bg-emerald-50 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-200'>
|
|
{exportableRecommendations.length} code ready
|
|
</StatusBadge>
|
|
)}
|
|
>
|
|
<div className='space-y-3'>
|
|
<div className='rounded-2xl bg-slate-50 px-4 py-3 text-sm leading-6 text-slate-600 dark:bg-slate-900/60 dark:text-slate-300'>
|
|
Best for quick sharing when you want one downloadable file instead of opening each recommendation card.
|
|
</div>
|
|
<BaseButton
|
|
color='success'
|
|
icon={icon.mdiDownload}
|
|
label={isExportingAll ? 'Exporting…' : 'Export all'}
|
|
className='w-full justify-center'
|
|
disabled={!report?.site?.id || isExportingAll}
|
|
onClick={() => {
|
|
void handleExportAll();
|
|
}}
|
|
/>
|
|
</div>
|
|
</DeliveryActionCard>
|
|
|
|
<DeliveryActionCard
|
|
title='Email the handoff'
|
|
description='Send the current recommendations directly to a developer or implementation partner without leaving the analyzer.'
|
|
iconPath={icon.mdiEmailOutline}
|
|
badge={(
|
|
<StatusBadge
|
|
className={hasEmailRecipient
|
|
? 'bg-sky-50 text-sky-700 dark:bg-sky-500/10 dark:text-sky-200'
|
|
: 'bg-amber-50 text-amber-700 dark:bg-amber-500/10 dark:text-amber-200'}
|
|
>
|
|
{hasEmailRecipient ? 'Recipient ready' : 'Recipient needed'}
|
|
</StatusBadge>
|
|
)}
|
|
>
|
|
<div className='space-y-3'>
|
|
<FormField label='Developer email' labelFor='schema-email-recipient'>
|
|
<input
|
|
id='schema-email-recipient'
|
|
name='schema-email-recipient'
|
|
placeholder='developer@example.com'
|
|
value={emailTo}
|
|
onChange={(event) => setEmailTo(event.target.value)}
|
|
/>
|
|
</FormField>
|
|
<div className='rounded-2xl bg-slate-50 px-4 py-3 text-sm leading-6 text-slate-600 dark:bg-slate-900/60 dark:text-slate-300'>
|
|
Use this when you want to send the full recommendation set straight from your phone.
|
|
</div>
|
|
<BaseButton
|
|
color='warning'
|
|
icon={icon.mdiEmailOutline}
|
|
label={emailingId === 'all' ? 'Emailing…' : 'Email all'}
|
|
className='w-full justify-center'
|
|
disabled={!report?.site?.id || emailingId === 'all'}
|
|
onClick={() => {
|
|
void handleEmailCode();
|
|
}}
|
|
/>
|
|
</div>
|
|
</DeliveryActionCard>
|
|
|
|
<DeliveryActionCard
|
|
title='Check Step 4 output'
|
|
description='Verify whether platform-specific implementation output is available for the platform selected in setup.'
|
|
iconPath={entitlements?.canPlatformOutput ? icon.mdiCodeBraces : icon.mdiLockOutline}
|
|
badge={(
|
|
<StatusBadge
|
|
className={entitlements?.canPlatformOutput
|
|
? 'bg-violet-50 text-violet-700 dark:bg-violet-500/10 dark:text-violet-200'
|
|
: 'bg-slate-100 text-slate-600 dark:bg-slate-900 dark:text-slate-200'}
|
|
>
|
|
{entitlements?.canPlatformOutput ? 'Premium unlocked' : 'Premium feature'}
|
|
</StatusBadge>
|
|
)}
|
|
>
|
|
<div className='space-y-3'>
|
|
<div className='grid gap-3 sm:grid-cols-2'>
|
|
<SummaryPanel
|
|
label='Selected platform'
|
|
value={selectedPlatformLabel}
|
|
/>
|
|
<SummaryPanel
|
|
label='Access'
|
|
value={entitlements?.canPlatformOutput ? 'Premium access detected' : 'Premium required for Step 4 output'}
|
|
/>
|
|
</div>
|
|
<BaseButton
|
|
color={entitlements?.canPlatformOutput ? 'info' : 'whiteDark'}
|
|
outline={!entitlements?.canPlatformOutput}
|
|
icon={entitlements?.canPlatformOutput ? icon.mdiCodeBraces : icon.mdiLockOutline}
|
|
label={isCheckingPlatformOutput
|
|
? 'Checking…'
|
|
: entitlements?.canPlatformOutput
|
|
? 'Check Step 4 output'
|
|
: 'Premium Step 4'}
|
|
className='w-full justify-center'
|
|
disabled={!report?.site?.id || isCheckingPlatformOutput}
|
|
onClick={() => {
|
|
void handlePlatformOutputCheck();
|
|
}}
|
|
/>
|
|
</div>
|
|
</DeliveryActionCard>
|
|
</div>
|
|
|
|
<div className='rounded-2xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-700 dark:bg-slate-950/30 sm:p-5'>
|
|
<div className='flex items-center gap-3'>
|
|
<span className='inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl bg-slate-100 text-slate-600 dark:bg-slate-900 dark:text-slate-200'>
|
|
<BaseIcon path={icon.mdiFormatListChecks} size={20} />
|
|
</span>
|
|
<div>
|
|
<h5 className='text-sm font-semibold text-slate-900 dark:text-white sm:text-base'>Handoff checklist</h5>
|
|
<p className='mt-1 text-sm leading-6 text-slate-500 dark:text-slate-300'>
|
|
A quick mobile-friendly checklist before you export or email the report.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className='mt-4 space-y-3'>
|
|
{deliveryChecklist.map((item) => (
|
|
<SummaryPanel
|
|
key={item.id}
|
|
label={item.label}
|
|
description={item.value}
|
|
aside={(
|
|
<StatusBadge
|
|
className={item.isReady
|
|
? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-200'
|
|
: 'bg-amber-50 text-amber-700 dark:bg-amber-500/10 dark:text-amber-200'}
|
|
compact
|
|
iconPath={item.isReady ? icon.mdiCheckCircleOutline : icon.mdiAlertCircleOutline}
|
|
>
|
|
{item.isReady ? 'Ready' : 'Needs attention'}
|
|
</StatusBadge>
|
|
)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className='rounded-2xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-700 dark:bg-slate-950/30 sm:p-5'>
|
|
<div className='flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between'>
|
|
<div>
|
|
<h5 className='text-sm font-semibold text-slate-900 dark:text-white sm:text-base'>Step 4 MVP / demo preview</h5>
|
|
<p className='mt-2 max-w-3xl text-sm leading-6 text-slate-500 dark:text-slate-300'>
|
|
This is a frontend demo of what the selected platform handoff could look like. It uses the current report data, keeps the exact requested page target visible, and never publishes to a live platform.
|
|
</p>
|
|
</div>
|
|
<div className='flex flex-wrap gap-2'>
|
|
<StatusBadge
|
|
className='bg-violet-50 text-violet-700 dark:bg-violet-500/10 dark:text-violet-200'
|
|
iconPath={selectedPlatformMeta.iconPath}
|
|
>
|
|
{selectedPlatformLabel}
|
|
</StatusBadge>
|
|
<StatusBadge
|
|
className={requestedPageTargetMet
|
|
? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-200'
|
|
: 'bg-amber-50 text-amber-700 dark:bg-amber-500/10 dark:text-amber-200'}
|
|
iconPath={requestedPageTargetMet ? icon.mdiCheckCircleOutline : icon.mdiAlertCircleOutline}
|
|
>
|
|
{requestedPageTargetMet ? 'Exact page target met' : 'Limited by crawlable pages'}
|
|
</StatusBadge>
|
|
<StatusBadge
|
|
className='bg-slate-100 text-slate-700 dark:bg-slate-900 dark:text-slate-200'
|
|
iconPath={icon.mdiBeakerOutline}
|
|
>
|
|
Demo only
|
|
</StatusBadge>
|
|
</div>
|
|
</div>
|
|
|
|
<div className='mt-4 grid gap-3 lg:grid-cols-3'>
|
|
<SectionStatCard
|
|
label='Pages requested'
|
|
value={requestedPagesForRun}
|
|
helper='The user-selected target for this run.'
|
|
iconPath={icon.mdiNumeric}
|
|
className='shadow-none'
|
|
valueClassName='text-lg sm:text-xl'
|
|
/>
|
|
<SectionStatCard
|
|
label='Pages analyzed'
|
|
value={actualPagesAnalyzed}
|
|
helper={pageCountStatusLabel}
|
|
iconPath={icon.mdiFileDocumentOutline}
|
|
className='shadow-none'
|
|
valueClassName='text-lg sm:text-xl'
|
|
/>
|
|
<SectionStatCard
|
|
label='Code-ready snippets'
|
|
value={exportableRecommendations.length}
|
|
helper={selectedPlatformMeta.payloadLabel}
|
|
iconPath={icon.mdiCodeBraces}
|
|
className='shadow-none'
|
|
valueClassName='text-lg sm:text-xl'
|
|
/>
|
|
</div>
|
|
|
|
<div className='mt-4 grid gap-4 xl:grid-cols-[minmax(0,1.2fr)_minmax(320px,0.8fr)]'>
|
|
<div className='space-y-4'>
|
|
<SummaryPanel
|
|
label='Platform handoff plan'
|
|
value={selectedPlatformMeta.implementationLabel}
|
|
description={selectedPlatformMeta.demoNote}
|
|
aside={(
|
|
<StatusBadge
|
|
className='bg-sky-50 text-sky-700 dark:bg-sky-500/10 dark:text-sky-200'
|
|
compact
|
|
iconPath={icon.mdiAccountWrenchOutline}
|
|
>
|
|
{selectedPlatformMeta.payloadLabel}
|
|
</StatusBadge>
|
|
)}
|
|
>
|
|
<div className='space-y-2'>
|
|
{selectedPlatformMeta.steps.map((step, index) => (
|
|
<div
|
|
key={`${selectedPlatform}-${step}`}
|
|
className='flex items-start gap-3 rounded-2xl bg-slate-50 px-3 py-3 text-sm leading-6 text-slate-600 dark:bg-slate-900/60 dark:text-slate-300'
|
|
>
|
|
<span className='inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-white text-xs font-semibold text-slate-700 shadow-sm dark:bg-slate-950 dark:text-slate-200'>
|
|
{index + 1}
|
|
</span>
|
|
<span>{step}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</SummaryPanel>
|
|
|
|
<SummaryPanel
|
|
label='Platform final deliverables'
|
|
description='These cards change by platform so stakeholders can preview the exact kind of handoff the team would receive.'
|
|
>
|
|
<div className='grid gap-3 md:grid-cols-3'>
|
|
{platformFinalDeliverables.map((deliverable) => (
|
|
<div
|
|
key={deliverable.id}
|
|
className={[
|
|
'rounded-2xl border p-3',
|
|
deliverable.toneClassName || 'border-slate-200 bg-slate-50 dark:border-slate-700 dark:bg-slate-900/60',
|
|
].join(' ')}
|
|
>
|
|
<div className='flex items-start justify-between gap-3'>
|
|
<div className='min-w-0'>
|
|
<div className='text-sm font-semibold text-slate-900 dark:text-white'>
|
|
{deliverable.title}
|
|
</div>
|
|
<div className='mt-1 text-xs font-medium text-slate-500 dark:text-slate-400'>
|
|
{deliverable.destination}
|
|
</div>
|
|
</div>
|
|
<span className='inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-2xl bg-white text-slate-600 shadow-sm dark:bg-slate-950 dark:text-slate-200'>
|
|
<BaseIcon path={deliverable.iconPath} size={18} />
|
|
</span>
|
|
</div>
|
|
<div className='mt-3 flex flex-wrap gap-2'>
|
|
<StatusBadge className='bg-white text-slate-700 dark:bg-slate-950 dark:text-slate-200' compact>
|
|
{deliverable.owner}
|
|
</StatusBadge>
|
|
<StatusBadge className='bg-slate-100 text-slate-700 dark:bg-slate-950 dark:text-slate-200' compact>
|
|
{deliverable.statusLabel}
|
|
</StatusBadge>
|
|
</div>
|
|
<p className='mt-3 text-sm leading-6 text-slate-500 dark:text-slate-300'>
|
|
{deliverable.description}
|
|
</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</SummaryPanel>
|
|
|
|
<SummaryPanel
|
|
label='What the selected platform package includes'
|
|
description='Switch the platform above to see the file/package mix change for WordPress, Shopify, Webflow, or a custom build.'
|
|
>
|
|
<div className='grid gap-3 md:grid-cols-3'>
|
|
{platformPreviewArtifacts.map((artifact) => (
|
|
<div
|
|
key={artifact.id}
|
|
className={[
|
|
'rounded-2xl border p-3',
|
|
artifact.toneClassName || 'border-slate-200 bg-slate-50 dark:border-slate-700 dark:bg-slate-900/60',
|
|
].join(' ')}
|
|
>
|
|
<div className='flex items-start justify-between gap-3'>
|
|
<div className='min-w-0'>
|
|
<div className='text-sm font-semibold text-slate-900 dark:text-white'>
|
|
{artifact.label}
|
|
</div>
|
|
<div className='mt-1 break-all text-xs font-medium text-slate-500 dark:text-slate-400'>
|
|
{artifact.fileName}
|
|
</div>
|
|
</div>
|
|
<span className='inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-2xl bg-white text-slate-600 shadow-sm dark:bg-slate-950 dark:text-slate-200'>
|
|
<BaseIcon path={artifact.iconPath} size={18} />
|
|
</span>
|
|
</div>
|
|
<p className='mt-3 text-sm leading-6 text-slate-500 dark:text-slate-300'>
|
|
{artifact.description}
|
|
</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</SummaryPanel>
|
|
|
|
<SummaryPanel
|
|
label='Implementation preview'
|
|
value={platformPreviewArtifacts[1]?.fileName || selectedPlatformMeta.payloadLabel}
|
|
description='A quick, platform-specific example of how the generated schema handoff could be inserted.'
|
|
>
|
|
<div className='overflow-hidden rounded-2xl bg-slate-950'>
|
|
<div className='border-b border-slate-800 px-4 py-3 text-[11px] font-semibold uppercase tracking-wide text-slate-400'>
|
|
{selectedPlatformMeta.implementationLabel}
|
|
</div>
|
|
<pre className='max-h-[280px] overflow-auto px-4 py-4 text-xs leading-6 text-slate-100'>
|
|
<code>{platformImplementationPreview}</code>
|
|
</pre>
|
|
</div>
|
|
</SummaryPanel>
|
|
|
|
<SummaryPanel
|
|
label='Demo payload preview'
|
|
description='Preview the payload shape a developer or future Step 4 generator would receive.'
|
|
>
|
|
<div className='overflow-hidden rounded-2xl bg-slate-950'>
|
|
<div className='border-b border-slate-800 px-4 py-3 text-[11px] font-semibold uppercase tracking-wide text-slate-400'>
|
|
{selectedPlatformMeta.payloadLabel}
|
|
</div>
|
|
<pre className='max-h-[380px] overflow-auto px-4 py-4 text-xs leading-6 text-slate-100'>
|
|
<code>{platformOutputPreviewJson}</code>
|
|
</pre>
|
|
</div>
|
|
</SummaryPanel>
|
|
|
|
<BaseButtons className='gap-3'>
|
|
<BaseButton
|
|
color='info'
|
|
icon={icon.mdiContentCopy}
|
|
label='Copy payload'
|
|
className='w-full justify-center sm:w-auto'
|
|
disabled={!report?.site?.id}
|
|
onClick={() => {
|
|
void handleCopyText(platformOutputPreviewJson, 'Step 4 demo payload copied.');
|
|
}}
|
|
/>
|
|
<BaseButton
|
|
color='whiteDark'
|
|
outline
|
|
icon={icon.mdiClipboardTextOutline}
|
|
label='Copy handoff notes'
|
|
className='w-full justify-center sm:w-auto'
|
|
disabled={!report?.site?.id}
|
|
onClick={() => {
|
|
void handleCopyText(platformHandoffText, 'Step 4 handoff notes copied.');
|
|
}}
|
|
/>
|
|
<BaseButton
|
|
color='success'
|
|
icon={icon.mdiSendOutline}
|
|
label={isSimulatingPlatformSend ? 'Simulating…' : 'Simulate send to platform'}
|
|
className='w-full justify-center sm:w-auto'
|
|
disabled={!report?.site?.id || isSimulatingPlatformSend}
|
|
onClick={() => {
|
|
void handleSimulatePlatformSend();
|
|
}}
|
|
/>
|
|
</BaseButtons>
|
|
</div>
|
|
|
|
<div className='space-y-4'>
|
|
<SummaryPanel
|
|
label='Page-count handling'
|
|
value={pageCountStatusLabel}
|
|
description={pageCountStatusDescription}
|
|
aside={(
|
|
<StatusBadge
|
|
className={requestedPageTargetMet
|
|
? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-200'
|
|
: 'bg-amber-50 text-amber-700 dark:bg-amber-500/10 dark:text-amber-200'}
|
|
compact
|
|
iconPath={requestedPageTargetMet ? icon.mdiCheckCircleOutline : icon.mdiAlertCircleOutline}
|
|
>
|
|
{actualPagesAnalyzed}/{requestedPagesForRun}
|
|
</StatusBadge>
|
|
)}
|
|
/>
|
|
|
|
<SummaryPanel
|
|
label='Implementation destination'
|
|
value={selectedPlatformMeta.developerDestination}
|
|
description={selectedPlatformMeta.liveStatus}
|
|
/>
|
|
|
|
{selectedPlatform === 'wordpress' && (detectedWordPressPlugins.length > 0 || wordpressSchemaOwnership || wordpressDuplicateRisk || wordpressPluginRecommendations.length > 0) && (
|
|
<SummaryPanel
|
|
label='WordPress implementation guidance'
|
|
value={wordpressSchemaOwnership?.label || wordpressDuplicateRisk?.label || 'Plugin detection available'}
|
|
description={wordpressSchemaOwnership?.recommendedImplementation || wordpressDuplicateRisk?.summary || 'Detected plugins help identify whether schema should be extended in a theme, custom snippet, or plugin-managed layer.'}
|
|
>
|
|
<div className='space-y-3'>
|
|
<div className='flex flex-wrap gap-2'>
|
|
{detectedWordPressPlugins.length > 0 ? detectedWordPressPlugins.map((plugin) => (
|
|
<StatusBadge
|
|
key={plugin.key || plugin.label}
|
|
className='bg-slate-100 text-slate-700 dark:bg-slate-900 dark:text-slate-200'
|
|
>
|
|
{plugin.label || plugin.key || 'Plugin'}
|
|
</StatusBadge>
|
|
)) : (
|
|
<StatusBadge className='bg-slate-100 text-slate-600 dark:bg-slate-900 dark:text-slate-300'>
|
|
No common plugins detected
|
|
</StatusBadge>
|
|
)}
|
|
{wordpressDuplicateRisk?.label && (
|
|
<StatusBadge className={wordpressDuplicateRiskBadgeClassName}>
|
|
{wordpressDuplicateRisk.label}
|
|
</StatusBadge>
|
|
)}
|
|
</div>
|
|
{wordpressPluginRecommendations.length > 0 && (
|
|
<div className='space-y-2 text-sm text-slate-600 dark:text-slate-300'>
|
|
{wordpressPluginRecommendations.slice(0, 2).map((recommendation) => (
|
|
<div key={recommendation.key || recommendation.title}>
|
|
<span className='font-semibold text-slate-900 dark:text-white'>{recommendation.label || recommendation.key || 'Plugin'}:</span>{' '}
|
|
{recommendation.recommendedApproach || recommendation.summary || 'Review plugin output before coding schema changes.'}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</SummaryPanel>
|
|
)}
|
|
|
|
<SummaryPanel
|
|
label='Schema types in this package'
|
|
description={step4SchemaTypes.length > 0
|
|
? `${step4SchemaTypes.length} schema type${step4SchemaTypes.length === 1 ? '' : 's'} will be highlighted in the developer handoff.`
|
|
: 'Schema types will populate here as soon as the crawl identifies code-ready opportunities.'}
|
|
>
|
|
<div className='flex flex-wrap gap-2'>
|
|
{step4SchemaTypes.length > 0 ? step4SchemaTypes.map((typeName) => (
|
|
<StatusBadge
|
|
key={typeName}
|
|
className='bg-slate-100 text-slate-700 dark:bg-slate-900 dark:text-slate-200'
|
|
>
|
|
{typeName}
|
|
</StatusBadge>
|
|
)) : (
|
|
<StatusBadge className='bg-slate-100 text-slate-600 dark:bg-slate-900 dark:text-slate-300'>
|
|
No schema types yet
|
|
</StatusBadge>
|
|
)}
|
|
</div>
|
|
</SummaryPanel>
|
|
|
|
<SummaryPanel
|
|
label='Page-by-page package mapping'
|
|
description={actualPagesAnalyzed > 0
|
|
? `All ${actualPagesAnalyzed} analyzed page${actualPagesAnalyzed === 1 ? '' : 's'} are mapped to a schema type, package file, and delivery target in this demo.`
|
|
: 'Run an analysis to populate the Step 4 package preview.'}
|
|
>
|
|
<div className='space-y-2 max-h-[520px] overflow-auto pr-1'>
|
|
{platformPageMappings.length > 0 ? platformPageMappings.map((mapping) => (
|
|
<div
|
|
key={mapping.id}
|
|
className='rounded-2xl border border-slate-200 bg-slate-50 px-3 py-3 text-sm leading-6 text-slate-600 dark:border-slate-700 dark:bg-slate-900/60 dark:text-slate-300'
|
|
>
|
|
<div className='flex items-start justify-between gap-3'>
|
|
<div className='min-w-0'>
|
|
<div className='font-medium text-slate-900 dark:text-white'>
|
|
{mapping.pageLabel}
|
|
</div>
|
|
{mapping.pageUrl && (
|
|
<div className='mt-1 break-all text-xs text-slate-500 dark:text-slate-400'>
|
|
{mapping.pageUrl}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<StatusBadge
|
|
className={mapping.statusClassName}
|
|
compact
|
|
iconPath={mapping.iconPath}
|
|
>
|
|
{mapping.statusLabel}
|
|
</StatusBadge>
|
|
</div>
|
|
<div className='mt-3 flex flex-wrap gap-2'>
|
|
<StatusBadge className='bg-white text-slate-700 dark:bg-slate-950 dark:text-slate-200' compact>
|
|
{mapping.schemaType}
|
|
</StatusBadge>
|
|
<StatusBadge
|
|
className='bg-white text-slate-700 dark:bg-slate-950 dark:text-slate-200'
|
|
compact
|
|
iconPath={icon.mdiPackageVariantClosed}
|
|
>
|
|
{mapping.packageFile}
|
|
</StatusBadge>
|
|
</div>
|
|
<div className='mt-3 grid gap-2 md:grid-cols-2'>
|
|
<div className='rounded-xl bg-white/80 px-3 py-2 dark:bg-slate-950/70'>
|
|
<div className='text-[11px] font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400'>
|
|
Deliverable
|
|
</div>
|
|
<div className='mt-1 font-medium text-slate-900 dark:text-white'>
|
|
{mapping.deliverableTitle}
|
|
</div>
|
|
<div className='mt-1 text-xs text-slate-500 dark:text-slate-400'>
|
|
{mapping.destination}
|
|
</div>
|
|
</div>
|
|
<div className='rounded-xl bg-white/80 px-3 py-2 dark:bg-slate-950/70'>
|
|
<div className='text-[11px] font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400'>
|
|
Next action
|
|
</div>
|
|
<div className='mt-1 text-sm text-slate-700 dark:text-slate-200'>
|
|
{mapping.actionLabel}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)) : (
|
|
<div className='rounded-2xl border border-dashed border-slate-300 px-4 py-3 text-sm text-slate-500 dark:border-slate-700 dark:text-slate-300'>
|
|
No analyzed pages yet. Run the analyzer first to build a Step 4 preview package.
|
|
</div>
|
|
)}
|
|
</div>
|
|
</SummaryPanel>
|
|
|
|
<SummaryPanel
|
|
label='Fake publish timeline'
|
|
value={lastPlatformSimulationLabel ? 'Demo handoff progressed through the staged workflow.' : 'Run “Simulate send to platform” to stage the demo workflow.'}
|
|
description='Frontend-only status preview: queued → packaged → ready for dev → approved.'
|
|
aside={(
|
|
<StatusBadge
|
|
className={lastPlatformSimulationLabel
|
|
? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-200'
|
|
: 'bg-slate-100 text-slate-600 dark:bg-slate-900 dark:text-slate-200'}
|
|
compact
|
|
iconPath={lastPlatformSimulationLabel ? icon.mdiRocketLaunchOutline : icon.mdiProgressClock}
|
|
>
|
|
{lastPlatformSimulationLabel ? 'Demo staged' : 'Awaiting demo'}
|
|
</StatusBadge>
|
|
)}
|
|
>
|
|
<div className='space-y-3'>
|
|
{platformPublishTimeline.map((step, index) => (
|
|
<div
|
|
key={step.id}
|
|
className={[
|
|
'rounded-2xl border px-3 py-3',
|
|
step.status === 'complete'
|
|
? 'border-emerald-200 bg-emerald-50/70 dark:border-emerald-500/30 dark:bg-emerald-500/10'
|
|
: step.status === 'current'
|
|
? 'border-sky-200 bg-sky-50/70 dark:border-sky-500/30 dark:bg-sky-500/10'
|
|
: 'border-slate-200 bg-slate-50 dark:border-slate-700 dark:bg-slate-900/60',
|
|
].join(' ')}
|
|
>
|
|
<div className='flex items-start justify-between gap-3'>
|
|
<div className='flex items-start gap-3'>
|
|
<span className='inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-2xl bg-white text-slate-600 shadow-sm dark:bg-slate-950 dark:text-slate-200'>
|
|
<BaseIcon path={step.iconPath} size={18} />
|
|
</span>
|
|
<div>
|
|
<div className='flex flex-wrap items-center gap-2'>
|
|
<span className='text-sm font-semibold text-slate-900 dark:text-white'>
|
|
{index + 1}. {step.label}
|
|
</span>
|
|
<StatusBadge
|
|
className={step.status === 'complete'
|
|
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-200'
|
|
: step.status === 'current'
|
|
? 'bg-sky-100 text-sky-700 dark:bg-sky-500/20 dark:text-sky-200'
|
|
: 'bg-white text-slate-600 dark:bg-slate-950 dark:text-slate-200'}
|
|
compact
|
|
>
|
|
{step.status === 'complete' ? 'Complete' : step.status === 'current' ? 'Current' : 'Upcoming'}
|
|
</StatusBadge>
|
|
</div>
|
|
<p className='mt-1 text-sm leading-6 text-slate-500 dark:text-slate-300'>
|
|
{step.description}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className='shrink-0 text-xs font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400'>
|
|
{step.timeLabel}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</SummaryPanel>
|
|
|
|
{lastPlatformSimulationLabel && (
|
|
<SummaryPanel
|
|
label='Latest demo run'
|
|
value={`Prepared ${lastPlatformSimulationLabel}`}
|
|
description={`A ${selectedPlatformLabel} preview payload was staged for review only. No live publish happened.`}
|
|
aside={(
|
|
<StatusBadge
|
|
className='bg-emerald-50 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-200'
|
|
compact
|
|
iconPath={icon.mdiCheckCircleOutline}
|
|
>
|
|
Demo ready
|
|
</StatusBadge>
|
|
)}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</CardBox>
|
|
</div>
|
|
)}
|
|
|
|
<ToastContainer />
|
|
</SectionMain>
|
|
</>
|
|
);
|
|
};
|
|
|
|
SchemaAnalyzerPage.getLayout = function getLayout(page: ReactElement) {
|
|
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
|
|
};
|
|
|
|
export default SchemaAnalyzerPage;
|