diff --git a/apps/studio/src/constants.ts b/apps/studio/src/constants.ts index 93ca9dd4ba..2e2612d4ad 100644 --- a/apps/studio/src/constants.ts +++ b/apps/studio/src/constants.ts @@ -17,7 +17,7 @@ export const AUTO_UPDATE_INTERVAL_MS = 60 * 60 * 1000; export const NIGHTLY_UPDATE_TTL_MS = 24 * 60 * 60 * 1000; export const MACOS_TRAFFIC_LIGHT_POSITION = { x: 20, y: 20 }; export const WINDOWS_TITLEBAR_HEIGHT = 44; -export const EMPTY_SITE_PLAYGROUND_URL = 'https://playground.wordpress.net/'; +export { EMPTY_SITE_PLAYGROUND_URL } from '@studio/common/constants'; export const ABOUT_WINDOW_WIDTH = 300; export const ABOUT_WINDOW_HEIGHT = 350; export const BUG_REPORT_URL = diff --git a/apps/studio/src/modules/add-site/components/new-site-options.tsx b/apps/studio/src/modules/add-site/components/new-site-options.tsx index 1c6b2599d9..cb6e1eb932 100644 --- a/apps/studio/src/modules/add-site/components/new-site-options.tsx +++ b/apps/studio/src/modules/add-site/components/new-site-options.tsx @@ -1,3 +1,7 @@ +import { + curateBlueprintsForDisplay, + FEATURED_BLUEPRINT_SLUGS, +} from '@studio/common/lib/blueprint-curation'; import { __experimentalVStack as VStack, __experimentalHeading as Heading, @@ -172,46 +176,6 @@ function BlueprintCard( { ); } -const BLUEPRINT_DISPLAY_NAMES: Record< string, string > = { - 'Quick Start': 'WordPress.com', - Development: 'WordPress Dev', - Commerce: 'WooCommerce', -}; - -function getBlueprintExcerptOverrides( __: ( text: string ) => string ): Record< string, string > { - return { - 'Quick Start': __( - 'A WordPress.com-like environment with Business plan plugins and themes pre-installed.' - ), - Commerce: __( - 'Create your next online store with WooCommerce and its companion plugins pre-installed.' - ), - Development: __( 'A streamlined environment for building and testing themes or plugins.' ), - }; -} - -const BLUEPRINT_ORDER: Record< string, number > = { - 'Quick Start': 1, - Commerce: 2, - Development: 3, -}; - -const FEATURED_BLUEPRINT_SLUGS = new Set( [ 'woo-shop', 'development', 'quick-start' ] ); - -function renameBlueprintsForDisplay( - blueprints: Blueprint[], - __: ( text: string ) => string -): Blueprint[] { - const excerptOverrides = getBlueprintExcerptOverrides( __ ); - return [ ...blueprints ] - .sort( ( a, b ) => ( BLUEPRINT_ORDER[ a.title ] ?? 99 ) - ( BLUEPRINT_ORDER[ b.title ] ?? 99 ) ) - .map( ( item ) => ( { - ...item, - excerpt: excerptOverrides[ item.title ] || item.excerpt, - title: BLUEPRINT_DISPLAY_NAMES[ item.title ] || item.title, - } ) ); -} - export function NewSiteOptions( { enableBlueprints, blueprints, @@ -289,7 +253,7 @@ export function NewSiteOptions( { ) : ( enableBlueprints && - renameBlueprintsForDisplay( featuredBlueprints, __ ).map( ( item ) => ( + curateBlueprintsForDisplay( featuredBlueprints, __ ).map( ( item ) => ( site.id ); + return fetchSyncableSites( token.accessToken, { connectedSiteIds } ); } export async function getConnectedWpcomSites( diff --git a/apps/ui/package.json b/apps/ui/package.json index bc3d5280cb..3e87785288 100644 --- a/apps/ui/package.json +++ b/apps/ui/package.json @@ -17,6 +17,7 @@ "@tanstack/react-query": "^5.75.5", "@tanstack/react-query-persist-client": "^5.96.2", "@tanstack/react-router": "^1.120.14", + "@wordpress/a11y": "^4.47.0", "@wordpress/api-fetch": "^7.47.0", "@wordpress/components": "^34.0.0", "@wordpress/core-data": "^7.47.0", diff --git a/apps/ui/src/components/blueprint-selector/index.tsx b/apps/ui/src/components/blueprint-selector/index.tsx index c6d6f3a93f..b231fb9ff0 100644 --- a/apps/ui/src/components/blueprint-selector/index.tsx +++ b/apps/ui/src/components/blueprint-selector/index.tsx @@ -1,11 +1,17 @@ +import { EMPTY_SITE_PLAYGROUND_URL } from '@studio/common/constants'; +import { + curateBlueprintsForDisplay, + FEATURED_BLUEPRINT_SLUGS, +} from '@studio/common/lib/blueprint-curation'; import { generateDefaultBlueprintDescription } from '@studio/common/lib/blueprint-settings'; import { validateBlueprintData } from '@studio/common/lib/blueprint-validation'; +import { Spinner } from '@wordpress/components'; import { __, sprintf } from '@wordpress/i18n'; -import { external } from '@wordpress/icons'; -import { Button, Icon } from '@wordpress/ui'; -import { useCallback, useState } from 'react'; -import { FileDropzone } from '@/components/file-dropzone'; +import { seen } from '@wordpress/icons'; +import { Button, Icon, Tooltip } from '@wordpress/ui'; +import { useCallback, useMemo, useRef, useState } from 'react'; import { useConnector } from '@/data/core'; +import { useGridArrowNavigation } from '@/hooks/use-grid-arrow-navigation'; import styles from './style.module.css'; import type { FeaturedBlueprint } from '@/data/core'; import type { BlueprintV1Declaration } from '@wp-playground/blueprints'; @@ -23,21 +29,159 @@ export interface PickedBlueprint { } interface BlueprintSelectorProps { - featured: FeaturedBlueprint[] | undefined; - isFeaturedLoading: boolean; + blueprints: FeaturedBlueprint[] | undefined; + isLoading: boolean; onPick: ( blueprint: PickedBlueprint ) => void; + // Picking the "Empty site" card — callers route this to the plain + // create-site flow rather than running an empty blueprint. + onPickEmpty: () => void; } const FILE_ACCEPT = 'application/json,.json,application/zip,.zip'; +/** + * Preview (eye) button overlaid on a card's image. Rendered as a sibling of + * the card's pick button (inside `cardMediaOverlay`) rather than nested in + * it, so the markup stays valid — nested interactive elements aren't. + */ +function PreviewOverlay( { url, title }: { url: string; title: string } ) { + const connector = useConnector(); + const label = __( 'Preview in your browser' ); + return ( +
+ + + void connector.openExternalUrl( url ) } + aria-label={ sprintf( + // translators: %s is the blueprint title. + __( 'Preview %s in Playground' ), + title + ) } + > + + + } + /> + }> + { label } + + + +
+ ); +} + +function EmptySiteCard( { onPick }: { onPick: () => void } ) { + return ( +
  • + + +
  • + ); +} + +function BlueprintCard( { + blueprint, + onPick, +}: { + blueprint: FeaturedBlueprint; + onPick: ( blueprint: FeaturedBlueprint ) => void; +} ) { + return ( +
  • + + { blueprint.playgroundUrl && ( + + ) } +
  • + ); +} + export function BlueprintSelector( { - featured, - isFeaturedLoading, + blueprints, + isLoading, onPick, + onPickEmpty, }: BlueprintSelectorProps ) { const connector = useConnector(); + const uploadInputRef = useRef< HTMLInputElement | null >( null ); + const handleGridKeyDown = useGridArrowNavigation(); const [ uploadError, setUploadError ] = useState< string | null >( null ); + // The endpoint returns blueprints oldest-first; newest-first reads better + // in the Explore grid (matches the desktop renderer). + const allBlueprints = useMemo( () => ( blueprints ?? [] ).slice().reverse(), [ blueprints ] ); + + const featuredBlueprints = useMemo( + () => + curateBlueprintsForDisplay( + allBlueprints.filter( ( blueprint ) => FEATURED_BLUEPRINT_SLUGS.has( blueprint.slug ) ), + __ + ), + [ allBlueprints ] + ); + const exploreBlueprints = useMemo( + () => allBlueprints.filter( ( blueprint ) => ! FEATURED_BLUEPRINT_SLUGS.has( blueprint.slug ) ), + [ allBlueprints ] + ); + const handleFeaturedClick = useCallback( ( item: FeaturedBlueprint ) => { setUploadError( null ); @@ -51,18 +195,6 @@ export function BlueprintSelector( { [ onPick ] ); - const handlePreviewClick = useCallback( - ( event: React.MouseEvent< HTMLButtonElement >, item: FeaturedBlueprint ) => { - // Stop the click from bubbling to the card's pick handler — the - // user explicitly asked to preview, not to select. - event.stopPropagation(); - if ( item.playgroundUrl ) { - void connector.openExternalUrl( item.playgroundUrl ); - } - }, - [ connector ] - ); - /** * Validates parsed blueprint JSON and hands a `PickedBlueprint` to the * parent. Returns `true` on success so callers can tell whether to clean @@ -170,64 +302,94 @@ export function BlueprintSelector( { [ acceptParsedBlueprint, connector ] ); + // Callers advertise "drop in your own", so the whole selector accepts + // blueprint drops; any validation error renders next to the Upload + // button above the explore grid. + const handleRootDrop = useCallback( + ( event: React.DragEvent< HTMLDivElement > ) => { + if ( event.defaultPrevented ) { + return; + } + event.preventDefault(); + const file = event.dataTransfer.files[ 0 ]; + if ( ! file ) { + return; + } + void handleFile( file ); + }, + [ handleFile ] + ); + return ( -
    +
    event.preventDefault() } + onDrop={ handleRootDrop } + >
    -

    { __( 'Upload your own' ) }

    - void handleFile( file ) } - error={ uploadError } - /> +
      + + { isLoading && ( +
    • + +
    • + ) } + { ! isLoading && allBlueprints.length === 0 && ( +
    • { __( 'Could not load templates.' ) }
    • + ) } + { featuredBlueprints.map( ( item ) => ( + + ) ) } +
    -
    -

    { __( 'Featured blueprints' ) }

    - { isFeaturedLoading && ( -

    { __( 'Loading featured blueprints…' ) }

    - ) } - { ! isFeaturedLoading && ( ! featured || featured.length === 0 ) && ( -

    - { __( 'No featured blueprints available right now.' ) } +

    +
    +

    { __( 'More blueprints' ) }

    +

    + { __( 'Get started quickly with a one of our blueprints, or' ) }{ ' ' } + + { '.' } +

    +
    + { uploadError && ( +

    + { uploadError }

    ) } - { featured && featured.length > 0 && ( -
      - { featured.map( ( item ) => ( -
    • - - { item.playgroundUrl && ( - - ) } -
    • + { + const file = event.target.files?.[ 0 ]; + if ( file ) { + void handleFile( file ); + } + // Reset so re-picking the same file after an error re-fires + // `change`. + event.target.value = ''; + } } + /> + { exploreBlueprints.length > 0 && ( +
        + { exploreBlueprints.map( ( item ) => ( + ) ) }
      ) } diff --git a/apps/ui/src/components/blueprint-selector/style.module.css b/apps/ui/src/components/blueprint-selector/style.module.css index 6ee1545b9d..a95aba945b 100644 --- a/apps/ui/src/components/blueprint-selector/style.module.css +++ b/apps/ui/src/components/blueprint-selector/style.module.css @@ -16,12 +16,6 @@ margin: 0; } -.featuredHint { - margin: 0; - color: var(--wpds-color-fg-content-neutral-weak, #666); - font-size: 0.875rem; -} - .grid { list-style: none; margin: 0; @@ -39,6 +33,98 @@ } } +/* The featured row (Empty site + the curated trio) fits four across on the + wide layout and folds to a 2x2 grid when the window narrows. */ +.gridFeatured { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +@media (max-width: 880px) { + .gridFeatured { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +/* Extra room between the featured row and the explore section so the + curated cards stand apart from the long tail. */ +.exploreSection { + margin-top: 40px; +} + +/* Centered intro for the blueprints long tail: heading, one-line blurb, + then the search + upload controls. */ +.exploreHeader { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + margin-bottom: 24px; +} + +.exploreSubtitle { + margin: 0; + color: var(--wpds-color-fg-content-neutral-weak, #666); + font-size: 0.875rem; +} + +/* Inline "upload a blueprint" link inside the subtitle — strip the button + box so it flows with the sentence. */ +.uploadLink { + padding: 0; + height: auto; + min-height: 0; + font-size: inherit; +} + +/* Matches the FileDropzone error pill the upload section used to render. */ +.uploadError { + padding: 8px 12px; + border-radius: 4px; + background: #fce8e8; + color: #7a1a1a; + font-size: 13px; + margin: 0; +} + +.hiddenInput { + display: none; +} + +/* Denser columns + tighter type than the featured row, so the open-by- + default explore grid reads as secondary to the curated cards. */ +.gridCompact { + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 12px; +} + +@media (max-width: 880px) { + .gridCompact { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +.gridCompact .cardBody { + padding: 10px 12px 12px; + gap: 4px; +} + +.gridCompact .cardTitle { + font-size: 0.8125rem; +} + +.gridCompact .cardExcerpt { + font-size: 0.75rem; + -webkit-line-clamp: 2; +} + +.gridStatus { + display: flex; + align-items: center; + justify-content: center; + color: var(--wpds-color-fg-content-neutral-weak, #666); + font-size: 0.875rem; +} + .cardWrapper { position: relative; } @@ -47,6 +133,7 @@ display: flex; flex-direction: column; width: 100%; + height: 100%; padding: 0; border: 1px solid var(--wpds-color-stroke-surface-neutral, #ddd); border-radius: 8px; @@ -55,20 +142,71 @@ overflow: hidden; text-align: left; color: inherit; + transition: border-color 0.15s ease; } .card:hover { - border-color: var(--wpds-color-stroke-interactive-neutral, #bbb); + border-color: var(--wpds-color-stroke-interactive-brand, #3858e9); +} + +/* Keyboard focus mirrors the hover affordance, plus an offset ring. */ +.card:focus-visible { + outline: 2px solid var(--wpds-color-stroke-interactive-brand, #3858e9); + outline-offset: 2px; + border-color: var(--wpds-color-stroke-interactive-brand, #3858e9); +} + +/* Anchors the Live Preview pill to the card's media area. Sits outside the + pick button (siblings, not nested) so the markup stays valid; the wrapper + itself ignores pointer events so card clicks pass through. */ +.cardMediaOverlay { + position: absolute; + top: 0; + left: 0; + right: 0; + aspect-ratio: 16 / 9; + pointer-events: none; } .previewButton { position: absolute; - top: 8px; + bottom: 8px; right: 8px; + pointer-events: auto; + display: inline-flex; + align-items: center; gap: 4px; + padding: 4px 8px; + border: 1px solid rgba(255, 255, 255, 0.9); + border-radius: 4px; background: rgba(255, 255, 255, 0.92); + color: #1e1e1e; + font-size: 11px; + line-height: 1; + white-space: nowrap; + cursor: pointer; backdrop-filter: blur(6px); - border-radius: 4px; + /* Hairline dark ring outside the light border so the pill separates + from both light and dark imagery — a contrast edge, not elevation. */ + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35); + /* Revealed on card hover (the button sits inside the hovered wrapper, + so pointing at its corner shows it too) or on keyboard focus. */ + opacity: 0; + transition: opacity 0.15s ease; +} + +.cardWrapper:hover .previewButton, +.previewButton:focus-visible { + opacity: 1; +} + +.previewButton:focus-visible { + outline: 2px solid var(--wpds-color-stroke-interactive-brand, #3858e9); + outline-offset: 2px; +} + +.previewButton:hover { + background: #fff; } .cardImage { @@ -78,6 +216,40 @@ background: #f0f0f0; } +.cardImageFallback { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + aspect-ratio: 16 / 9; + background: var(--wpds-color-bg-surface-neutral, #f0f0f0); + color: var(--wpds-color-fg-content-neutral-weak, #666); + font-size: 0.8125rem; +} + +/* Thumbnail for the Empty Site card — a fixed dark slate with a faint grid + and a document glyph, identical in both color schemes by design. */ +.emptyMedia { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 100%; + aspect-ratio: 16 / 9; + background: #1f1f1f; + color: #fff; + overflow: hidden; +} + +.emptyMediaGrid { + position: absolute; + inset: 0; + background-image: + linear-gradient(rgba(255, 255, 255, 0.06) 1px, transparent 1px), + linear-gradient(90deg, rgba(255, 255, 255, 0.06) 1px, transparent 1px); + background-size: 32px 32px; +} + .cardBody { display: flex; flex-direction: column; @@ -88,6 +260,9 @@ .cardTitle { font-size: 0.95rem; font-weight: 600; + /* The global h3 rule pins line-height to the fixed 28px lg token, which + reads double-spaced at this reduced font size once a title wraps. */ + line-height: 1.3; margin: 0; } diff --git a/apps/ui/src/components/busy-overlay/index.tsx b/apps/ui/src/components/busy-overlay/index.tsx new file mode 100644 index 0000000000..cacaddc34e --- /dev/null +++ b/apps/ui/src/components/busy-overlay/index.tsx @@ -0,0 +1,15 @@ +import styles from './style.module.css'; + +/** + * Transparent full-window shield that blocks pointer interaction while a + * long-running action (site creation, connect-and-pull) finishes. Pair it + * with disabled/loading states on the triggering button — it deliberately + * covers everything, including the onboarding close button, so a stray + * click can't interrupt the work mid-flight. + */ +export function BusyOverlay( { active }: { active: boolean } ) { + if ( ! active ) { + return null; + } + return ); } @@ -141,33 +186,30 @@ function OnboardingBlueprintPage() { // above; render nothing in the intermediate frame to avoid a flash. if ( ! picked ) return null; - const initialValues = mapBlueprintSettingsToFormValues( - extractFormValuesFromBlueprint( picked.blueprint ), - picked.title - ); + // Hold the form until the collision-checked name resolves — initial + // values are applied once, so seeding early with a colliding name would + // lock it in. + const initialValues = seededName + ? { + ...mapBlueprintSettingsToFormValues( + extractFormValuesFromBlueprint( picked.blueprint ), + picked.title + ), + name: seededName, + } + : undefined; return (
      -

      { picked.title }

      { picked.excerpt &&

      { picked.excerpt }

      } void navigate( { to: '/onboarding' } ) } + onCancel={ handleBackToSelect } isSubmitting={ createSite.isPending } submitError={ submitError } - submitLabel={ __( 'Create site from Blueprint' ) } />
      ); @@ -193,15 +235,19 @@ function mapBlueprintSettingsToFormValues( }; } +// Exported so the desks router can register the same page under its own +// route tree with identical search semantics. +export function validateBlueprintSearch( search: Record< string, unknown > ): BlueprintSearch { + const value = search.step; + if ( value === 'configure' || value === 'select' ) { + return { step: value }; + } + return {}; +} + export const onboardingBlueprintRoute = createRoute( { getParentRoute: () => onboardingLayoutRoute, path: '/onboarding/blueprint', - validateSearch: ( search: Record< string, unknown > ): BlueprintSearch => { - const value = search.step; - if ( value === 'configure' || value === 'select' ) { - return { step: value }; - } - return {}; - }, + validateSearch: validateBlueprintSearch, component: OnboardingBlueprintPage, } ); diff --git a/apps/ui/src/ui-classic/router/route-onboarding-blueprint/style.module.css b/apps/ui/src/ui-classic/router/route-onboarding-blueprint/style.module.css deleted file mode 100644 index 56615bfa21..0000000000 --- a/apps/ui/src/ui-classic/router/route-onboarding-blueprint/style.module.css +++ /dev/null @@ -1,8 +0,0 @@ -.backLink { - display: inline-flex; - align-items: center; - gap: 4px; - align-self: flex-start; - margin-bottom: 16px; - padding: 4px 8px 4px 4px; -} diff --git a/apps/ui/src/ui-classic/router/route-onboarding-connect/index.tsx b/apps/ui/src/ui-classic/router/route-onboarding-connect/index.tsx new file mode 100644 index 0000000000..31011f69c1 --- /dev/null +++ b/apps/ui/src/ui-classic/router/route-onboarding-connect/index.tsx @@ -0,0 +1,585 @@ +import { getEnvironmentLabel, getSiteEnvironment } from '@studio/common/lib/sync/environment-utils'; +import { getMshotUrl } from '@studio/common/lib/sync/mshots'; +import { createRoute, useNavigate } from '@tanstack/react-router'; +import { speak } from '@wordpress/a11y'; +import { Spinner } from '@wordpress/components'; +import { __, sprintf } from '@wordpress/i18n'; +import { chevronLeft, check, external, search, wordpress } from '@wordpress/icons'; +import { Button, Icon, Input, InputLayout } from '@wordpress/ui'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { BusyOverlay } from '@/components/busy-overlay'; +import { OnboardingFooter } from '@/components/onboarding-footer'; +import { useConnector } from '@/data/core'; +import { useAuthUser } from '@/data/queries/use-auth-user'; +import { useFindAvailableSiteName } from '@/data/queries/use-create-site-helpers'; +import { useCreateSite, useDeleteSite } from '@/data/queries/use-sites'; +import { usePullSiteFromLive } from '@/data/queries/use-sync-site'; +import { useUserLocale } from '@/data/queries/use-user-locale'; +import { useSyncableWpcomSites } from '@/data/queries/use-wpcom-sites'; +import { useGridArrowNavigation } from '@/hooks/use-grid-arrow-navigation'; +import { useOffline } from '@/hooks/use-offline'; +import { getLocalizedLink } from '@/lib/docs-links'; +import { onboardingLayoutRoute } from '../layout-onboarding'; +import sharedStyles from '../layout-onboarding/style.module.css'; +import styles from './style.module.css'; +import type { SyncSite } from '@/data/core'; + +const SEARCH_VISIBILITY_THRESHOLD = 5; + +// Same wordpress.com new-site flow the desktop renderer's Create button +// opens (see generate-checkout-url.ts). +const CREATE_WPCOM_SITE_URL = + 'https://wordpress.com/setup/new-hosted-site?ref=studio§ion=studio-sync&showDomainStep=true'; + +// Labels for sites the user can see but not pick; the needs-upgrade and +// needs-transfer groups get overlay CTAs instead. +function getSyncStatusLabel( site: SyncSite ): string | null { + switch ( site.syncSupport ) { + case 'already-connected': + return __( 'Already connected' ); + case 'missing-permissions': + return __( 'Missing permissions' ); + case 'deleted': + return __( 'Deleted' ); + case 'unsupported': + return __( 'Unsupported' ); + default: + return null; + } +} + +interface SiteSection { + key: string; + title?: string; + description?: string; + sites: SyncSite[]; +} + +// Groups sites the way the desktop renderer's picker does: syncable sites +// lead (no heading), followed by explained groups for everything else. +function groupSites( sites: SyncSite[] ): SiteSection[] { + const syncable = sites.filter( ( s ) => s.syncSupport === 'syncable' ); + const alreadyConnected = sites.filter( ( s ) => s.syncSupport === 'already-connected' ); + const needsTransfer = sites.filter( ( s ) => s.syncSupport === 'needs-transfer' ); + const needsUpgrade = sites.filter( ( s ) => s.syncSupport === 'needs-upgrade' ); + const other = sites.filter( + ( s ) => + s.syncSupport === 'unsupported' || + s.syncSupport === 'missing-permissions' || + s.syncSupport === 'deleted' + ); + + const sections: SiteSection[] = []; + if ( syncable.length > 0 ) { + sections.push( { key: 'syncable', sites: syncable } ); + } + if ( alreadyConnected.length > 0 ) { + sections.push( { + key: 'already-connected', + title: __( 'Already connected' ), + description: __( 'These sites are already linked to a local site.' ), + sites: alreadyConnected, + } ); + } + if ( needsTransfer.length > 0 ) { + sections.push( { + key: 'needs-transfer', + title: __( 'Enable hosting features first' ), + description: __( + 'These sites need hosting features turned on before they can sync. You can do this from WordPress.com.' + ), + sites: needsTransfer, + } ); + } + if ( needsUpgrade.length > 0 ) { + sections.push( { + key: 'needs-upgrade', + title: __( 'Upgrade your plan to sync' ), + description: __( + 'Syncing requires a Business plan or higher. Upgrade on WordPress.com to get started.' + ), + sites: needsUpgrade, + } ); + } + if ( other.length > 0 ) { + sections.push( { + key: 'other', + title: __( 'Not available for sync' ), + description: __( + "These sites can't be synced due to missing permissions or other limitations." + ), + sites: other, + } ); + } + return sections; +} + +function SignedOutView() { + const connector = useConnector(); + const isOffline = useOffline(); + + const benefits = [ + __( 'Work on your site locally.' ), + __( 'Sync content, themes, and plugins.' ), + __( 'Supports staging and production sites.' ), + ]; + + return ( +
      +
        + { benefits.map( ( benefit ) => ( +
      • + + { benefit } +
      • + ) ) } +
      +
      + +

      + { __( 'New to WordPress.com?' ) }{ ' ' } + +

      + { isOffline && ( +

      { __( "You're currently offline." ) }

      + ) } +
      +
      + ); +} + +// Centered call to action on a non-syncable site's thumbnail — "Enable" for +// sites that need hosting features, "Upgrade plan" for free-plan sites. +// Rendered as a sibling of the (inert) card button so the markup stays valid. +function ThumbnailCta( { site }: { site: SyncSite } ) { + const connector = useConnector(); + + if ( site.syncSupport === 'needs-upgrade' ) { + return ( +
      + + { site.planName && { site.planName } } +
      + ); + } + if ( site.syncSupport === 'needs-transfer' ) { + return ( +
      + +
      + ); + } + return null; +} + +function RemoteSiteCard( { + site, + isSelected, + onSelect, +}: { + site: SyncSite; + isSelected: boolean; + onSelect: ( id: number ) => void; +} ) { + const isSyncable = site.syncSupport === 'syncable'; + const isDimmed = + site.syncSupport === 'deleted' || + site.syncSupport === 'unsupported' || + site.syncSupport === 'missing-permissions'; + const statusLabel = getSyncStatusLabel( site ); + const environment = getSiteEnvironment( site ); + + let cardClass = styles.siteCard; + if ( isSelected ) { + cardClass += ` ${ styles.siteCardSelected }`; + } else if ( ! isSyncable ) { + cardClass += ` ${ styles.siteCardInert }`; + if ( isDimmed ) { + cardClass += ` ${ styles.siteCardDimmed }`; + } + } + + return ( +
    • + + +
    • + ); +} + +export function OnboardingConnectPage() { + const navigate = useNavigate(); + const connector = useConnector(); + const locale = useUserLocale(); + const { data: user, isLoading: isAuthLoading } = useAuthUser(); + const syncable = useSyncableWpcomSites( { enabled: !! user } ); + const createSite = useCreateSite(); + const deleteSite = useDeleteSite(); + const pullSiteFromLive = usePullSiteFromLive(); + const findAvailableSiteName = useFindAvailableSiteName(); + + const isOffline = useOffline(); + const handleGridKeyDown = useGridArrowNavigation(); + const [ selectedId, setSelectedId ] = useState< number | null >( null ); + const [ isConnecting, setIsConnecting ] = useState( false ); + const [ submitError, setSubmitError ] = useState( '' ); + const [ searchQuery, setSearchQuery ] = useState( '' ); + + const sites = useMemo( () => syncable.data ?? [], [ syncable.data ] ); + const isSingleSite = sites.length === 1; + + // With exactly one site on the account, pre-select it so the user can + // proceed straight to Add site — mirrors the desktop renderer. + useEffect( () => { + if ( isSingleSite && sites[ 0 ].syncSupport === 'syncable' ) { + setSelectedId( sites[ 0 ].id ); + } + }, [ isSingleSite, sites ] ); + const filteredSites = useMemo( () => { + const query = searchQuery.toLowerCase().trim(); + if ( ! query ) { + return sites; + } + return sites.filter( + ( site ) => + site.name.toLowerCase().includes( query ) || site.url.toLowerCase().includes( query ) + ); + }, [ sites, searchQuery ] ); + const sections = useMemo( () => groupSites( filteredSites ), [ filteredSites ] ); + // While a search narrows the list, hold the compact card size everywhere — + // the lead section's grow-to-fill sizing would balloon one or two matches + // and resize them with every keystroke. + const isSearching = searchQuery.trim().length > 0; + + const selectedSite = sites.find( ( site ) => site.id === selectedId ); + const showSearch = searchQuery.length > 0 || sites.length > SEARCH_VISIBILITY_THRESHOLD; + + const handleConnect = useCallback( async () => { + if ( ! selectedSite || isConnecting ) { + return; + } + setSubmitError( '' ); + setIsConnecting( true ); + let createdSiteId: string | null = null; + try { + // Create the local shell first (skipping server start — the pull + // restarts it once the remote content lands), then persist the + // connection and kick off the pull. Mirrors the desktop renderer's + // pull-remote flow. + const { name: availableName, path } = await findAvailableSiteName( + selectedSite.name || selectedSite.url + ); + const site = await createSite.mutateAsync( { + name: availableName, + path, + skipStart: true, + } ); + createdSiteId = site.id; + await connector.connectWpcomSite( site.id, { + ...selectedSite, + localSiteId: site.id, + syncSupport: 'already-connected', + } ); + // Fire-and-forget: the pull reports progress through the shared + // sync-activity channel, which the site view surfaces. + pullSiteFromLive.mutate( { siteId: site.id, remoteSiteId: selectedSite.id } ); + speak( + sprintf( + // translators: %s is the site name. + __( '%s site added.' ), + availableName + ) + ); + await navigate( { to: '/sites/$siteId/new', params: { siteId: site.id } } ); + } catch ( error ) { + // Roll back the never-connected shell so a retry doesn't leave an + // orphaned local site behind (and pick "Name 2" next time around). + if ( createdSiteId ) { + try { + await deleteSite.mutateAsync( { id: createdSiteId } ); + } catch { + // Keep the original connect error as the user-facing message. + } + } + setIsConnecting( false ); + setSubmitError( + error instanceof Error ? error.message : __( 'Failed to connect site. Please try again.' ) + ); + } + }, [ + selectedSite, + isConnecting, + findAvailableSiteName, + connector, + createSite, + deleteSite, + pullSiteFromLive, + navigate, + ] ); + + const isSignedIn = !! user; + + const helperLinks = ( +

      + + { ' · ' } + +

      + ); + + return ( +
      + { /* Connecting creates the local site and persists the connection; + shield the window so stray clicks can't interrupt mid-flight. */ } + +

      + { isSignedIn && isSingleSite ? __( 'Connect your site' ) : __( 'Connect a site' ) } +

      +

      + { ! isSignedIn && __( 'Connect your WordPress.com account to access your sites.' ) } + { isSignedIn && + ( isSingleSite + ? __( 'Ready to bring into your Studio.' ) + : __( 'Select a WordPress.com or Pressable site to bring into your Studio.' ) ) } +

      + + { ! isSignedIn && ! isAuthLoading && } + + { isSignedIn && ( + <> + { /* The helper links read "Refreshing…" during the initial + load; hide the whole row until the list exists and let + the loading state below carry the message. */ } + { ! isSingleSite && ! syncable.isLoading && ( +
      + { showSearch && ( + + + + } + value={ searchQuery } + onChange={ ( event ) => setSearchQuery( event.target.value ) } + /> + ) } + { helperLinks } +
      + ) } + + { syncable.isLoading && ( +
      + +

      { __( 'Loading your sites…' ) }

      +
      + ) } + { ! syncable.isLoading && sites.length === 0 && ( +
      +

      + { __( 'No WordPress.com sites found on this account.' ) } +

      + +
      + ) } + { ! syncable.isLoading && sites.length > 0 && filteredSites.length === 0 && ( +

      + { sprintf( + // translators: %s is the search query. + __( 'No sites found for "%s"' ), + searchQuery + ) } +

      + ) } + + { isSingleSite ? ( +
      +
        + +
      + { helperLinks } +
      + ) : ( +
      + { sections.map( ( section, sectionIndex ) => ( +
      + { section.title && ( +
      +

      { section.title }

      + { section.description && ( +

      { section.description }

      + ) } +
      + ) } + { /* Only the lead section grows its cards to fill the + row — and only when not searching; secondary groups + and filtered results stay at the compact size. */ } +
        + { section.sites.map( ( site ) => ( + + ) ) } +
      +
      + ) ) } +
      + ) } + + { submitError && ( +
      + { submitError } +
      + ) } + + ) } + + + + { isSignedIn && ( + + ) } + +
      + ); +} + +export const onboardingConnectRoute = createRoute( { + getParentRoute: () => onboardingLayoutRoute, + path: '/onboarding/connect', + component: OnboardingConnectPage, +} ); diff --git a/apps/ui/src/ui-classic/router/route-onboarding-connect/style.module.css b/apps/ui/src/ui-classic/router/route-onboarding-connect/style.module.css new file mode 100644 index 0000000000..eee7532687 --- /dev/null +++ b/apps/ui/src/ui-classic/router/route-onboarding-connect/style.module.css @@ -0,0 +1,399 @@ +.signedOut { + display: flex; + flex-direction: column; + gap: 24px; + max-width: 420px; + margin-inline: auto; + text-align: left; +} + +.benefits { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +.benefit { + display: flex; + align-items: center; + gap: 8px; + color: var(--wpds-color-fg-content-neutral-weak, #666); +} + +.benefitIcon { + flex-shrink: 0; + fill: var(--wpds-color-fg-interactive-brand, #3858e9); +} + +.authActions { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 12px; +} + +.signupHint { + margin: 0; + color: var(--wpds-color-fg-content-neutral-weak, #666); +} + +.signupLink { + padding: 0; + height: auto; + min-height: 0; +} + +.offlineHint { + margin: 0; + font-size: 0.8125rem; + color: var(--wpds-color-fg-content-neutral-weak, #666); +} + +.listHint { + margin: 0; + color: var(--wpds-color-fg-content-neutral-weak, #666); +} + +/* Initial-load state: spinner over the hint, centered where the grid will + appear. */ +.loadingState { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + padding: 32px 0; +} + +/* Search + helper links, centered under the subtitle like the desktop + renderer's picker header. */ +.searchHeader { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + max-width: 480px; + margin: 0 auto 24px; +} + +.search { + width: 100%; + /* The wpds input fill token is transparent (#0000); with the dot grid + correctly painting behind the page, it shows through the field + without a solid fill. */ + background-color: var(--wpds-color-bg-surface-neutral, #fcfcfc); +} + +.helperLinks { + margin: 0; + font-size: 0.75rem; + color: var(--wpds-color-fg-content-neutral-weak, #666); + display: flex; + align-items: center; + gap: 4px; +} + +.helperLink { + padding: 0; + height: auto; + min-height: 0; + font-size: 0.75rem; +} + +.sections { + display: flex; + flex-direction: column; + gap: 32px; + text-align: left; +} + +.section { + display: flex; + flex-direction: column; + gap: 16px; +} + +.sectionHeader { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + gap: 2px; +} + +.sectionTitle { + font-size: 1rem; + font-weight: 500; + margin: 0; +} + +.sectionDescription { + margin: 0; + font-size: 0.875rem; + color: var(--wpds-color-fg-content-neutral-weak, #666); +} + +/* Mirrors the desktop renderer's pull-remote picker: a responsive grid of + site cards with live screenshot thumbnails. */ +.siteGrid { + list-style: none; + margin: 0; + padding: 0; + display: grid; + /* auto-fit (not auto-fill) collapses unused tracks, so a handful of + sites gets larger cards instead of empty columns. */ + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 20px; +} + +/* Larger windows raise the column minimum so thumbnails grow with the + viewport instead of just adding more columns. */ +@media (min-width: 1280px) { + .siteGrid { + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + } +} + +/* Secondary sections (Already connected, upgrade/transfer, …): auto-fill + keeps the empty tracks, so a couple of sites render at the compact card + size instead of ballooning to fill the row — the count-aware sizing only + applies to the lead section. */ +.siteGridSecondary { + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); +} + +@media (min-width: 1280px) { + .siteGridSecondary { + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + } +} + +/* Single-site account: one comfortable card, centered. */ +.siteGridSingle { + grid-template-columns: minmax(0, 420px); + justify-content: center; +} + +.singleSite { + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; +} + +.emptyState { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; +} + +.siteCardWrapper { + position: relative; +} + +.siteCard { + display: flex; + flex-direction: column; + width: 100%; + padding: 6px; + border: none; + border-radius: 12px; + background: transparent; + cursor: pointer; + text-align: left; + color: inherit; + transition: background-color 0.15s ease, box-shadow 0.15s ease; +} + +/* Brand ring on hover, matching the card affordance on the home and + gallery screens. */ +.siteCard:hover { + background: var(--wpds-color-bg-surface-neutral-strong, #f7f7f7); + box-shadow: 0 0 0 2px var(--wpds-color-stroke-interactive-brand, #3858e9); +} + +/* Keyboard focus gets the hover surface plus an offset ring — the selected + state's own ring sits flush, so the two remain distinguishable. */ +.siteCard:focus-visible { + outline: 2px solid var(--wpds-color-stroke-interactive-brand, #3858e9); + outline-offset: 2px; + background: var(--wpds-color-bg-surface-neutral-strong, #f7f7f7); +} + +/* Visible but not selectable (already connected, needs upgrade/transfer). */ +.siteCardInert, +.siteCardInert:hover { + cursor: default; + background: transparent; + box-shadow: none; +} + +/* Sites that can't sync at all (deleted, unsupported, missing permissions). */ +.siteCardDimmed, +.siteCardDimmed:hover { + opacity: 0.55; +} + +.siteCardSelected, +.siteCardSelected:hover { + box-shadow: 0 0 0 2px var(--wpds-color-stroke-interactive-brand, #3858e9); + background: color-mix(in srgb, var(--wpds-color-fg-interactive-brand, #3858e9) 5%, transparent); +} + +.siteThumb { + /* border-box so the 1px border doesn't push the thumb 2px past its + container — which also kept the CTA scrim from covering its right + and bottom edges. */ + box-sizing: border-box; + position: relative; + display: block; + width: 100%; + aspect-ratio: 3 / 2; + border-radius: 8px; + overflow: hidden; + border: 1px solid var(--wpds-color-stroke-surface-neutral, #ddd); + background: var(--wpds-color-bg-surface-neutral, #f0f0f0); +} + +.siteThumb img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.siteBadges { + position: absolute; + bottom: 6px; + right: 6px; + display: flex; + gap: 4px; +} + +.siteBadge { + font-size: 11px; + line-height: 1; + padding: 4px 6px; + border-radius: 4px; + background: rgba(0, 0, 0, 0.4); + color: #fff; + backdrop-filter: blur(4px); +} + +/* Environment chips use the same fixed palette as the desktop renderer's + EnvironmentBadge — identical in both color schemes by design. */ +.envBadge { + font-size: 11px; + line-height: 1; + padding: 4px 6px; + border-radius: 4px; + font-weight: 500; +} + +.envBadge-production { + background: #ceead6; + color: #1a6928; +} + +.envBadge-staging { + background: #fef0c7; + color: #93590c; +} + +.envBadge-development { + background: #d9e2ff; + color: #1f3a93; +} + +/* Centered CTA over a non-syncable site's thumbnail. Sits outside the inert + card button (siblings, not nested) so the markup stays valid; aligned to + the thumbnail box, which is inset by the card's 6px padding. */ +.thumbCtaOverlay { + position: absolute; + top: 6px; + left: 6px; + right: 6px; + aspect-ratio: 3 / 2; + border-radius: 8px; + overflow: hidden; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + /* Scrim keeps the CTA legible over both light and busy thumbnails — + dark only, no blur, so the site underneath stays recognizable. */ + background: rgba(0, 0, 0, 0.4); +} + +.ctaButton { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 6px 12px; + border: none; + border-radius: 4px; + background: #fff; + color: #1e1e1e; + font-size: 12px; + font-weight: 500; + line-height: 1; + white-space: nowrap; + cursor: pointer; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15); +} + +.ctaButton:hover { + background: #f0f0f0; +} + +.ctaButton:focus-visible { + outline: 2px solid var(--wpds-color-stroke-interactive-brand, #3858e9); + outline-offset: 2px; +} + +.planBadge { + font-size: 11px; + line-height: 1; + padding: 4px 8px; + border-radius: 4px; + background: rgba(0, 0, 0, 0.5); + color: #fff; + backdrop-filter: blur(4px); +} + +.siteText { + display: flex; + flex-direction: column; + gap: 2px; + padding: 8px 6px 4px; + min-width: 0; +} + +.siteName { + font-size: 0.875rem; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.siteUrl { + font-size: 0.75rem; + color: var(--wpds-color-fg-content-neutral-weak, #666); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.siteStatus { + font-size: 11px; + color: var(--wpds-color-fg-content-neutral-weak, #666); +} + +.submitError { + margin-top: 16px; + color: var(--wpds-color-fg-content-error, var(--wpds-color-fg-content-neutral)); +} diff --git a/apps/ui/src/ui-classic/router/route-onboarding-create/index.tsx b/apps/ui/src/ui-classic/router/route-onboarding-create/index.tsx index bbb98abf50..7b8d9448b2 100644 --- a/apps/ui/src/ui-classic/router/route-onboarding-create/index.tsx +++ b/apps/ui/src/ui-classic/router/route-onboarding-create/index.tsx @@ -1,5 +1,6 @@ import { createRoute, useNavigate } from '@tanstack/react-router'; -import { __ } from '@wordpress/i18n'; +import { speak } from '@wordpress/a11y'; +import { __, sprintf } from '@wordpress/i18n'; import { useState } from 'react'; import { CreateSiteForm } from '@/components/create-site-form'; import { @@ -11,7 +12,7 @@ import { onboardingLayoutRoute } from '../layout-onboarding'; import styles from '../layout-onboarding/style.module.css'; import type { CreateSiteFormValues } from '@/components/create-site-form'; -function CreateSitePage() { +export function CreateSitePage() { const navigate = useNavigate(); const { data: sites } = useSites(); const { data: existingDomainNames } = useExistingCustomDomains(); @@ -33,6 +34,13 @@ function CreateSitePage() { adminPassword: values.adminPassword || undefined, adminEmail: values.adminEmail || undefined, } ); + speak( + sprintf( + // translators: %s is the site name. + __( '%s site added.' ), + values.name + ) + ); await navigate( { to: '/sites/$siteId/new', params: { siteId: site.id } } ); } catch ( error ) { setSubmitError( @@ -51,7 +59,7 @@ function CreateSitePage() { initialValues={ proposedName ? { name: proposedName } : undefined } existingDomainNames={ existingDomainNames ?? [] } onSubmit={ handleSubmit } - onCancel={ () => void navigate( { to: '/onboarding' } ) } + onCancel={ () => void navigate( { to: '/onboarding/blueprint' } ) } isSubmitting={ createSite.isPending } submitError={ submitError } /> diff --git a/apps/ui/src/ui-classic/router/route-onboarding-home/index.tsx b/apps/ui/src/ui-classic/router/route-onboarding-home/index.tsx index be5ebbe680..8320444843 100644 --- a/apps/ui/src/ui-classic/router/route-onboarding-home/index.tsx +++ b/apps/ui/src/ui-classic/router/route-onboarding-home/index.tsx @@ -1,36 +1,156 @@ -import { createRoute, Link } from '@tanstack/react-router'; +import { ACCEPTED_IMPORT_FILE_TYPES } from '@studio/common/constants'; +import { createRoute, Link, useNavigate } from '@tanstack/react-router'; import { __ } from '@wordpress/i18n'; +import { useCallback, useRef, useState } from 'react'; +import { + BuildNewSiteIllustration, + ConnectSiteIllustration, + DropBackupIllustration, + illustrationHostClass, +} from '@/components/onboarding-illustrations'; +import { useConnector } from '@/data/core'; +import { useGridArrowNavigation } from '@/hooks/use-grid-arrow-navigation'; +import { useOffline } from '@/hooks/use-offline'; +import { isValidBackupFile } from '@/lib/backup-files'; +import { setPendingBackup } from '@/lib/pending-backup'; import { onboardingLayoutRoute } from '../layout-onboarding'; -import styles from '../layout-onboarding/style.module.css'; +import sharedStyles from '../layout-onboarding/style.module.css'; +import styles from './style.module.css'; -function OnboardingHomePage() { +const cardClass = `${ styles.card } ${ illustrationHostClass }`; + +// Connecting requires WordPress.com, so the card is disabled offline — +// matching the desktop renderer's options screen. +function ConnectSiteCard() { + const isOffline = useOffline(); return ( -
      -

      { __( 'Start a new site' ) }

      -

      - { __( 'WordPress can power anything. What are you building?' ) } -

      -
      - -

      { __( 'Create new' ) }

      -

      - { __( 'Start fresh with a blank site and build it with AI' ) } -

      - - -

      { __( 'Start from a blueprint' ) }

      -

      - { __( - 'Pick a featured blueprint or drop in your own to provision plugins, content, and settings.' - ) } -

      - - -

      { __( 'Bring existing' ) }

      + { + if ( isOffline ) { + event.preventDefault(); + } + } } + > + +
      +

      { __( 'Connect a site' ) }

      +

      + { __( 'Edit a WordPress.com or Pressable site locally, then push changes back' ) } +

      +
      + + ); +} + +/** + * The import card doubles as a drop target, mirroring the desktop renderer's + * options screen: dropping (or browsing to) a valid backup archive skips the + * import route's select step and lands straight on configure, with the file + * handed over through the pending-backup slot. + */ +function ImportDropCard() { + const navigate = useNavigate(); + const connector = useConnector(); + const fileRef = useRef< HTMLInputElement >( null ); + const [ isDragging, setIsDragging ] = useState( false ); + const [ error, setError ] = useState< string | null >( null ); + + const handleFile = useCallback( + async ( file: File | undefined ) => { + if ( ! file ) { + return; + } + if ( ! isValidBackupFile( file ) ) { + setError( __( 'Unsupported file type.' ) ); + return; + } + const path = await connector.getFilePath( file ); + if ( ! path ) { + setError( __( 'Unable to read the file. Try clicking the card to browse instead.' ) ); + return; + } + setError( null ); + setPendingBackup( { file, path } ); + void navigate( { to: '/onboarding/import' } ); + }, + [ connector, navigate ] + ); + + return ( + <> + { + void handleFile( event.target.files?.[ 0 ] ); + event.target.value = ''; + } } + /> + + + ); +} + +export function OnboardingHomePage() { + const handleGridKeyDown = useGridArrowNavigation(); + return ( +
      +

      { __( 'Add a site' ) }

      +

      + { __( 'Start fresh or bring an existing site into your Studio.' ) } +

      +
      + + +
      +

      { __( 'Build a new site' ) }

      +

      + { __( + 'Start from scratch or use a blueprint. Perfect for theme and plugin development.' + ) } +

      +
      + +
      ); diff --git a/apps/ui/src/ui-classic/router/route-onboarding-home/style.module.css b/apps/ui/src/ui-classic/router/route-onboarding-home/style.module.css new file mode 100644 index 0000000000..155a770388 --- /dev/null +++ b/apps/ui/src/ui-classic/router/route-onboarding-home/style.module.css @@ -0,0 +1,140 @@ +/* The heading/subtitle use the shared classes from ../layout-onboarding so + the typography can't drift between the home screen and the steps it leads + to. Only `.page` stays local: the home screen has no fixed footer, so it + skips the shared footer-clearance padding. */ +.page { + width: 100%; + text-align: center; +} + +.cards { + display: flex; + gap: 20px; + justify-content: center; +} + +.card { + flex: 1 1 0; + min-width: 0; + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + padding: 24px; + border: 1px solid var(--wpds-color-stroke-surface-neutral, #ddd); + border-radius: 12px; + /* Semi-transparent surface lets the dot grid glow through; the blur keeps + text readable as dots drift underneath. */ + background: color-mix(in srgb, var(--wpds-color-bg-surface-neutral-strong, #fff) 50%, transparent); + backdrop-filter: blur(12px); + appearance: none; + font: inherit; + text-align: center; + color: inherit; + text-decoration: none; + cursor: pointer; + transition: border-color 0.15s ease; +} + +/* Illustrations are authored at 198×110; let them shrink with the card so + four cards fit comfortably on narrower windows. */ +.card svg { + max-width: 100%; + height: auto; +} + +/* The 1px border plus a 1px ring of the same color reads as a 2px edge + without shifting the layout. */ +.card:hover, +.cardDragging { + border-color: var(--wpds-color-stroke-interactive-brand, #3858e9); + box-shadow: 0 0 0 1px var(--wpds-color-stroke-interactive-brand, #3858e9); +} + +/* Keyboard focus mirrors the hover affordance, plus an offset ring so the + focused card is unmistakable against its neighbors. */ +.card:focus-visible { + outline: 2px solid var(--wpds-color-stroke-interactive-brand, #3858e9); + outline-offset: 2px; + border-color: var(--wpds-color-stroke-interactive-brand, #3858e9); +} + +.card:focus-visible .cardTitle { + color: var(--wpds-color-fg-interactive-brand, #3858e9); +} + +.cardDisabled { + opacity: 0.5; + cursor: not-allowed; +} + +.cardDisabled:hover { + border-color: var(--wpds-color-stroke-surface-neutral, #ddd); + box-shadow: none; +} + +.cardDisabled:hover .cardTitle { + color: inherit; +} + +.cardDragging { + background: color-mix(in srgb, var(--wpds-color-fg-interactive-brand, #3858e9) 5%, transparent); +} + +.cardText { + display: flex; + flex-direction: column; + gap: 4px; +} + +.cardTitle { + font-size: 16px; + font-weight: 500; + margin: 0; + transition: color 0.15s ease; +} + +.card:hover .cardTitle { + color: var(--wpds-color-fg-interactive-brand, #3858e9); +} + +.cardBody { + color: var(--wpds-color-fg-content-neutral-weak, #666); + font-size: 13px; + line-height: 1.4; + margin: 0; +} + +.cardError { + margin-top: 4px; + font-size: 12px; + color: var(--wpds-color-fg-content-error, #b32d2e); +} + +/* Narrow windows: stack the option cards and lay each one out + horizontally — illustration on the left, text on the right. */ +@media (max-width: 900px) { + .cards { + flex-direction: column; + align-items: stretch; + } + + .card { + flex-direction: row; + align-items: center; + gap: 20px; + text-align: left; + } + + .card svg { + flex-shrink: 0; + } + + .cardText { + text-align: left; + } +} + +.hiddenInput { + display: none; +} diff --git a/apps/ui/src/ui-classic/router/route-onboarding-import/index.tsx b/apps/ui/src/ui-classic/router/route-onboarding-import/index.tsx index d210ce266b..5186846a49 100644 --- a/apps/ui/src/ui-classic/router/route-onboarding-import/index.tsx +++ b/apps/ui/src/ui-classic/router/route-onboarding-import/index.tsx @@ -1,27 +1,18 @@ -import { ACCEPTED_IMPORT_FILE_TYPES } from '@studio/common/constants'; import { createRoute, useNavigate } from '@tanstack/react-router'; -import { __ } from '@wordpress/i18n'; -import { arrowLeft, download } from '@wordpress/icons'; -import { Button, Icon } from '@wordpress/ui'; -import { useCallback, useEffect, useState } from 'react'; -import { flushSync } from 'react-dom'; +import { speak } from '@wordpress/a11y'; +import { __, sprintf } from '@wordpress/i18n'; +import { useCallback, useEffect, useState, useSyncExternalStore } from 'react'; import { CreateSiteForm } from '@/components/create-site-form'; -import { FileDropzone } from '@/components/file-dropzone'; -import { useConnector } from '@/data/core'; import { useExistingCustomDomains } from '@/data/queries/use-create-site-helpers'; import { useImportSite } from '@/data/queries/use-import-site'; import { useCreateSite } from '@/data/queries/use-sites'; +import { useSeededSiteName } from '@/hooks/use-seeded-site-name'; +import { nameFromFilename } from '@/lib/backup-files'; +import { pendingBackupSlot } from '@/lib/pending-backup'; import { onboardingLayoutRoute } from '../layout-onboarding'; import sharedStyles from '../layout-onboarding/style.module.css'; -import styles from './style.module.css'; import type { CreateSiteFormValues } from '@/components/create-site-form'; -type Step = 'select' | 'configure'; - -interface ImportSearch { - step?: Step; -} - interface PickedBackup { file: File; // Resolved from the connector once at pick-time so the submit handler @@ -29,99 +20,50 @@ interface PickedBackup { path: string; } -function isValidBackupFile( file: File ): boolean { - const lower = file.name.toLowerCase(); - return ACCEPTED_IMPORT_FILE_TYPES.some( ( ext ) => lower.endsWith( ext ) ); -} - /** - * Derives a friendly default site name from a backup filename. Strips the - * archive extension and common "site-backup-2024-01-01" date suffixes so the - * form can seed the site name without the user having to retype it. + * Configure-and-import step. The backup file itself is always picked on the + * onboarding home screen (its drop-target card) and handed over through the + * pending-backup slot — this route has no picker of its own. */ -function nameFromFilename( filename: string ): string { - const basename = filename.replace( /^.*[\\/]/, '' ); - const lower = basename.toLowerCase(); - const ext = ACCEPTED_IMPORT_FILE_TYPES.find( ( candidate ) => lower.endsWith( candidate ) ); - return ( ext ? basename.slice( 0, -ext.length ) : basename ) - .replace( /[-_](backup|export|wordpress|jetpack)(s)?$/i, '' ) - .replace( /[-_]\d{4}[-_]\d{2}[-_]\d{2}.*$/, '' ) - .replace( /[-_]+/g, ' ' ) - .trim(); -} - -function OnboardingImportPage() { - const { step } = onboardingImportRoute.useSearch(); +export function OnboardingImportPage() { const navigate = useNavigate(); - const connector = useConnector(); - const activeStep: Step = step === 'configure' ? 'configure' : 'select'; const { data: existingDomainNames } = useExistingCustomDomains(); const createSite = useCreateSite(); const importSite = useImportSite(); - // Picked backup lives in component state — survives navigation between - // steps but not a hard refresh. If the user lands on `step=configure` - // with no picked backup, the effect below bounces them back to select. const [ picked, setPicked ] = useState< PickedBackup | null >( null ); - const [ pickError, setPickError ] = useState< string | null >( null ); const [ submitError, setSubmitError ] = useState( '' ); + const pending = useSyncExternalStore( pendingBackupSlot.subscribe, pendingBackupSlot.peek ); + // Adopt a pending hand-off whenever one arrives — including while another + // backup is already being configured, so a fresh hand-off always wins and + // the slot never holds a stale value. useEffect( () => { - if ( activeStep === 'configure' && ! picked ) { - void navigate( { - to: '/onboarding/import', - search: { step: 'select' }, - replace: true, - } ); + if ( ! pending ) { + return; } - }, [ activeStep, picked, navigate ] ); + setPicked( pending ); + pendingBackupSlot.clear(); + }, [ pending ] ); - const handlePick = useCallback( - async ( file: File ) => { - if ( ! isValidBackupFile( file ) ) { - setPickError( - __( - 'This file type is not supported. Please use a .zip, .gz, .tar, .tar.gz, or .wpress file.' - ) - ); - return; - } - const path = await connector.getFilePath( file ); - if ( ! path ) { - setPickError( - __( 'Unable to resolve the backup file path. Try choosing the file via the button.' ) - ); - return; - } - // `flushSync` commits the state updates before `navigate` fires so - // the router's URL change and React's component state land in the - // same render pass. Without this, tanstack router's store update - // commits first, the component re-renders with `activeStep` already - // at `configure` but `picked` still null, and the hard-refresh - // guard effect below immediately bounces us back to `select`. - flushSync( () => { - setPickError( null ); - setPicked( { file, path } ); - } ); - void navigate( { - to: '/onboarding/import', - search: { step: 'configure' }, - } ); - }, - [ connector, navigate ] - ); + // Landing here with nothing picked and nothing pending (hard refresh, + // direct URL, history navigation) returns to the home screen, where the + // import drop card lives. + useEffect( () => { + if ( picked || pending ) { + return; + } + void navigate( { to: '/onboarding', replace: true } ); + }, [ picked, pending, navigate ] ); - const handleClearPick = useCallback( () => { - setPicked( null ); - setPickError( null ); - }, [] ); + // The filename-derived site name can collide with an existing site + // folder; resolve an available variant ("Name", "Name 2", ...) before + // seeding the form, like the desktop renderer does. + const seededName = useSeededSiteName( picked ? nameFromFilename( picked.file.name ) : null ); - const handleBackToSelect = useCallback( () => { - void navigate( { - to: '/onboarding/import', - search: { step: 'select' }, - } ); + const handleBack = useCallback( () => { + void navigate( { to: '/onboarding' } ); }, [ navigate ] ); const handleSubmit = async ( values: CreateSiteFormValues ) => { @@ -143,6 +85,13 @@ function OnboardingImportPage() { siteId: site.id, backup: { path: picked.path, type: picked.file.type }, } ); + speak( + sprintf( + // translators: %s is the site name. + __( '%s site added.' ), + values.name + ) + ); await navigate( { to: '/sites/$siteId/new', params: { siteId: site.id } } ); } catch ( error ) { setSubmitError( @@ -151,49 +100,20 @@ function OnboardingImportPage() { } }; - if ( activeStep === 'select' ) { - return ( -
      -

      { __( 'Import from a backup' ) }

      -

      - { __( - 'Drop a backup archive to restore a site locally. Jetpack, All-in-One WP Migration, Local, and Playground exports are supported.' - ) } -

      - void handlePick( file ) } - file={ picked?.file ?? null } - onClear={ handleClearPick } - error={ pickError } - /> -
      - ); - } - - // `step=configure` with no picked backup is handled by the effect above; - // render nothing in the intermediate frame to avoid a flash. + // The bounce effect above handles the empty case; render nothing in the + // intermediate frame to avoid a flash. if ( ! picked ) return null; - const initialValues: Partial< CreateSiteFormValues > = { - name: nameFromFilename( picked.file.name ), - }; + // Hold the form until the collision-checked name resolves — initial + // values are applied once, so seeding early with a colliding name would + // lock it in. + const initialValues: Partial< CreateSiteFormValues > | undefined = seededName + ? { name: seededName } + : undefined; const isSubmitting = createSite.isPending || importSite.isPending; return (
      -

      { __( 'Configure the imported site' ) }

      { __( 'Pick a name and local folder. The backup will restore on top of this new site.' ) } @@ -202,7 +122,7 @@ function OnboardingImportPage() { initialValues={ initialValues } existingDomainNames={ existingDomainNames ?? [] } onSubmit={ handleSubmit } - onCancel={ () => void navigate( { to: '/onboarding' } ) } + onCancel={ handleBack } isSubmitting={ isSubmitting } submitError={ submitError } submitLabel={ __( 'Import site' ) } @@ -214,12 +134,5 @@ function OnboardingImportPage() { export const onboardingImportRoute = createRoute( { getParentRoute: () => onboardingLayoutRoute, path: '/onboarding/import', - validateSearch: ( search: Record< string, unknown > ): ImportSearch => { - const value = search.step; - if ( value === 'configure' || value === 'select' ) { - return { step: value }; - } - return {}; - }, component: OnboardingImportPage, } ); diff --git a/apps/ui/src/ui-classic/router/route-onboarding-import/style.module.css b/apps/ui/src/ui-classic/router/route-onboarding-import/style.module.css deleted file mode 100644 index 56615bfa21..0000000000 --- a/apps/ui/src/ui-classic/router/route-onboarding-import/style.module.css +++ /dev/null @@ -1,8 +0,0 @@ -.backLink { - display: inline-flex; - align-items: center; - gap: 4px; - align-self: flex-start; - margin-bottom: 16px; - padding: 4px 8px 4px 4px; -} diff --git a/apps/ui/src/ui-classic/router/router.tsx b/apps/ui/src/ui-classic/router/router.tsx index 28c826f134..b0e15dfa88 100644 --- a/apps/ui/src/ui-classic/router/router.tsx +++ b/apps/ui/src/ui-classic/router/router.tsx @@ -7,6 +7,7 @@ import { dashboardRoute } from './route-dashboard'; import { indexRoute } from './route-index'; import { newSessionRoute } from './route-new-session'; import { onboardingBlueprintRoute } from './route-onboarding-blueprint'; +import { onboardingConnectRoute } from './route-onboarding-connect'; import { onboardingCreateRoute } from './route-onboarding-create'; import { onboardingHomeRoute } from './route-onboarding-home'; import { onboardingImportRoute } from './route-onboarding-import'; @@ -28,6 +29,7 @@ const routeTree = rootRoute.addChildren( [ onboardingHomeRoute, onboardingCreateRoute, onboardingBlueprintRoute, + onboardingConnectRoute, onboardingImportRoute, ] ), ] ); @@ -37,6 +39,9 @@ export function createAppRouter( context: RouterContext ) { routeTree, context, defaultPreload: 'intent', + // Animate route (and step) changes with the View Transitions API — + // the fade-and-rise keyframes live in index.css. + defaultViewTransition: true, history: createPackagedRouterHistory(), } ); } diff --git a/apps/ui/src/ui-desks/app.tsx b/apps/ui/src/ui-desks/app.tsx index f8403f2c0a..d719af3a25 100644 --- a/apps/ui/src/ui-desks/app.tsx +++ b/apps/ui/src/ui-desks/app.tsx @@ -2,10 +2,14 @@ import { RouterProvider, createRouter } from '@tanstack/react-router'; import { useMemo } from 'react'; import { createPackagedRouterHistory } from '@/app/router-history'; import { + desksDashboardRedirectRoute, + desksNewSiteRedirectRoute, desksOnboardingBlueprintRoute, + desksOnboardingConnectRoute, desksOnboardingCreateRoute, desksOnboardingHomeRoute, desksOnboardingImportRoute, + desksOnboardingLayoutRoute, } from './onboarding'; import { desksRootRoute } from './router/root'; import { siteDeskRoute } from './site-desk'; @@ -16,10 +20,15 @@ const routeTree = desksRootRoute.addChildren( [ userDeskRoute, desksSiteSettingsRoute, siteDeskRoute, - desksOnboardingHomeRoute, - desksOnboardingCreateRoute, - desksOnboardingBlueprintRoute, - desksOnboardingImportRoute, + desksOnboardingLayoutRoute.addChildren( [ + desksOnboardingHomeRoute, + desksOnboardingCreateRoute, + desksOnboardingBlueprintRoute, + desksOnboardingConnectRoute, + desksOnboardingImportRoute, + ] ), + desksDashboardRedirectRoute, + desksNewSiteRedirectRoute, ] ); export function createDesksRouter() { diff --git a/apps/ui/src/ui-desks/chrome/create-menu/index.tsx b/apps/ui/src/ui-desks/chrome/create-menu/index.tsx index f1faee07e9..0f4f75f08f 100644 --- a/apps/ui/src/ui-desks/chrome/create-menu/index.tsx +++ b/apps/ui/src/ui-desks/chrome/create-menu/index.tsx @@ -80,7 +80,9 @@ export function DeskCreateMenu() { }; const openImportSite = () => { - void navigate( { to: '/onboarding/import', search: { step: 'select' } } ); + // Importing starts from the onboarding home screen's drop card — the + // import route itself only configures a backup that's already picked. + void navigate( { to: '/onboarding' } ); }; return ( diff --git a/apps/ui/src/ui-desks/onboarding/index.tsx b/apps/ui/src/ui-desks/onboarding/index.tsx index 21bb55e602..c8e0bec885 100644 --- a/apps/ui/src/ui-desks/onboarding/index.tsx +++ b/apps/ui/src/ui-desks/onboarding/index.tsx @@ -1,491 +1,71 @@ -import { ACCEPTED_IMPORT_FILE_TYPES } from '@studio/common/constants'; +import { createRoute, redirect } from '@tanstack/react-router'; +import { OnboardingShell } from '@/ui-classic/router/layout-onboarding'; import { - extractFormValuesFromBlueprint, - updateBlueprintWithFormValues, -} from '@studio/common/lib/blueprint-settings'; -import { createRoute, useNavigate } from '@tanstack/react-router'; -import { __ } from '@wordpress/i18n'; -import { arrowLeft, download } from '@wordpress/icons'; -import { useCallback, useEffect, useState } from 'react'; -import { flushSync } from 'react-dom'; -import { BlueprintSelector, type PickedBlueprint } from '@/components/blueprint-selector'; -import { CreateSiteForm } from '@/components/create-site-form'; -import { FileDropzone } from '@/components/file-dropzone'; -import { OnboardingLayout } from '@/components/onboarding-layout'; -import { useConnector } from '@/data/core'; -import { - useExistingCustomDomains, - useProposedSiteName, -} from '@/data/queries/use-create-site-helpers'; -import { useFeaturedBlueprints } from '@/data/queries/use-featured-blueprints'; -import { useImportSite } from '@/data/queries/use-import-site'; -import { useCreateSite, useSites } from '@/data/queries/use-sites'; -import { Button } from '@/ui-desks/components'; + OnboardingBlueprintPage, + validateBlueprintSearch, +} from '@/ui-classic/router/route-onboarding-blueprint'; +import { OnboardingConnectPage } from '@/ui-classic/router/route-onboarding-connect'; +import { CreateSitePage } from '@/ui-classic/router/route-onboarding-create'; +import { OnboardingHomePage } from '@/ui-classic/router/route-onboarding-home'; +import { OnboardingImportPage } from '@/ui-classic/router/route-onboarding-import'; import { desksRootRoute } from '../router/root'; -import styles from './style.module.css'; -import type { CreateSiteFormValues } from '@/components/create-site-form'; - -type Step = 'select' | 'configure'; - -interface StepSearch { - step?: Step; -} - -interface PickedBackup { - file: File; - path: string; -} - -export function DeskOnboardingHome() { - const navigate = useNavigate(); - - return ( - void navigate( { to: '/' } ) } width="wide"> -

      -

      { __( 'Start a new site' ) }

      -

      - { __( 'WordPress can power anything. What are you building?' ) } -

      -
      - - - -
      -
      - - ); -} - -export function DeskOnboardingCreate() { - const navigate = useNavigate(); - const { data: sites } = useSites(); - const { data: existingDomainNames } = useExistingCustomDomains(); - const { data: proposedName } = useProposedSiteName( sites ); - const createSite = useCreateSite(); - const [ submitError, setSubmitError ] = useState( '' ); - - const handleSubmit = async ( values: CreateSiteFormValues ) => { - setSubmitError( '' ); - try { - const site = await createSite.mutateAsync( { - name: values.name, - path: values.path, - phpVersion: values.phpVersion, - wpVersion: values.wpVersion, - customDomain: values.customDomain, - enableHttps: values.enableHttps, - adminUsername: values.adminUsername || undefined, - adminPassword: values.adminPassword || undefined, - adminEmail: values.adminEmail || undefined, - } ); - await navigate( { to: '/sites/$siteId', params: { siteId: site.id } } ); - } catch ( error ) { - setSubmitError( - error instanceof Error ? error.message : __( 'Failed to create site. Please try again.' ) - ); - } - }; - - return ( - void navigate( { to: '/' } ) }> -
      -

      { __( 'Create a new site' ) }

      -

      - { __( 'Choose a name and we\u2019ll scaffold a fresh WordPress site locally.' ) } -

      - void navigate( { to: '/onboarding' } ) } - isSubmitting={ createSite.isPending } - submitError={ submitError } - /> -
      -
      - ); -} - -export function DeskOnboardingBlueprint() { - const { step } = desksOnboardingBlueprintRoute.useSearch() as StepSearch; - const navigate = useNavigate(); - const activeStep: Step = step === 'configure' ? 'configure' : 'select'; - const featured = useFeaturedBlueprints(); - const { data: existingDomainNames } = useExistingCustomDomains(); - const createSite = useCreateSite(); - const [ picked, setPicked ] = useState< PickedBlueprint | null >( null ); - const [ submitError, setSubmitError ] = useState( '' ); - - useEffect( () => { - if ( activeStep === 'configure' && ! picked ) { - void navigate( { - to: '/onboarding/blueprint', - search: { step: 'select' }, - replace: true, - } ); - } - }, [ activeStep, picked, navigate ] ); - - const handlePick = useCallback( - ( blueprint: PickedBlueprint ) => { - flushSync( () => { - setPicked( blueprint ); - setSubmitError( '' ); - } ); - void navigate( { - to: '/onboarding/blueprint', - search: { step: 'configure' }, - } ); - }, - [ navigate ] - ); - - const handleSubmit = async ( values: CreateSiteFormValues ) => { - if ( ! picked ) return; - setSubmitError( '' ); - const mergedBlueprint = updateBlueprintWithFormValues( picked.blueprint, { - phpVersion: values.phpVersion, - wpVersion: values.wpVersion, - customDomain: values.customDomain, - enableHttps: values.enableHttps, - adminUsername: values.adminUsername, - adminPassword: values.adminPassword, - siteName: values.name, - } ); - try { - const site = await createSite.mutateAsync( { - name: values.name, - path: values.path, - phpVersion: values.phpVersion, - wpVersion: values.wpVersion, - customDomain: values.customDomain, - enableHttps: values.enableHttps, - adminUsername: values.adminUsername || undefined, - adminPassword: values.adminPassword || undefined, - adminEmail: values.adminEmail || undefined, - blueprint: { - blueprint: mergedBlueprint, - slug: picked.slug, - filePath: picked.filePath, - }, - } ); - await navigate( { to: '/sites/$siteId', params: { siteId: site.id } } ); - } catch ( error ) { - setSubmitError( - error instanceof Error - ? error.message - : __( 'Failed to create site from Blueprint. Please try again.' ) - ); - } - }; - - if ( activeStep === 'select' ) { - return ( - void navigate( { to: '/' } ) } width="wide"> -
      -

      { __( 'Start from a Blueprint' ) }

      -

      - { __( - 'Pick a featured Blueprint or drop in your own to provision plugins, content, and settings.' - ) } -

      - -
      -
      - ); - } - - if ( ! picked ) { - return null; - } - - const initialValues = mapBlueprintSettingsToFormValues( - extractFormValuesFromBlueprint( picked.blueprint ), - picked.title - ); - - return ( - void navigate( { to: '/' } ) }> -
      - -

      { picked.title }

      - { picked.excerpt &&

      { picked.excerpt }

      } - void navigate( { to: '/onboarding' } ) } - isSubmitting={ createSite.isPending } - submitError={ submitError } - submitLabel={ __( 'Create site from Blueprint' ) } - /> -
      -
      - ); -} - -export function DeskOnboardingImport() { - const { step } = desksOnboardingImportRoute.useSearch() as StepSearch; - const navigate = useNavigate(); - const connector = useConnector(); - const activeStep: Step = step === 'configure' ? 'configure' : 'select'; - const { data: existingDomainNames } = useExistingCustomDomains(); - const createSite = useCreateSite(); - const importSite = useImportSite(); - const [ picked, setPicked ] = useState< PickedBackup | null >( null ); - const [ pickError, setPickError ] = useState< string | null >( null ); - const [ submitError, setSubmitError ] = useState( '' ); - - useEffect( () => { - if ( activeStep === 'configure' && ! picked ) { - void navigate( { - to: '/onboarding/import', - search: { step: 'select' }, - replace: true, - } ); - } - }, [ activeStep, picked, navigate ] ); - - const handlePick = useCallback( - async ( file: File ) => { - if ( ! isValidBackupFile( file ) ) { - setPickError( - __( - 'This file type is not supported. Please use a .zip, .gz, .tar, .tar.gz, or .wpress file.' - ) - ); - return; - } - const path = await connector.getFilePath( file ); - if ( ! path ) { - setPickError( - __( 'Unable to resolve the backup file path. Try choosing the file via the button.' ) - ); - return; - } - flushSync( () => { - setPickError( null ); - setPicked( { file, path } ); - } ); - void navigate( { - to: '/onboarding/import', - search: { step: 'configure' }, - } ); - }, - [ connector, navigate ] - ); - - const handleClearPick = useCallback( () => { - setPicked( null ); - setPickError( null ); - }, [] ); - const handleSubmit = async ( values: CreateSiteFormValues ) => { - if ( ! picked ) return; - setSubmitError( '' ); - try { - const site = await createSite.mutateAsync( { - name: values.name, - path: values.path, - phpVersion: values.phpVersion, - wpVersion: values.wpVersion, - customDomain: values.customDomain, - enableHttps: values.enableHttps, - adminUsername: values.adminUsername || undefined, - adminPassword: values.adminPassword || undefined, - adminEmail: values.adminEmail || undefined, - } ); - await importSite.mutateAsync( { - siteId: site.id, - backup: { path: picked.path, type: picked.file.type }, - } ); - await navigate( { to: '/sites/$siteId', params: { siteId: site.id } } ); - } catch ( error ) { - setSubmitError( - error instanceof Error ? error.message : __( 'Failed to import site. Please try again.' ) - ); - } - }; +/** + * The desks surface has no site-creation flow of its own — it registers the + * classic onboarding pages under its own route tree. The two redirect + * routes below adapt the flow's exit points (`/dashboard` on close, + * `/sites/$siteId/new` after creation) to their desks equivalents. + */ - if ( activeStep === 'select' ) { - return ( - void navigate( { to: '/' } ) }> -
      -

      { __( 'Import from a backup' ) }

      -

      - { __( - 'Drop a backup archive to restore a site locally. Jetpack, All-in-One WP Migration, Local, and Playground exports are supported.' - ) } -

      - void handlePick( file ) } - file={ picked?.file ?? null } - onClear={ handleClearPick } - error={ pickError } - /> -
      -
      - ); - } - - if ( ! picked ) { - return null; - } - - const initialValues: Partial< CreateSiteFormValues > = { - name: nameFromFilename( picked.file.name ), - }; - const isSubmitting = createSite.isPending || importSite.isPending; - - return ( - void navigate( { to: '/' } ) }> -
      - -

      { __( 'Configure the imported site' ) }

      -

      - { __( 'Pick a name and local folder. The backup will restore on top of this new site.' ) } -

      - void navigate( { to: '/onboarding' } ) } - isSubmitting={ isSubmitting } - submitError={ submitError } - submitLabel={ __( 'Import site' ) } - /> -
      -
      - ); -} - -function mapBlueprintSettingsToFormValues( - settings: ReturnType< typeof extractFormValuesFromBlueprint >, - fallbackName: string -): Partial< CreateSiteFormValues > { - return { - name: settings.siteName || fallbackName, - phpVersion: settings.phpVersion, - wpVersion: settings.wpVersion, - customDomain: settings.customDomain, - enableHttps: settings.enableHttps, - adminUsername: settings.adminUsername, - adminPassword: settings.adminPassword, - }; -} - -function isValidBackupFile( file: File ): boolean { - const lower = file.name.toLowerCase(); - return ACCEPTED_IMPORT_FILE_TYPES.some( ( ext ) => lower.endsWith( ext ) ); -} - -function nameFromFilename( filename: string ): string { - const basename = filename.replace( /^.*[\\/]/, '' ); - const lower = basename.toLowerCase(); - const ext = ACCEPTED_IMPORT_FILE_TYPES.find( ( candidate ) => lower.endsWith( candidate ) ); - return ( ext ? basename.slice( 0, -ext.length ) : basename ) - .replace( /[-_](backup|export|wordpress|jetpack)(s)?$/i, '' ) - .replace( /[-_]\d{4}[-_]\d{2}[-_]\d{2}.*$/, '' ) - .replace( /[-_]+/g, ' ' ) - .trim(); -} - -function validateStepSearch( search: Record< string, unknown > ): StepSearch { - const value = search.step; - if ( value === 'configure' || value === 'select' ) { - return { step: value }; - } - return {}; -} +export const desksOnboardingLayoutRoute = createRoute( { + getParentRoute: () => desksRootRoute, + id: 'desks-onboarding-layout', + component: OnboardingShell, +} ); export const desksOnboardingHomeRoute = createRoute( { - getParentRoute: () => desksRootRoute, + getParentRoute: () => desksOnboardingLayoutRoute, path: '/onboarding', - component: DeskOnboardingHome, + component: OnboardingHomePage, } ); export const desksOnboardingCreateRoute = createRoute( { - getParentRoute: () => desksRootRoute, + getParentRoute: () => desksOnboardingLayoutRoute, path: '/onboarding/create', - component: DeskOnboardingCreate, + component: CreateSitePage, } ); export const desksOnboardingBlueprintRoute = createRoute( { - getParentRoute: () => desksRootRoute, + getParentRoute: () => desksOnboardingLayoutRoute, path: '/onboarding/blueprint', - validateSearch: validateStepSearch, - component: DeskOnboardingBlueprint, + validateSearch: validateBlueprintSearch, + component: OnboardingBlueprintPage, +} ); + +export const desksOnboardingConnectRoute = createRoute( { + getParentRoute: () => desksOnboardingLayoutRoute, + path: '/onboarding/connect', + component: OnboardingConnectPage, } ); export const desksOnboardingImportRoute = createRoute( { - getParentRoute: () => desksRootRoute, + getParentRoute: () => desksOnboardingLayoutRoute, path: '/onboarding/import', - validateSearch: validateStepSearch, - component: DeskOnboardingImport, + component: OnboardingImportPage, +} ); + +export const desksDashboardRedirectRoute = createRoute( { + getParentRoute: () => desksRootRoute, + path: '/dashboard', + beforeLoad: () => { + throw redirect( { to: '/' } ); + }, +} ); + +export const desksNewSiteRedirectRoute = createRoute( { + getParentRoute: () => desksRootRoute, + path: '/sites/$siteId/new', + beforeLoad: ( { params } ) => { + throw redirect( { to: '/sites/$siteId', params } ); + }, } ); diff --git a/apps/ui/src/ui-desks/onboarding/style.module.css b/apps/ui/src/ui-desks/onboarding/style.module.css deleted file mode 100644 index f6c171e58b..0000000000 --- a/apps/ui/src/ui-desks/onboarding/style.module.css +++ /dev/null @@ -1,56 +0,0 @@ -.page { - width: 100%; - text-align: left; -} - -.title { - margin-bottom: 8px; -} - -.subtitle { - color: var(--wpds-color-fg-content-neutral-weak, #666); - margin: 0 0 32px; -} - -.cards { - display: flex; - gap: 16px; -} - -.card { - position: relative; - display: block; - text-align: left; - padding: 24px; - width: 240px; - border: 1px solid var(--wpds-color-stroke-surface-neutral, #ddd); - border-radius: 8px; - cursor: pointer; - color: inherit; - text-decoration: none; - background: var(--wpds-color-bg-surface-neutral-strong, #fff); - font: inherit; -} - -.card:hover { - border-color: var(--wpds-color-stroke-interactive-neutral, #bbb); -} - -.cardTitle { - font-weight: 600; - margin-bottom: 8px; -} - -.cardBody { - color: var(--wpds-color-fg-content-neutral-weak, #666); - font-size: 0.875rem; -} - -.backLink { - display: inline-flex; - align-items: center; - gap: 4px; - align-self: flex-start; - margin-bottom: 16px; - padding: 4px 8px 4px 4px; -} diff --git a/apps/ui/src/ui-desks/router/root.tsx b/apps/ui/src/ui-desks/router/root.tsx index 676d93b083..75caa4686a 100644 --- a/apps/ui/src/ui-desks/router/root.tsx +++ b/apps/ui/src/ui-desks/router/root.tsx @@ -1,7 +1,16 @@ import { createRootRoute, Outlet } from '@tanstack/react-router'; +import { useAddSiteListener } from '@/hooks/use-add-site-listener'; import { validateChatsSearch } from '../chats/search'; +function DesksRootLayout() { + // Same external "add a site" entry points as the classic root: the + // File ▸ Add Site… menu item and `wp-studio://add-site` deep links land + // on the shared onboarding flow. + useAddSiteListener(); + return ; +} + export const desksRootRoute = createRootRoute< unknown >( { validateSearch: validateChatsSearch, - component: () => , + component: DesksRootLayout, } ); diff --git a/package-lock.json b/package-lock.json index 5dc525572d..d82c1dbec9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1079,6 +1079,7 @@ "@tanstack/react-query": "^5.75.5", "@tanstack/react-query-persist-client": "^5.96.2", "@tanstack/react-router": "^1.120.14", + "@wordpress/a11y": "^4.47.0", "@wordpress/api-fetch": "^7.47.0", "@wordpress/components": "^34.0.0", "@wordpress/core-data": "^7.47.0", diff --git a/tools/common/constants.ts b/tools/common/constants.ts index be6310ef86..883c3e6cf5 100644 --- a/tools/common/constants.ts +++ b/tools/common/constants.ts @@ -36,6 +36,7 @@ export const CERT_UNTRUSTED_ROOT = 'CERT_TRUST_IS_UNTRUSTED_ROOT'; // Windows AP export const DEFAULT_CUSTOM_DOMAIN_SUFFIX = '.wp.local'; // WordPress constants +export const EMPTY_SITE_PLAYGROUND_URL = 'https://playground.wordpress.net/'; export const MINIMUM_WORDPRESS_VERSION = '6.2.1' as const; // https://wordpress.github.io/wordpress-playground/blueprints/examples/#load-an-older-wordpress-version export const DEFAULT_WORDPRESS_VERSION = 'latest' as const; export const DEFAULT_PHP_VERSION: typeof RecommendedPHPVersion = RecommendedPHPVersion; diff --git a/tools/common/lib/blueprint-curation.ts b/tools/common/lib/blueprint-curation.ts new file mode 100644 index 0000000000..a1fe39f1d0 --- /dev/null +++ b/tools/common/lib/blueprint-curation.ts @@ -0,0 +1,56 @@ +/** + * Display curation for the public blueprints gallery, shared by the desktop + * renderer's Add Site flow and the apps/ui onboarding flow so the two can't + * drift. The wpcom blueprints endpoint returns raw titles/excerpts; these + * helpers rename the featured trio for display, override their excerpts, + * and pin their order. + */ + +type TranslateFn = ( text: string ) => string; + +export const FEATURED_BLUEPRINT_SLUGS: ReadonlySet< string > = new Set( [ + 'woo-shop', + 'development', + 'quick-start', +] ); + +const BLUEPRINT_DISPLAY_NAMES: Record< string, string > = { + 'Quick Start': 'WordPress.com', + Development: 'WordPress Dev', + Commerce: 'WooCommerce', +}; + +const BLUEPRINT_ORDER: Record< string, number > = { + 'Quick Start': 1, + Commerce: 2, + Development: 3, +}; + +// Takes the translate function as a parameter (rather than importing the +// global `__`) so callers using a scoped i18n instance — like the desktop +// renderer's I18nProvider — still get translated strings. +function getBlueprintExcerptOverrides( __: TranslateFn ): Record< string, string > { + return { + 'Quick Start': __( + 'A WordPress.com-like environment with Business plan plugins and themes pre-installed.' + ), + Commerce: __( + 'Create your next online store with WooCommerce and its companion plugins pre-installed.' + ), + Development: __( 'A streamlined environment for building and testing themes or plugins.' ), + }; +} + +export function curateBlueprintsForDisplay< T extends { title: string; excerpt: string } >( + blueprints: T[], + __: TranslateFn +): T[] { + const excerptOverrides = getBlueprintExcerptOverrides( __ ); + return [ ...blueprints ] + .sort( ( a, b ) => ( BLUEPRINT_ORDER[ a.title ] ?? 99 ) - ( BLUEPRINT_ORDER[ b.title ] ?? 99 ) ) + .map( ( item ) => ( { + ...item, + excerpt: excerptOverrides[ item.title ] || item.excerpt, + title: BLUEPRINT_DISPLAY_NAMES[ item.title ] || item.title, + } ) ); +} diff --git a/apps/studio/src/modules/sync/lib/environment-utils.ts b/tools/common/lib/sync/environment-utils.ts similarity index 93% rename from apps/studio/src/modules/sync/lib/environment-utils.ts rename to tools/common/lib/sync/environment-utils.ts index 204f82a251..be4dc8f41f 100644 --- a/apps/studio/src/modules/sync/lib/environment-utils.ts +++ b/tools/common/lib/sync/environment-utils.ts @@ -1,6 +1,6 @@ -import { SyncSite } from '@studio/common/types/sync'; import { __ } from '@wordpress/i18n'; import { z } from 'zod'; +import type { SyncSite } from '@studio/common/types/sync'; const EnvironmentSchema = z.enum( [ 'production', 'staging', 'development' ] ); export type EnvironmentType = z.infer< typeof EnvironmentSchema >; diff --git a/tools/common/lib/sync/mshots.ts b/tools/common/lib/sync/mshots.ts new file mode 100644 index 0000000000..89702d1d9a --- /dev/null +++ b/tools/common/lib/sync/mshots.ts @@ -0,0 +1,4 @@ +// WordPress.com screenshot service used for remote-site thumbnails. +export function getMshotUrl( siteUrl: string ): string { + return `https://s0.wp.com/mshots/v1/${ encodeURIComponent( siteUrl ) }?w=600&h=400`; +} diff --git a/tools/common/lib/sync/sync-api.ts b/tools/common/lib/sync/sync-api.ts index a81b5a17fd..2673403ad6 100644 --- a/tools/common/lib/sync/sync-api.ts +++ b/tools/common/lib/sync/sync-api.ts @@ -30,24 +30,48 @@ const SITE_FIELDS = [ 'environment_type', ].join( ',' ); -export async function fetchSyncableSites( token: string ): Promise< SyncSite[] > { +export async function fetchSyncableSites( + token: string, + options?: { connectedSiteIds?: number[] } +): Promise< SyncSite[] > { const wpcom = wpcomFactory( token, wpcomXhrRequest ); - const rawResponse = await wpcom.req.get( - { - apiNamespace: 'rest/v1.2', - path: '/me/sites', - }, - { - fields: SITE_FIELDS, - filter: 'atomic,wpcom', - options: 'created_at,wpcom_staging_blog_ids', - site_activity: 'active', + // Mirrors the desktop renderer's site-picker query (wpcomSitesApi), but + // drains every page so callers get the full account in one call — the + // unpaginated v1.2 endpoint silently returned only a subset of sites. + const PER_PAGE = 100; + const MAX_PAGES = 20; + const allSites: unknown[] = []; + + for ( let page = 1; page <= MAX_PAGES; page++ ) { + const rawResponse = await wpcom.req.get( + { + apiNamespace: 'rest/v1.3', + path: '/me/sites', + }, + { + fields: SITE_FIELDS, + filter: 'atomic,wpcom', + options: 'created_at,wpcom_staging_blog_ids,software_version', + site_activity: 'active', + include_a8c_owned: false, + page, + per_page: PER_PAGE, + } + ); + + const parsed = sitesEndpointResponseSchema.parse( rawResponse ); + allSites.push( ...parsed.sites ); + // The endpoint may clamp the requested page size, so judge "last page" + // by the server-reported per_page when it's present. + if ( parsed.sites.length < ( parsed.per_page ?? PER_PAGE ) ) { + break; } - ); + } - const parsed = sitesEndpointResponseSchema.parse( rawResponse ); - return transformSitesResponse( parsed.sites ); + return transformSitesResponse( allSites, { + connectedSiteIds: options?.connectedSiteIds, + } ); } export async function initiateBackup(