2026-04-14 22:08:27 +00:00

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;