diff --git a/apps/ui/src/components/site-overview-view/cards.module.css b/apps/ui/src/components/site-overview-view/cards.module.css index ba012b05e5..6086792916 100644 --- a/apps/ui/src/components/site-overview-view/cards.module.css +++ b/apps/ui/src/components/site-overview-view/cards.module.css @@ -37,6 +37,20 @@ color: var(--wpds-color-fg-content-neutral); } +.headerActions { + display: flex; + align-items: center; + gap: var(--wpds-dimension-padding-sm); +} + +.headerMeta { + font-size: var(--wpds-typography-font-size-xs); + line-height: var(--wpds-typography-line-height-xs); + color: var(--wpds-color-fg-content-neutral-weak); + white-space: nowrap; + cursor: default; +} + .empty { margin: 0; font-size: var(--wpds-typography-font-size-sm); @@ -66,6 +80,20 @@ min-width: 0; } +.rowTitle { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-size: var(--wpds-typography-font-size-sm); + line-height: var(--wpds-typography-line-height-sm); + color: var(--wpds-color-fg-content-neutral); +} + +.rowTitleExpired { + color: var(--wpds-color-fg-content-neutral-weak); + text-decoration: line-through; +} + .rowMeta { overflow: hidden; white-space: nowrap; @@ -114,6 +142,10 @@ color: var(--wpds-color-fg-content-warning); } +.expired { + color: var(--wpds-color-fg-content-error); +} + .itemIcon { display: grid; place-items: center; diff --git a/apps/ui/src/components/site-overview-view/index.test.tsx b/apps/ui/src/components/site-overview-view/index.test.tsx index cac9618843..34c59753be 100644 --- a/apps/ui/src/components/site-overview-view/index.test.tsx +++ b/apps/ui/src/components/site-overview-view/index.test.tsx @@ -6,6 +6,7 @@ import { useAgenticFeatures } from '@/data/queries/use-agentic-features'; import { useAuthUser, useLogin } from '@/data/queries/use-auth-user'; import { useConnectedWpcomSites } from '@/data/queries/use-connected-wpcom-sites'; import { useExistingCustomDomains } from '@/data/queries/use-create-site-helpers'; +import { usePublishPreviewSite } from '@/data/queries/use-preview-site'; import { useSiteStorageUsage } from '@/data/queries/use-site-storage-usage'; import { useSiteThumbnail } from '@/data/queries/use-site-thumbnail'; import { @@ -19,6 +20,7 @@ import { useUpdateSite, useXdebugEnabledSite, } from '@/data/queries/use-sites'; +import { useSnapshots, useSnapshotUsage } from '@/data/queries/use-snapshots'; import { usePullSiteFromLive, usePushSiteToLive } from '@/data/queries/use-sync-site'; import { useUserPreferences } from '@/data/queries/use-user-preferences'; import { useWordPressVersions, useWpVersion } from '@/data/queries/use-wordpress-versions'; @@ -105,6 +107,15 @@ vi.mock( '@/data/queries/use-connected-wpcom-sites', () => ( { useConnectedWpcomSites: vi.fn(), } ) ); +vi.mock( '@/data/queries/use-preview-site', () => ( { + usePublishPreviewSite: vi.fn(), +} ) ); + +vi.mock( '@/data/queries/use-snapshots', () => ( { + useSnapshots: vi.fn(), + useSnapshotUsage: vi.fn(), +} ) ); + vi.mock( '@/data/queries/use-create-site-helpers', () => ( { useExistingCustomDomains: vi.fn(), } ) ); @@ -163,6 +174,9 @@ const useConnectorMock = vi.mocked( useConnector, { partial: true } ); const useAgenticFeaturesMock = vi.mocked( useAgenticFeatures ); const useAuthUserMock = vi.mocked( useAuthUser, { partial: true } ); const useConnectedWpcomSitesMock = vi.mocked( useConnectedWpcomSites, { partial: true } ); +const usePublishPreviewSiteMock = vi.mocked( usePublishPreviewSite, { partial: true } ); +const useSnapshotsMock = vi.mocked( useSnapshots, { partial: true } ); +const useSnapshotUsageMock = vi.mocked( useSnapshotUsage, { partial: true } ); const useLoginMock = vi.mocked( useLogin, { partial: true } ); const useExistingCustomDomainsMock = vi.mocked( useExistingCustomDomains, { partial: true } ); const useCopySiteMock = vi.mocked( useCopySite, { partial: true } ); @@ -197,6 +211,7 @@ describe( 'SiteOverviewView', () => { const copyText = vi.fn().mockResolvedValue( undefined ); const pullSiteFromLive = vi.fn(); const pushSiteToLive = vi.fn(); + const publishPreviewSite = vi.fn(); const onTabChange = vi.fn(); const connectorStub = ( openInOS = true ) => ( { @@ -244,6 +259,12 @@ describe( 'SiteOverviewView', () => { } ); useLoginMock.mockReturnValue( { isPending: false, mutate: vi.fn() } ); useConnectedWpcomSitesMock.mockReturnValue( { data: [], isLoading: false } ); + useSnapshotsMock.mockReturnValue( { data: [] } ); + useSnapshotUsageMock.mockReturnValue( { data: undefined } ); + usePublishPreviewSiteMock.mockReturnValue( { + isPending: false, + mutate: publishPreviewSite, + } ); usePullSiteFromLiveMock.mockReturnValue( { mutate: pullSiteFromLive } ); usePushSiteToLiveMock.mockReturnValue( { mutate: pushSiteToLive } ); useIsSiteSyncingMock.mockReturnValue( { push: false, pull: false } ); @@ -423,6 +444,49 @@ describe( 'SiteOverviewView', () => { ); } ); + it( 'shows preview expiry and account usage', () => { + useSnapshotsMock.mockReturnValue( { + data: [ + { + url: 'demo-preview.wp.build', + atomicSiteId: 1, + localSiteId: 'site-1', + date: Date.now() - 5 * 24 * 60 * 60 * 1000, + }, + ], + } ); + useSnapshotUsageMock.mockReturnValue( { + data: { siteCount: 2, siteLimit: 10, siteCreationBlocked: false }, + } ); + + renderView(); + + expect( screen.getByText( 'demo-preview.wp.build' ) ).toBeVisible(); + expect( screen.getByText( 'Expires in 2 days' ) ).toBeVisible(); + expect( screen.getByText( '2 of 10' ) ).toBeVisible(); + } ); + + it( 'publishes a preview site from its empty state', () => { + renderView(); + + fireEvent.click( screen.getByRole( 'button', { name: 'Publish a preview site' } ) ); + + expect( publishPreviewSite ).toHaveBeenCalledWith( { siteId: 'site-1' } ); + } ); + + it( 'explains that preview publishing requires sign-in', () => { + useAuthUserMock.mockReturnValue( { data: null } ); + + renderView(); + + expect( + screen.getByText( 'Sign in to publish a preview site and share your work.' ) + ).toBeVisible(); + expect( + screen.queryByRole( 'button', { name: 'Publish a preview site' } ) + ).not.toBeInTheDocument(); + } ); + it( 'keeps the browser action available without a cached thumbnail', () => { useSiteThumbnailMock.mockReturnValue( { data: null } ); diff --git a/apps/ui/src/components/site-overview-view/index.tsx b/apps/ui/src/components/site-overview-view/index.tsx index 1b02bcc906..73a47d2bd8 100644 --- a/apps/ui/src/components/site-overview-view/index.tsx +++ b/apps/ui/src/components/site-overview-view/index.tsx @@ -36,6 +36,7 @@ import { useTrafficLightSpace } from '@/hooks/use-traffic-light-space'; import { AboutSection } from './about-section'; import { ConnectionsSection } from './connections-section'; import { CardSectionDivider, OverviewCard } from './overview-card'; +import { PreviewSitesSection } from './preview-sites-section'; import styles from './style.module.css'; import type { SiteSettingsTabId } from '@/components/site-settings-view'; import type { SiteDetails } from '@/data/core'; @@ -272,6 +273,8 @@ function SiteOverviewBody( { + +
diff --git a/apps/ui/src/components/site-overview-view/preview-sites-section.tsx b/apps/ui/src/components/site-overview-view/preview-sites-section.tsx new file mode 100644 index 0000000000..14719d1d2a --- /dev/null +++ b/apps/ui/src/components/site-overview-view/preview-sites-section.tsx @@ -0,0 +1,220 @@ +import { DAY_MS, DEMO_SITE_EXPIRATION_DAYS } from '@studio/common/constants'; +import { __, _n, sprintf } from '@wordpress/i18n'; +import { copy, external, Icon, moreVertical, plus, update } from '@wordpress/icons'; +import { IconButton, Tooltip } from '@wordpress/ui'; +import { clsx } from 'clsx'; +import { Fragment, useMemo } from 'react'; +import * as Menu from '@/components/menu'; +import { ensureProtocol, stripProtocol } from '@/components/site-dropdown/utils'; +import { useConnector } from '@/data/core'; +import { useAuthUser } from '@/data/queries/use-auth-user'; +import { usePublishPreviewSite } from '@/data/queries/use-preview-site'; +import { useSnapshots, useSnapshotUsage } from '@/data/queries/use-snapshots'; +import { useOffline } from '@/hooks/use-offline'; +import styles from './cards.module.css'; +import { CardEmptyState, CardRows, CardSection, RowDivider } from './overview-card'; +import { RowLink } from './row-link'; +import type { SiteDetails, Snapshot } from '@/data/core'; + +const LIFETIME_MS = DEMO_SITE_EXPIRATION_DAYS * DAY_MS; + +export function PreviewSitesSection( { site }: { site: SiteDetails } ) { + const { data: authUser } = useAuthUser(); + const isOffline = useOffline(); + const { data: allSnapshots } = useSnapshots( authUser?.id ); + const { data: usage } = useSnapshotUsage( authUser?.id ); + const publishPreviewSite = usePublishPreviewSite(); + + const snapshots = useMemo( + () => + ( allSnapshots ?? [] ) + .filter( ( snapshot ) => snapshot.localSiteId === site.id ) + .sort( ( a, b ) => b.date - a.date ), + [ allSnapshots, site.id ] + ); + + const publishAction = authUser ? ( +
+ { usage && usage.siteLimit > 0 ? ( + + + { sprintf( + // translators: 1: previews in use, 2: total previews allowed. + __( '%1$d of %2$d' ), + usage.siteCount, + usage.siteLimit + ) } + + } + /> + }> + { sprintf( + // translators: 1: previews in use, 2: total previews allowed. + __( '%1$d of %2$d preview sites used on your account' ), + usage.siteCount, + usage.siteLimit + ) } + + + ) : null } + publishPreviewSite.mutate( { siteId: site.id } ) } + /> +
+ ) : null; + + return ( + + { ! authUser ? ( + + { __( 'Sign in to publish a preview site and share your work.' ) } + + ) : ! snapshots.length ? ( + + { sprintf( + // translators: %d: number of days a preview site stays online. + _n( + 'No preview site yet. Publishing one gives you a shareable link for %d day.', + 'No preview site yet. Publishing one gives you a shareable link for %d days.', + DEMO_SITE_EXPIRATION_DAYS + ), + DEMO_SITE_EXPIRATION_DAYS + ) } + + ) : ( + + { snapshots.map( ( snapshot, index ) => ( + + { index > 0 && } + + + ) ) } + + ) } + + ); +} + +function PreviewRow( { site, snapshot }: { site: SiteDetails; snapshot: Snapshot } ) { + const connector = useConnector(); + const isOffline = useOffline(); + const publishPreviewSite = usePublishPreviewSite(); + const life = describeLife( snapshot ); + const url = ensureProtocol( snapshot.url ); + + const republish = () => + publishPreviewSite.mutate( { + siteId: site.id, + existingHostname: life.expired ? undefined : stripProtocol( snapshot.url ), + } ); + + return ( +
+
+ { life.expired ? ( + + { stripProtocol( snapshot.url ) } + + ) : ( + + ) } + + { life.label } + +
+
+ { life.expired ? ( + + ) : ( + void connector.openExternalUrl( url ) } + /> + ) } + + + } + /> + + void connector.copyText( url ) }> + + { __( 'Copy link' ) } + + { ! life.expired && ( + + + { __( 'Update preview' ) } + + ) } + + +
+
+ ); +} + +function describeLife( snapshot: Snapshot ): { + label: string; + endingSoon: boolean; + expired: boolean; +} { + const remainingMs = snapshot.date + LIFETIME_MS - Date.now(); + if ( remainingMs <= 0 ) { + return { label: __( 'Expired' ), endingSoon: false, expired: true }; + } + + const remainingDays = Math.ceil( remainingMs / DAY_MS ); + return { + label: sprintf( + // translators: %d: number of days until a preview site expires. + _n( 'Expires in %d day', 'Expires in %d days', remainingDays ), + remainingDays + ), + endingSoon: remainingMs <= DAY_MS, + expired: false, + }; +}