From bae1bfb5ae026b217b07bec6a1fe699f07707d5a Mon Sep 17 00:00:00 2001 From: Shaun Andrews Date: Thu, 11 Jun 2026 16:14:38 -0400 Subject: [PATCH] Add the Connect a site onboarding flow --- .../router/route-onboarding-connect/index.tsx | 585 ++++++++++++++++++ .../route-onboarding-connect/style.module.css | 399 ++++++++++++ apps/ui/src/ui-classic/router/router.tsx | 2 + 3 files changed, 986 insertions(+) create mode 100644 apps/ui/src/ui-classic/router/route-onboarding-connect/index.tsx create mode 100644 apps/ui/src/ui-classic/router/route-onboarding-connect/style.module.css 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 ( +
+ +
+ +

+ { __( '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/router.tsx b/apps/ui/src/ui-classic/router/router.tsx index b88e3f9d79..0b075317a5 100644 --- a/apps/ui/src/ui-classic/router/router.tsx +++ b/apps/ui/src/ui-classic/router/router.tsx @@ -6,6 +6,7 @@ import { rootRoute } from './layout-root'; 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'; @@ -26,6 +27,7 @@ const routeTree = rootRoute.addChildren( [ onboardingHomeRoute, onboardingCreateRoute, onboardingBlueprintRoute, + onboardingConnectRoute, onboardingImportRoute, ] ), ] );