import React, { useEffect, useRef, useState } from 'react'; import { __, _n, sprintf } from '@wordpress/i18n'; import { Navigate, useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { toast } from 'sonner'; import { ArrowRight, Check, FlaskConical, ImageIcon, RefreshCw } from 'lucide-react'; import { getDashboardAnalysisStats, getDashboardCountsStats, getDashboardOptimizationStats, getPluginSettings, getScanProgress, getSetupState, saveSetupState, savePluginSettings, startScan, type DashboardAnalysisStats, type DashboardCountsStats, type DashboardOptimizationStats, type ScanProgress, type SetupPayload, type SiteType, } from '../api'; import { formatBytes, numberFormat } from '../lib/i18n'; import { useCompressionCheck } from '../components/dashboard/useCompressionCheck'; import { gradeStyle, heroSentence, type DashboardModelStats } from '../components/dashboard/model'; import GradeChip from '../components/dashboard/GradeChip'; import BeforeAfterModal from '../components/setup/BeforeAfterModal'; /** Each screen has its own address (#/setup/grade), so Back, refresh and a shared link all land on the right one. */ const STEPS = [ 'start', 'checking', 'grade', 'photos' ] as const; type StepName = typeof STEPS[ number ]; type Step = 0 | 1 | 2 | 3; /** The grade opens on its own after this long, or as soon as the scan finishes. */ const AUTO_GRADE_MS = 8000; const POLL_MS = 2500; /** The grade's button waits this long for the photos, then lets you go on without them: a big library can take far longer to scan. */ const PHOTOS_WAIT_MS = 8000; const SITE_TYPES: { value: SiteType; label: string }[] = [ { value: 'blog', label: __( 'Blog or content site', 'image-sizes' ) }, { value: 'store', label: __( 'Online store', 'image-sizes' ) }, { value: 'photo', label: __( 'Photography or portfolio', 'image-sizes' ) }, { value: 'business', label: __( 'Business site', 'image-sizes' ) }, { value: 'agency', label: __( 'Agency managing client sites', 'image-sizes' ) }, ]; // webp_on_upload is left out: it deletes originals, so it is only turned on through its own warning. const FREE_SETTINGS: { key: 'lazy_load'; label: string }[] = [ { key: 'lazy_load', label: __( 'Lazy loading', 'image-sizes' ) }, ]; const eyebrow = 'text-xs leading-4 font-semibold tracking-[0.6px] uppercase text-thumbpress-primary'; const primaryButton = 'h-12 px-7 border-0 rounded-lg bg-thumbpress-primary text-white text-base font-semibold inline-flex items-center gap-2 cursor-pointer disabled:opacity-60 disabled:cursor-default'; /** One line under a category's grade: what is wrong there, in plain words. */ function categoryLine( key: string, s: DashboardModelStats, reclaimable: number, counting: boolean ): string { switch ( key ) { case 'speed': return s.large_images > 0 ? sprintf( /* translators: %s: number of images over 1 MB. */ __( '%s images over 1 MB', 'image-sizes' ), numberFormat( s.large_images ), ) : __( 'No image is over 1 MB', 'image-sizes' ); case 'seo': return s.missing_alt > 0 ? sprintf( /* translators: %s: number of images with no alt text. */ __( '%s images Google can’t read (no alt text)', 'image-sizes' ), numberFormat( s.missing_alt ), ) : __( 'Every image has alt text', 'image-sizes' ); case 'storage': return ( reclaimable > 0 ? sprintf( /* translators: %s: size that could be reclaimed, e.g. "2.1 GB". */ __( '%s you could get back', 'image-sizes' ), formatBytes( reclaimable, true ), ) : __( 'Nothing to reclaim yet', 'image-sizes' ) ) + ( counting ? ' · ' + __( 'duplicates still counting…', 'image-sizes' ) : '' ); default: return s.hotlink ? __( 'Hotlink protection is on', 'image-sizes' ) : __( 'Hotlink protection is off', 'image-sizes' ); } } export default function Setup() { const navigate = useNavigate(); const [ params ] = useSearchParams(); const requestedMode = params.get( 'mode' ); const mode: 'fresh' | 'upgrade' | 'manual' = requestedMode === 'upgrade' || requestedMode === 'manual' ? requestedMode : 'fresh'; const { step: stepParam } = useParams(); const location = useLocation(); const known = STEPS.includes( stepParam as StepName ); const step = ( known ? STEPS.indexOf( stepParam as StepName ) : 0 ) as Step; // Keep the query (?mode=) when moving between screens. const go = ( to: Step, replace = false ) => navigate( { pathname: `/setup/${ STEPS[ to ] }`, search: location.search }, { replace } ); const [ payload, setPayload ] = useState< SetupPayload | null >( null ); const [ counts, setCounts ] = useState< DashboardCountsStats | null >( null ); const [ optimization, setOptimization ] = useState< DashboardOptimizationStats | null >( null ); const [ analysis, setAnalysis ] = useState< DashboardAnalysisStats | null >( null ); const [ progress, setProgress ] = useState< ScanProgress | null >( null ); const [ settings, setSettings ] = useState< Record< string, boolean > | null >( null ); const [ siteType, setSiteType ] = useState< SiteType >( 'blog' ); const [ typeMenu, setTypeMenu ] = useState( false ); const [ busy, setBusy ] = useState( false ); const [ applied, setApplied ] = useState( 0 ); const [ settledGrade, setSettledGrade ] = useState( '' ); const [ photosWaitOver, setPhotosWaitOver ] = useState( false ); const startedAt = useRef( 0 ); const shownGrade = useRef( '' ); const checkStarted = useRef( false ); const check = useCompressionCheck( null ); // The checkup owns the screen: cover the admin chrome, and stop the page behind it scrolling. useEffect( () => { const before = document.body.style.overflow; document.body.style.overflow = 'hidden'; return () => { document.body.style.overflow = before; }; }, [] ); useEffect( () => { ( async () => { try { const res: any = await getSetupState(); if ( res?.data ) { setPayload( res.data ); setSiteType( res.data.state.site_type || res.data.detected_type ); } } catch { // The checkup still works without saved state. } try { const [ c, o, p, s ]: any[] = await Promise.all( [ getDashboardCountsStats(), getDashboardOptimizationStats(), getScanProgress(), getPluginSettings(), ] ); if ( c?.data ) setCounts( c.data ); if ( o?.data ) setOptimization( o.data ); if ( p?.data ) setProgress( p.data ); if ( s?.data ) setSettings( s.data ); } catch { // Numbers fill in as the polls succeed. } } )(); }, [] ); // While the scan runs the numbers move, so the grade screen reads them from the same endpoints as the dashboard. useEffect( () => { if ( step === 0 ) { return; } let stop = false; const read = async () => { try { const [ p, a, c ]: any[] = await Promise.all( [ getScanProgress(), getDashboardAnalysisStats(), getDashboardCountsStats() ] ); if ( stop ) return; if ( p?.data ) setProgress( p.data ); if ( a?.data ) setAnalysis( a.data ); if ( c?.data ) setCounts( c.data ); } catch { // The next poll tells the same story. } }; read(); const timer = setInterval( read, POLL_MS ); return () => { stop = true; clearInterval( timer ); }; }, [ step ] ); const scanDone = !! progress && ! progress.is_running && progress.is_ready; // The grade opens on its own once there is something to show, or the moment the scan is done. useEffect( () => { if ( step !== 1 || ! analysis ) { return; } const wait = Math.max( 0, AUTO_GRADE_MS - ( Date.now() - startedAt.current ) ); // Replace, not push: Back from the grade must not land on a screen that immediately skips forward. const timer = setTimeout( () => go( 2, true ), scanDone ? 0 : wait ); return () => clearTimeout( timer ); }, [ step, analysis, scanDone ] ); // Once the library is scanned, test a few photos on copies so the last screen can show them. useEffect( () => { if ( step >= 2 && scanDone && ! checkStarted.current && ( counts?.total_images ?? 0 ) > 0 ) { checkStarted.current = true; check.run(); } }, [ step, scanDone, counts?.total_images ] ); // The grade shown while the scan was still running may not be the final one; say so once. useEffect( () => { if ( step === 2 && ! shownGrade.current && analysis?.grade ) { shownGrade.current = analysis.grade; } if ( step >= 2 && scanDone && analysis?.grade && shownGrade.current && analysis.grade !== shownGrade.current ) { setSettledGrade( analysis.grade ); } }, [ step, scanDone, analysis?.grade ] ); const photosReady = !! check.result && !! check.samples && check.samples.length > 0; useEffect( () => { if ( step !== 2 ) { return; } const timer = setTimeout( () => setPhotosWaitOver( true ), PHOTOS_WAIT_MS ); return () => clearTimeout( timer ); }, [ step ] ); // With images to show, hold the button until the photos are ready, the check has failed and there are none to wait for, // or the wait is over: then the button goes to the dashboard, the scan carries on there, and turns into "See my photos" if they arrive first. const waitingForPhotos = ( counts?.total_images ?? 0 ) > 0 && ! photosReady && ! photosWaitOver && ( ! scanDone || check.running ); // A screen that needs earlier work sends you back to it: a refreshed or shared link must not strand you. useEffect( () => { if ( ! progress || ! counts ) { return; } const hasImages = counts.total_images > 0; const scanned = progress.is_running || progress.is_ready; if ( step === 1 && ! scanned ) { go( 0, true ); } else if ( step === 2 && hasImages && ! scanned ) { go( 0, true ); } else if ( step === 3 && ! photosReady ) { go( hasImages && scanned ? 2 : 0, true ); } }, [ step, progress, counts, check.result, check.samples ] ); const skip = async () => { try { await saveSetupState( { skipped: true } ); } finally { navigate( '/' ); } }; const start = async () => { setBusy( true ); try { // Take the scan's own answer, or the guard below would read the old "not scanned" and send us back. const started: any = await startScan(); if ( started?.data ) setProgress( started.data ); saveSetupState( { started: true, mode, site_type: siteType } ).catch( () => {} ); startedAt.current = Date.now(); go( 1 ); } catch { toast.error( __( 'Could not start the scan. You can skip setup and try again from the dashboard.', 'image-sizes' ) ); } finally { setBusy( false ); } }; const changeSiteType = ( value: SiteType ) => { setSiteType( value ); setTypeMenu( false ); saveSetupState( { site_type: value } ).catch( () => {} ); }; const pending = settings ? FREE_SETTINGS.filter( ( item ) => ! settings[ item.key ] ) : []; const apply = async () => { setBusy( true ); try { if ( pending.length > 0 ) { const changes: Record< string, boolean > = {}; pending.forEach( ( item ) => { changes[ item.key ] = true; } ); await savePluginSettings( changes ); setSettings( ( current ) => ( { ...( current ?? {} ), ...changes } ) ); } setApplied( pending.length ); await saveSetupState( { completed: true, mode, site_type: siteType, free_fixes_applied: pending.map( ( item ) => item.key ), ...( analysis?.grade ? { grade_before: analysis.grade, score_before: analysis.health_score } : {} ), } ); if ( photosReady ) { go( 3 ); } else { navigate( '/' ); } } catch { toast.error( __( 'Could not save. Nothing was changed.', 'image-sizes' ) ); } finally { setBusy( false ); } }; const stats: DashboardModelStats | null = counts && analysis ? { scan_running: !! progress?.is_running, ...counts, total_thumbnails: optimization?.total_thumbnails ?? 0, unoptimized_images: optimization?.unoptimized_images ?? 0, compressed: optimization?.compressed ?? 0, not_compressed: optimization?.not_compressed ?? 0, ...analysis, } : null; // A site that already has Pro is not sold Pro: the upsell links go away, the rest stays. const pro = payload?.pro_active ?? !! window.THUMBPRESS?.pro_active; const images = counts?.total_images ?? 0; const thumbnails = optimization?.total_thumbnails ?? 0; const percent = progress?.total ? progress.percent : 0; const reclaimable = stats ? stats.duplicate_bytes + ( stats.likely_unused?.bytes ?? 0 ) + ( check.result?.saved_bytes ?? 0 ) : 0; const style = gradeStyle( analysis?.grade ?? '' ); const showBar = step >= 2 && !! progress && ( progress.is_running || scanDone ); if ( ! known ) { return ; } return (
{ __( 'ThumbPress', 'image-sizes' ) } { __( 'Site Image Checkup', 'image-sizes' ) }
{ /* START */ } { step === 0 && (
{ __( '60-second checkup', 'image-sizes' ) }

{ __( 'Let’s check your images.', 'image-sizes' ) }

{ counts ? numberFormat( images ) : '–' } { __( 'images in your library', 'image-sizes' ) }
{ optimization ? numberFormat( thumbnails ) : '–' } { __( 'thumbnails WordPress made from them', 'image-sizes' ) }
{ counts && images === 0 ? ( <>

{ __( 'You’re starting clean. Switch on the free settings now and new uploads stay light from the first one.', 'image-sizes' ) }

) : ( <>

{ __( 'We’ll scan your library and show your grade in a few seconds. Nothing changes until you say so.', 'image-sizes' ) }

) }
) } { /* CHECKING */ } { step === 1 && (
{ __( '60-second checkup', 'image-sizes' ) }

{ __( 'Checking your images…', 'image-sizes' ) }

{ sprintf( /* translators: 1: images checked, 2: images in the library. */ __( '%1$s of %2$s images checked', 'image-sizes' ), numberFormat( progress?.processed ?? 0 ), numberFormat( progress?.total ?? images ), ) } { percent }%
{ [ [ analysis?.large_images ?? 0, __( 'over 1 MB', 'image-sizes' ) ], [ analysis?.missing_alt ?? 0, __( 'without alt text', 'image-sizes' ) ], [ counts?.not_webp ?? 0, __( 'not WebP yet', 'image-sizes' ) ], ].map( ( [ value, label ] ) => (
{ numberFormat( value as number ) } { label }
) ) }

{ __( 'Opens on its own in a few seconds. Duplicates keep counting in the background.', 'image-sizes' ) }

) } { /* GRADE */ } { step >= 2 && (
{ images === 0 ? (

{ __( 'You’re starting clean.', 'image-sizes' ) }

{ __( 'There is nothing to grade yet. Your grade appears after your first uploads and a scan.', 'image-sizes' ) }

) : ! stats ? (
{ __( 'Reading your numbers…', 'image-sizes' ) }
) : (
{ analysis?.grade }
{ sprintf( /* translators: %s: score out of 100. */ __( 'Your image grade · %s / 100', 'image-sizes' ), numberFormat( analysis?.health_score ?? 0 ), ) } { progress?.is_running && { __( 'Partial data', 'image-sizes' ) } } { typeMenu && (
    { SITE_TYPES.map( ( type ) => (
  • ) ) }
) }

{ heroSentence( stats ) }

{ check.result && check.result.saved_bytes > 0 && ( { sprintf( /* translators: %s: size compression could save, e.g. "2.1 GB". */ __( 'You could get back ~%s', 'image-sizes' ), formatBytes( check.result.saved_bytes, true ), ) } ) }
{ ( [ [ 'speed', __( 'Speed', 'image-sizes' ) ], [ 'seo', __( 'SEO', 'image-sizes' ) ], [ 'storage', __( 'Storage', 'image-sizes' ) ], [ 'protect', __( 'Protect', 'image-sizes' ) ] ] as const ).map( ( [ key, name ] ) => (
{ name } { categoryLine( key, stats, reclaimable, !! progress?.is_running ) }
) ) }
) } { settledGrade && (
{ sprintf( /* translators: %s: grade letter. */ __( 'The scan finished and counted more. Your grade is now %s.', 'image-sizes' ), settledGrade, ) }
) }
{ ! settings ? __( 'Reading your settings…', 'image-sizes' ) : pending.length > 0 ? sprintf( /* translators: %s: number of free settings that can be switched on. */ _n( 'Quick win: %s free setting switches on in one click', 'Quick win: %s free settings switch on in one click', pending.length, 'image-sizes' ), numberFormat( pending.length ), ) : __( 'Your free settings are already on', 'image-sizes' ) } { pending.length > 0 ? sprintf( /* translators: %s: names of the settings, joined with a middle dot. */ __( '%s. They apply to new uploads and visitors; your existing images are not touched.', 'image-sizes' ), pending.map( ( item ) => item.label ).join( ' · ' ), ) : __( 'WebP for new uploads and lazy loading are both on.', 'image-sizes' ) }
{ step === 2 && ( ) } { __( 'Nothing else changes.', 'image-sizes' ) } { ! pro && <>{ ' ' }{ __( 'Or see how Pro gets you to an A', 'image-sizes' ) } }
) }
{ /* Bottom scan bar: hands off to the dashboard's scan panel afterwards. */ } { showBar && progress && (
{ progress.is_running ? ( <> { sprintf( /* translators: 1: images checked, 2: images in the library. */ __( 'Still scanning in the background · %1$s of %2$s', 'image-sizes' ), numberFormat( progress.processed ), numberFormat( progress.total ), ) }
{ progress.percent }% ) : ( <> { __( 'Scan complete.', 'image-sizes' ) }{ ' ' } { sprintf( /* translators: %s: number of images checked. */ __( 'All %s images checked.', 'image-sizes' ), numberFormat( progress.indexed || progress.total ), ) }{ ' ' } { settledGrade ? sprintf( /* translators: %s: grade letter. */ __( 'Your grade is now %s.', 'image-sizes' ), settledGrade, ) : sprintf( /* translators: %s: grade letter. */ __( 'Your grade stays %s.', 'image-sizes' ), analysis?.grade ?? '', ) } ) }
) } { step === 3 && check.result && check.samples && ( navigate( '/' ) } /> ) }
); }