diff --git a/client/a8c-for-agencies/components/a4a-migration-offer-v2/migration-contact-support-form/index.tsx b/client/a8c-for-agencies/components/a4a-migration-offer-v2/migration-contact-support-form/index.tsx index 7fa2ebd5dde36..0b662bfd6a2c3 100644 --- a/client/a8c-for-agencies/components/a4a-migration-offer-v2/migration-contact-support-form/index.tsx +++ b/client/a8c-for-agencies/components/a4a-migration-offer-v2/migration-contact-support-form/index.tsx @@ -124,6 +124,7 @@ export default function MigrationContactSupportForm( { show, onClose }: Props ) name, email, product, + agency_id: agency?.id, no_of_sites: site, ...( pressableContactType && { contact_type: pressableContactType } ), ...( pressable_id && { pressable_id } ), diff --git a/client/a8c-for-agencies/components/agency-site-tag/index.tsx b/client/a8c-for-agencies/components/agency-site-tag/index.tsx index 8ae012ad32fca..b74eb32d2b834 100644 --- a/client/a8c-for-agencies/components/agency-site-tag/index.tsx +++ b/client/a8c-for-agencies/components/agency-site-tag/index.tsx @@ -5,17 +5,20 @@ import './style.scss'; interface Props { tag: string; onRemoveTag: ( tag: string ) => void; + isRemovable?: boolean; } -export default function AgencySiteTag( { tag, onRemoveTag }: Props ) { +export default function AgencySiteTag( { tag, onRemoveTag, isRemovable = true }: Props ) { return ( { tag } - onRemoveTag( tag ) } - icon={ closeSmall } - /> + { isRemovable && ( + onRemoveTag( tag ) } + icon={ closeSmall } + /> + ) } ); } diff --git a/client/a8c-for-agencies/components/agency-site-tags/index.tsx b/client/a8c-for-agencies/components/agency-site-tags/index.tsx index b73f771bfb024..9a989a4e78955 100644 --- a/client/a8c-for-agencies/components/agency-site-tags/index.tsx +++ b/client/a8c-for-agencies/components/agency-site-tags/index.tsx @@ -63,8 +63,13 @@ export default function AgencySiteTags( { tags, isLoading, onAddTags, onRemoveTa { tags.length ? ( { tags.map( ( tag ) => ( -
  • - +
  • +
  • ) ) }
    diff --git a/client/a8c-for-agencies/components/user-contact-support-modal-form/index.tsx b/client/a8c-for-agencies/components/user-contact-support-modal-form/index.tsx index 6325e64dae0af..8d3f7007eff91 100644 --- a/client/a8c-for-agencies/components/user-contact-support-modal-form/index.tsx +++ b/client/a8c-for-agencies/components/user-contact-support-modal-form/index.tsx @@ -130,6 +130,7 @@ export default function UserContactSupportModalForm( { name, email, product, + agency_id: agency?.id, ...( site && { site } ), ...( pressableContactType && { contact_type: pressableContactType } ), ...( pressable_id && { pressable_id } ), diff --git a/client/a8c-for-agencies/data/support/types.ts b/client/a8c-for-agencies/data/support/types.ts index eab863fa37d33..4f03707309920 100644 --- a/client/a8c-for-agencies/data/support/types.ts +++ b/client/a8c-for-agencies/data/support/types.ts @@ -10,6 +10,7 @@ export interface SubmitContactSupportParams { email: string; message: string; product: string; + agency_id: number | undefined; site?: string; no_of_sites?: number; contact_type?: string; diff --git a/client/a8c-for-agencies/data/support/use-submit-support-form-mutation.ts b/client/a8c-for-agencies/data/support/use-submit-support-form-mutation.ts index e12d3ce12c90f..d182a69ed2343 100644 --- a/client/a8c-for-agencies/data/support/use-submit-support-form-mutation.ts +++ b/client/a8c-for-agencies/data/support/use-submit-support-form-mutation.ts @@ -9,7 +9,7 @@ interface APIResponse { function mutationSubmitSupportForm( params: SubmitContactSupportParams ): Promise< APIResponse > { let path = '/agency/help/zendesk/create-ticket'; - if ( params.product === 'pressable' ) { + if ( params.product === 'pressable' && params.contact_type === 'support' ) { path = '/agency/help/pressable/support'; } diff --git a/client/a8c-for-agencies/data/team/use-transfer-ownership.ts b/client/a8c-for-agencies/data/team/use-transfer-ownership.ts new file mode 100644 index 0000000000000..2314c3a4ff86e --- /dev/null +++ b/client/a8c-for-agencies/data/team/use-transfer-ownership.ts @@ -0,0 +1,44 @@ +import { useMutation, UseMutationOptions, UseMutationResult } from '@tanstack/react-query'; +import wpcom from 'calypso/lib/wp'; +import { useSelector } from 'calypso/state'; +import { getActiveAgencyId } from 'calypso/state/a8c-for-agencies/agency/selectors'; + +interface APIError { + status: number; + code: string | null; + message: string; +} + +export interface Params { + id: number; +} + +interface APIResponse { + success: boolean; +} + +function transferOwnershipMutation( params: Params, agencyId?: number ): Promise< APIResponse > { + if ( ! agencyId ) { + throw new Error( 'Agency ID is required to transfer ownership' ); + } + + return wpcom.req.post( { + apiNamespace: 'wpcom/v2', + path: `/agency/${ agencyId }/transfer-ownership`, + method: 'PUT', + body: { + new_owner_id: params.id, + }, + } ); +} + +export default function useTransferOwnershipMutation< TContext = unknown >( + options?: UseMutationOptions< APIResponse, APIError, Params, TContext > +): UseMutationResult< APIResponse, APIError, Params, TContext > { + const agencyId = useSelector( getActiveAgencyId ); + + return useMutation< APIResponse, APIError, Params, TContext >( { + ...options, + mutationFn: ( args ) => transferOwnershipMutation( args, agencyId ), + } ); +} diff --git a/client/a8c-for-agencies/hooks/use-update-tags-for-sites.ts b/client/a8c-for-agencies/hooks/use-update-tags-for-sites.ts new file mode 100644 index 0000000000000..818f9c013a54e --- /dev/null +++ b/client/a8c-for-agencies/hooks/use-update-tags-for-sites.ts @@ -0,0 +1,42 @@ +import { useMutation, UseMutationOptions, UseMutationResult } from '@tanstack/react-query'; +import SiteTag from 'calypso/a8c-for-agencies/types/site-tag'; +import wpcom from 'calypso/lib/wp'; +import { useSelector } from 'calypso/state'; +import { getActiveAgencyId } from 'calypso/state/a8c-for-agencies/agency/selectors'; +import { APIError } from 'calypso/state/partner-portal/types'; + +interface UpdateTagsForSitesMutationOptions { + siteIds: number[]; + tags: string[]; +} + +function mutationUpdateSiteTags( { + agencyId, + siteIds, + tags, +}: UpdateTagsForSitesMutationOptions & { agencyId: number | undefined } ): Promise< SiteTag[] > { + if ( ! agencyId ) { + throw new Error( 'Agency ID is required to update the tags' ); + } + + return wpcom.req.put( { + method: 'PUT', + apiNamespace: 'wpcom/v2', + path: `/agency/${ agencyId }/sites/tags`, + body: { + tags, + site_ids: siteIds, + }, + } ); +} + +export default function useUpdateTagsForSitesMutation< TContext = unknown >( + options?: UseMutationOptions< SiteTag[], APIError, UpdateTagsForSitesMutationOptions, TContext > +): UseMutationResult< SiteTag[], APIError, UpdateTagsForSitesMutationOptions, TContext > { + const agencyId = useSelector( getActiveAgencyId ); + + return useMutation< SiteTag[], APIError, UpdateTagsForSitesMutationOptions, TContext >( { + ...options, + mutationFn: ( args ) => mutationUpdateSiteTags( { ...args, agencyId } ), + } ); +} diff --git a/client/a8c-for-agencies/sections/migrations/commissions-list/commission-columns.tsx b/client/a8c-for-agencies/sections/migrations/commissions-list/commission-columns.tsx index c38b8cb80e34d..ed55452d6094d 100644 --- a/client/a8c-for-agencies/sections/migrations/commissions-list/commission-columns.tsx +++ b/client/a8c-for-agencies/sections/migrations/commissions-list/commission-columns.tsx @@ -2,22 +2,20 @@ import { BadgeType } from '@automattic/components'; import { useTranslate } from 'i18n-calypso'; import StatusBadge from 'calypso/a8c-for-agencies/components/step-section-item/status-badge'; import FormattedDate from 'calypso/components/formatted-date'; +import { urlToSlug } from 'calypso/lib/url/http-utils'; const DETAILS_DATE_FORMAT_SHORT = 'DD MMM YYYY'; export const SiteColumn = ( { site }: { site: string } ) => { - return site; + return urlToSlug( site ); }; -export const MigratedOnColumn = ( { migratedOn }: { migratedOn: Date } ) => { - return ; +export const MigratedOnColumn = ( { migratedOn }: { migratedOn: number } ) => { + const date = new Date( migratedOn * 1000 ); + return ; }; -export const ReviewStatusColumn = ( { - reviewStatus, -}: { - reviewStatus: 'confirmed' | 'pending' | 'rejected'; -} ) => { +export const ReviewStatusColumn = ( { reviewStatus }: { reviewStatus: string } ) => { const translate = useTranslate(); const getStatusProps = () => { @@ -27,16 +25,16 @@ export const ReviewStatusColumn = ( { statusText: translate( 'Confirmed' ), statusType: 'success', }; - case 'pending': - return { - statusText: translate( 'Pending' ), - statusType: 'warning', - }; case 'rejected': return { statusText: translate( 'Rejected' ), statusType: 'error', }; + default: + return { + statusText: translate( 'Pending' ), + statusType: 'warning', + }; } }; diff --git a/client/a8c-for-agencies/sections/migrations/commissions-list/index.tsx b/client/a8c-for-agencies/sections/migrations/commissions-list/index.tsx index 4c16ccfedbcde..cd8d634978c51 100644 --- a/client/a8c-for-agencies/sections/migrations/commissions-list/index.tsx +++ b/client/a8c-for-agencies/sections/migrations/commissions-list/index.tsx @@ -6,14 +6,10 @@ import ItemsDataViews from 'calypso/a8c-for-agencies/components/items-dashboard/ import { DataViewsState } from 'calypso/a8c-for-agencies/components/items-dashboard/items-dataviews/interfaces'; import { MigratedOnColumn, ReviewStatusColumn, SiteColumn } from './commission-columns'; import MigrationsCommissionsListMobileView from './mobile-view'; -import type { MigrationCommissionItem } from '../types'; +import type { TaggedSite } from '../types'; import type { Field } from '@wordpress/dataviews'; -export default function MigrationsCommissionsList( { - items, -}: { - items: MigrationCommissionItem[]; -} ) { +export default function MigrationsCommissionsList( { items }: { items: TaggedSite[] } ) { const translate = useTranslate(); const isDesktop = useDesktopBreakpoint(); @@ -33,15 +29,17 @@ export default function MigrationsCommissionsList( { id: 'site', label: translate( 'Site' ).toUpperCase(), getValue: () => '-', - render: ( { item } ): ReactNode => , + render: ( { item }: { item: TaggedSite } ): ReactNode => , enableHiding: false, enableSorting: false, }, { id: 'migratedOn', - label: translate( 'Migrated on' ).toUpperCase(), + // FIXME: This should be "Migrated on" instead of "Date Added" + // We will change this when the MC tool is implemented and we have the migration date + label: translate( 'Date Added' ).toUpperCase(), getValue: () => '-', - render: ( { item } ): ReactNode => , + render: ( { item } ): ReactNode => , enableHiding: false, enableSorting: false, }, @@ -49,9 +47,7 @@ export default function MigrationsCommissionsList( { id: 'reviewStatus', label: translate( 'Review status' ).toUpperCase(), getValue: () => '-', - render: ( { item } ): ReactNode => ( - - ), + render: ( { item } ): ReactNode => , enableHiding: false, enableSorting: false, }, diff --git a/client/a8c-for-agencies/sections/migrations/commissions-list/mobile-view.tsx b/client/a8c-for-agencies/sections/migrations/commissions-list/mobile-view.tsx index a808392488837..39a9d28232b22 100644 --- a/client/a8c-for-agencies/sections/migrations/commissions-list/mobile-view.tsx +++ b/client/a8c-for-agencies/sections/migrations/commissions-list/mobile-view.tsx @@ -5,14 +5,14 @@ import { ListItemCardContent, } from 'calypso/a8c-for-agencies/components/list-item-cards'; import { MigratedOnColumn, ReviewStatusColumn, SiteColumn } from './commission-columns'; -import type { MigrationCommissionItem } from '../types'; +import type { TaggedSite } from '../types'; import './style.scss'; export default function MigrationsCommissionsListMobileView( { commissions, }: { - commissions: MigrationCommissionItem[]; + commissions: TaggedSite[]; } ) { const translate = useTranslate(); @@ -23,16 +23,16 @@ export default function MigrationsCommissionsListMobileView( {
    - +
    - +
    - +
    ) ) } diff --git a/client/a8c-for-agencies/sections/migrations/consolidated-commissions/index.tsx b/client/a8c-for-agencies/sections/migrations/consolidated-commissions/index.tsx index ced423c3a9d7a..48521ea72485d 100644 --- a/client/a8c-for-agencies/sections/migrations/consolidated-commissions/index.tsx +++ b/client/a8c-for-agencies/sections/migrations/consolidated-commissions/index.tsx @@ -1,6 +1,6 @@ import { Card } from '@automattic/components'; import { useTranslate } from 'i18n-calypso'; -import type { MigrationCommissionItem } from '../types'; +import type { TaggedSite } from '../types'; import './style.scss'; @@ -9,21 +9,17 @@ const getQuarter = ( date = new Date() ) => { return Math.ceil( ( currentMonth + 1 ) / 3 ); }; -export default function MigrationsConsolidatedCommissions( { - items, -}: { - items: MigrationCommissionItem[]; -} ) { +export default function MigrationsConsolidatedCommissions( { items }: { items: TaggedSite[] } ) { const translate = useTranslate(); const migrationCommissions = items.filter( ( item ) => // Consider only confirmed migrations for the current quarter - item.reviewStatus === 'confirmed' && getQuarter( item.migratedOn ) === getQuarter() - ).length * 100; // FIXME: Consider the maximum commission value + item.state === 'confirmed' && getQuarter( new Date( item.created_at ) ) === getQuarter() + ).length * 100; // FIXME: Consider the maximum commission value when the MC tool is implemented - const sitesPendingReview = items.filter( ( item ) => item.reviewStatus === 'pending' ).length; + const sitesPendingReview = items.filter( ( item ) => item.state !== 'confirmed' ).length; const currentQuarter = getQuarter(); diff --git a/client/a8c-for-agencies/sections/migrations/hooks/use-fetch-all-managed-sites.ts b/client/a8c-for-agencies/sections/migrations/hooks/use-fetch-all-managed-sites.ts index ce65fa2ba6494..c31b3d05da222 100644 --- a/client/a8c-for-agencies/sections/migrations/hooks/use-fetch-all-managed-sites.ts +++ b/client/a8c-for-agencies/sections/migrations/hooks/use-fetch-all-managed-sites.ts @@ -46,7 +46,7 @@ export const useFetchAllManagedSites = () => { const foundSite = sites.find( ( s ) => s?.ID === site.blog_id ); return foundSite ? { - id: site.blog_id, + id: site.a4a_site_id, site: urlToSlug( site.url ), date: foundSite.options?.created_at || '', } diff --git a/client/a8c-for-agencies/sections/migrations/hooks/use-fetch-migration-commissions.ts b/client/a8c-for-agencies/sections/migrations/hooks/use-fetch-migration-commissions.ts deleted file mode 100644 index 2fd01905fca37..0000000000000 --- a/client/a8c-for-agencies/sections/migrations/hooks/use-fetch-migration-commissions.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; -import wpcom from 'calypso/lib/wp'; -import { useSelector } from 'calypso/state'; -import { getActiveAgencyId } from 'calypso/state/a8c-for-agencies/agency/selectors'; -import type { MigrationCommissionItem } from '../types'; - -export const getMigrationCommissionsQueryKey = ( agencyId?: number ) => { - return [ 'a4a-migration-commissions', agencyId ]; -}; - -// FIXME: Replace 'any' with the correct type once the API is implemented -const getMigrationCommissions = ( commissions: any ): MigrationCommissionItem[] => { - return commissions.map( ( commission: any ) => { - return { - id: commission.blog_id, - siteUrl: commission.site_url, - migratedOn: new Date( commission.migrated_on ), - reviewStatus: commission.review_status, - }; - } ); -}; - -export default function useFetchMigrationCommissions() { - const agencyId = useSelector( getActiveAgencyId ); - - const isAPIEnabled = false; // Remove this line once the API is implemented - - const data = useQuery( { - // Since, we will be removing the hardcoded data, we need not pass the isAPIEnabled as a dependency - // eslint-disable-next-line @tanstack/query/exhaustive-deps - queryKey: getMigrationCommissionsQueryKey( agencyId ), - queryFn: () => - isAPIEnabled - ? wpcom.req.get( { - apiNamespace: 'wpcom/v2', - path: `/agency/${ agencyId }/migrations/commissions`, // FIXME: Replace with the correct API path - } ) - : [ - { - blog_id: 1, - site_url: 'site1.com', - migrated_on: '2024-10-14 13:14:22', - review_status: 'confirmed', - }, - { - blog_id: 2, - site_url: 'site4.com', - migrated_on: '2024-06-14 13:14:22', - review_status: 'confirmed', - }, - { - blog_id: 3, - site_url: 'site2.com', - migrated_on: '2024-06-14 13:14:22', - review_status: 'pending', - }, - { - blog_id: 4, - site_url: 'site2.com', - migrated_on: '2024-06-14 13:14:22', - review_status: 'pending', - }, - { - blog_id: 5, - site_url: 'site5.com', - migrated_on: '2024-06-14 13:14:22', - review_status: 'rejected', - }, - ], // Remove this line once the API is implemented - enabled: !! agencyId, - refetchOnWindowFocus: false, - select: getMigrationCommissions, - } ); - - return data; -} diff --git a/client/a8c-for-agencies/sections/migrations/hooks/use-fetch-tagged-sites-for-migration.ts b/client/a8c-for-agencies/sections/migrations/hooks/use-fetch-tagged-sites-for-migration.ts new file mode 100644 index 0000000000000..d4bda83eef471 --- /dev/null +++ b/client/a8c-for-agencies/sections/migrations/hooks/use-fetch-tagged-sites-for-migration.ts @@ -0,0 +1,26 @@ +import { useQuery } from '@tanstack/react-query'; +import wpcom from 'calypso/lib/wp'; +import { useSelector } from 'calypso/state'; +import { getActiveAgencyId } from 'calypso/state/a8c-for-agencies/agency/selectors'; +import { A4A_MIGRATED_SITE_TAG } from '../lib/constants'; + +export default function useFetchTaggedSitesForMigration() { + const agencyId = useSelector( getActiveAgencyId ); + + const data = useQuery( { + queryKey: [ 'a4a-migration-commissions', agencyId ], + queryFn: () => + wpcom.req.get( + { + apiNamespace: 'wpcom/v2', + path: `/agency/${ agencyId }/sites`, + }, + { + filters: { tags: A4A_MIGRATED_SITE_TAG }, + } + ), + refetchOnWindowFocus: false, + } ); + + return data; +} diff --git a/client/a8c-for-agencies/sections/migrations/lib/constants.ts b/client/a8c-for-agencies/sections/migrations/lib/constants.ts new file mode 100644 index 0000000000000..721d574239f78 --- /dev/null +++ b/client/a8c-for-agencies/sections/migrations/lib/constants.ts @@ -0,0 +1 @@ +export const A4A_MIGRATED_SITE_TAG = 'a4a_self_migrated_site'; diff --git a/client/a8c-for-agencies/sections/migrations/primary/migrations-commissions/index.tsx b/client/a8c-for-agencies/sections/migrations/primary/migrations-commissions/index.tsx index 93512e7066141..37be0b01fea5b 100644 --- a/client/a8c-for-agencies/sections/migrations/primary/migrations-commissions/index.tsx +++ b/client/a8c-for-agencies/sections/migrations/primary/migrations-commissions/index.tsx @@ -16,7 +16,7 @@ import { useDispatch } from 'calypso/state'; import { recordTracksEvent } from 'calypso/state/analytics/actions'; import MigrationsCommissionsList from '../../commissions-list'; import MigrationsConsolidatedCommissions from '../../consolidated-commissions'; -import useFetchMigrationCommissions from '../../hooks/use-fetch-migration-commissions'; +import useFetchTaggedSitesForMigration from '../../hooks/use-fetch-tagged-sites-for-migration'; import MigrationsTagSitesModal from '../../tag-sites-modal'; import MigrationsCommissionsEmptyState from './empty-state'; @@ -35,12 +35,16 @@ export default function MigrationsCommissions() { setShowAddSitesModal( true ); }, [ dispatch ] ); - const { data: migrationCommissions, isFetched } = useFetchMigrationCommissions(); + const { + data: taggedSites, + isLoading, + refetch: fetchMigratedSites, + } = useFetchTaggedSitesForMigration(); - const showEmptyState = ! migrationCommissions?.length; + const showEmptyState = ! taggedSites?.length; const content = useMemo( () => { - if ( ! isFetched ) { + if ( isLoading ) { return ( <> @@ -53,11 +57,11 @@ export default function MigrationsCommissions() { ) : (
    - - + +
    ); - }, [ isFetched, showEmptyState, migrationCommissions, setShowAddSitesModal ] ); + }, [ isLoading, showEmptyState, taggedSites, setShowAddSitesModal ] ); return ( { content } { showAddSitesModal && ( - setShowAddSitesModal( false ) } /> + setShowAddSitesModal( false ) } + taggedSites={ taggedSites } + fetchMigratedSites={ fetchMigratedSites } + /> ) } diff --git a/client/a8c-for-agencies/sections/migrations/primary/migrations-overview-v2/sections/migrations-faqs/index.tsx b/client/a8c-for-agencies/sections/migrations/primary/migrations-overview-v2/sections/migrations-faqs/index.tsx index c8cdc31a2e17e..f8ad43b43c224 100644 --- a/client/a8c-for-agencies/sections/migrations/primary/migrations-overview-v2/sections/migrations-faqs/index.tsx +++ b/client/a8c-for-agencies/sections/migrations/primary/migrations-overview-v2/sections/migrations-faqs/index.tsx @@ -1,7 +1,11 @@ +import config from '@automattic/calypso-config'; import { useTranslate } from 'i18n-calypso'; import { useCallback } from 'react'; import PageSection from 'calypso/a8c-for-agencies/components/page-section'; -import { A4A_REFERRALS_PAYMENT_SETTINGS } from 'calypso/a8c-for-agencies/components/sidebar-menu/lib/constants'; +import { + A4A_MIGRATIONS_PAYMENT_SETTINGS, + A4A_REFERRALS_PAYMENT_SETTINGS, +} from 'calypso/a8c-for-agencies/components/sidebar-menu/lib/constants'; import FoldableFAQ from 'calypso/components/foldable-faq'; import { useDispatch } from 'calypso/state'; import { recordTracksEvent } from 'calypso/state/analytics/actions'; @@ -126,7 +130,11 @@ export default function MigrationsFAQs() { ), PaymentSettingLink: ( dispatch( recordTracksEvent( 'calypso_a4a_migrations_payment_setting_link_click' ) diff --git a/client/a8c-for-agencies/sections/migrations/tag-sites-modal/add-sites-table.tsx b/client/a8c-for-agencies/sections/migrations/tag-sites-modal/add-sites-table.tsx index 7a711a3923ccf..1f277160cad2b 100644 --- a/client/a8c-for-agencies/sections/migrations/tag-sites-modal/add-sites-table.tsx +++ b/client/a8c-for-agencies/sections/migrations/tag-sites-modal/add-sites-table.tsx @@ -9,6 +9,7 @@ import ItemsDataViews from 'calypso/a8c-for-agencies/components/items-dashboard/ import { useDispatch } from 'calypso/state'; import { recordTracksEvent } from 'calypso/state/analytics/actions'; import { useFetchAllManagedSites } from '../hooks/use-fetch-all-managed-sites'; +import { TaggedSite } from '../types'; export type SiteItem = { id: number; @@ -19,9 +20,11 @@ export type SiteItem = { export default function MigrationsAddSitesTable( { selectedSites, setSelectedSites, + taggedSites, }: { - selectedSites: number[]; - setSelectedSites: ( sites: number[] ) => void; + selectedSites: SiteItem[]; + setSelectedSites: ( sites: SiteItem[] ) => void; + taggedSites?: TaggedSite[]; } ) { const translate = useTranslate(); const isDesktop = useDesktopBreakpoint(); @@ -29,24 +32,34 @@ export default function MigrationsAddSitesTable( { const { items, isLoading } = useFetchAllManagedSites(); + const taggedSitesIds = useMemo( + () => taggedSites?.map( ( site ) => site.id ) || [], + [ taggedSites ] + ); + + // Filter out sites that are already tagged + const availableSites = useMemo( () => { + return items.filter( ( item ) => ! taggedSitesIds.includes( item.id ) ); + }, [ items, taggedSitesIds ] ); + const [ dataViewsState, setDataViewsState ] = useState( initialDataViewsState ); const onSelectAllSites = useCallback( () => { - const isAllSitesSelected = selectedSites.length === items.length; - setSelectedSites( isAllSitesSelected ? [] : items.map( ( item ) => item.id ) ); + const isAllSitesSelected = selectedSites.length === availableSites.length; + setSelectedSites( isAllSitesSelected ? [] : availableSites ); dispatch( recordTracksEvent( 'calypso_a8c_migrations_tag_sites_modal_select_all_sites_click', { type: isAllSitesSelected ? 'deselect' : 'select', } ) ); - }, [ dispatch, items, selectedSites.length, setSelectedSites ] ); + }, [ dispatch, availableSites, selectedSites.length, setSelectedSites ] ); const onSelectSite = useCallback( ( checked: boolean, item: SiteItem ) => { if ( checked ) { - setSelectedSites( [ ...selectedSites, item.id ] ); + setSelectedSites( [ ...selectedSites, item ] ); } else { - setSelectedSites( selectedSites.filter( ( id ) => id !== item.id ) ); + setSelectedSites( selectedSites.filter( ( site ) => site.id !== item.id ) ); } dispatch( recordTracksEvent( 'calypso_a8c_migrations_tag_sites_modal_select_site_click', { @@ -64,7 +77,7 @@ export default function MigrationsAddSitesTable( {
    @@ -76,7 +89,7 @@ export default function MigrationsAddSitesTable( { className="view-details-button" data-site-id={ item.id } label={ item.site } - checked={ selectedSites.includes( item.id ) } + checked={ selectedSites.map( ( site ) => site.id ).includes( item.id ) } onChange={ ( checked ) => onSelectSite( checked, item ) } disabled={ false } /> @@ -95,11 +108,18 @@ export default function MigrationsAddSitesTable( { }; return isDesktop ? [ siteColumn, dateColumn ] : [ siteColumn ]; - }, [ isDesktop, items.length, onSelectAllSites, onSelectSite, selectedSites, translate ] ); + }, [ + isDesktop, + availableSites.length, + onSelectAllSites, + onSelectSite, + selectedSites, + translate, + ] ); const { data: allSites, paginationInfo } = useMemo( () => { - return filterSortAndPaginate( items, dataViewsState, fields ); - }, [ items, dataViewsState, fields ] ); + return filterSortAndPaginate( availableSites, dataViewsState, fields ); + }, [ availableSites, dataViewsState, fields ] ); return (
    diff --git a/client/a8c-for-agencies/sections/migrations/tag-sites-modal/index.tsx b/client/a8c-for-agencies/sections/migrations/tag-sites-modal/index.tsx index 721d622325258..f4cca4753c804 100644 --- a/client/a8c-for-agencies/sections/migrations/tag-sites-modal/index.tsx +++ b/client/a8c-for-agencies/sections/migrations/tag-sites-modal/index.tsx @@ -6,18 +6,72 @@ import A4AModal from 'calypso/a8c-for-agencies/components/a4a-modal'; import { preventWidows } from 'calypso/lib/formatting'; import { useDispatch } from 'calypso/state'; import { recordTracksEvent } from 'calypso/state/analytics/actions'; -import MigrationsAddSitesTable from './add-sites-table'; +import { errorNotice, successNotice } from 'calypso/state/notices/actions'; +import useUpdateTagsForSitesMutation from '../../../hooks/use-update-tags-for-sites'; +import { A4A_MIGRATED_SITE_TAG } from '../lib/constants'; +import MigrationsAddSitesTable, { SiteItem } from './add-sites-table'; +import type { TaggedSite } from '../types'; import './style.scss'; -export default function MigrationsTagSitesModal( { onClose }: { onClose: () => void } ) { +export default function MigrationsTagSitesModal( { + onClose, + taggedSites, + fetchMigratedSites, +}: { + onClose: () => void; + taggedSites?: TaggedSite[]; + fetchMigratedSites: () => void; +} ) { const translate = useTranslate(); const dispatch = useDispatch(); - const [ selectedSites, setSelectedSites ] = useState< number[] | [] >( [] ); + const { mutate: tagSitesForMigration, isPending } = useUpdateTagsForSitesMutation(); + + const [ selectedSites, setSelectedSites ] = useState< SiteItem[] | [] >( [] ); const handleAddSites = () => { - // TODO: Implement this + tagSitesForMigration( + { + siteIds: selectedSites.map( ( site ) => site.id ), + tags: [ A4A_MIGRATED_SITE_TAG ], + }, + { + onSuccess: () => { + // Refetch the sites to update the UI + fetchMigratedSites(); + dispatch( + recordTracksEvent( 'calypso_a8c_migrations_tag_sites_modal_add_sites_success', { + count: selectedSites.length, + } ) + ); + const hasSingleSite = selectedSites.length === 1; + const siteUrl = hasSingleSite ? selectedSites[ 0 ].site : ''; + dispatch( + hasSingleSite + ? successNotice( + translate( + 'The site {{strong}}%(siteUrl)s{{/strong}} has been successfully tagged for commission.', + { + components: { strong: }, + args: { siteUrl }, + } + ) + ) + : successNotice( + translate( '%(count)s sites have been successfully tagged for commission.', { + args: { count: selectedSites.length }, + comment: '%(count)s is the number of sites tagged.', + } ) + ) + ); + onClose(); + }, + onError: ( error ) => { + dispatch( errorNotice( error.message ) ); + }, + } + ); dispatch( recordTracksEvent( 'calypso_a8c_migrations_tag_sites_modal_add_sites_click', { count: selectedSites.length, @@ -34,7 +88,12 @@ export default function MigrationsTagSitesModal( { onClose }: { onClose: () => v +
    diff --git a/client/a8c-for-agencies/sections/migrations/types.ts b/client/a8c-for-agencies/sections/migrations/types.ts index ace6e48dee765..88e35c39c5710 100644 --- a/client/a8c-for-agencies/sections/migrations/types.ts +++ b/client/a8c-for-agencies/sections/migrations/types.ts @@ -1,10 +1,7 @@ -export interface MigrationCommissionItem { +export interface TaggedSite { id: number; - siteUrl: string; - migratedOn: Date; - reviewStatus: 'confirmed' | 'pending' | 'rejected'; -} - -export interface MigrationCommissionAPIResponse { - // TODO: Define the API response shape + blog_id: number; + created_at: number; + url: string; + state: string; } diff --git a/client/a8c-for-agencies/sections/team/hooks/use-handle-member-action.ts b/client/a8c-for-agencies/sections/team/hooks/use-handle-member-action.ts index 3145a8f23c439..2e0c18a16fa1b 100644 --- a/client/a8c-for-agencies/sections/team/hooks/use-handle-member-action.ts +++ b/client/a8c-for-agencies/sections/team/hooks/use-handle-member-action.ts @@ -4,6 +4,7 @@ import { useCallback } from 'react'; import useCancelMemberInviteMutation from 'calypso/a8c-for-agencies/data/team/use-cancel-member-invite'; import useRemoveMemberMutation from 'calypso/a8c-for-agencies/data/team/use-remove-member'; import useResendMemberInviteMutation from 'calypso/a8c-for-agencies/data/team/use-resend-member-invite'; +import useTransferOwnershipMutation from 'calypso/a8c-for-agencies/data/team/use-transfer-ownership'; import { useDispatch, useSelector } from 'calypso/state'; import { errorNotice, successNotice } from 'calypso/state/notices/actions'; import { TeamMember } from '../types'; @@ -22,6 +23,8 @@ export default function useHandleMemberAction( { onRefetchList }: Props ) { const { mutate: removeMember } = useRemoveMemberMutation(); + const { mutate: transferOwnership } = useTransferOwnershipMutation(); + const currentUser = useSelector( getCurrentUser ); return useCallback( @@ -114,6 +117,34 @@ export default function useHandleMemberAction( { onRefetchList }: Props ) { } ); } + + if ( action === 'transfer-ownership' ) { + transferOwnership( + { id: item.id }, + { + onSuccess: () => { + dispatch( + successNotice( translate( 'Ownership has been successfully transferred.' ), { + id: 'transfer-ownership-success', + duration: 5000, + } ) + ); + onRefetchList?.(); + callback?.(); + }, + + onError: ( error ) => { + dispatch( + errorNotice( error.message, { + id: 'transfer-ownership-error', + duration: 5000, + } ) + ); + callback?.(); + }, + } + ); + } }, [ cancelMemberInvite, @@ -123,6 +154,7 @@ export default function useHandleMemberAction( { onRefetchList }: Props ) { removeMember, resendMemberInvite, translate, + transferOwnership, ] ); } diff --git a/client/a8c-for-agencies/sections/team/primary/team-list/columns.tsx b/client/a8c-for-agencies/sections/team/primary/team-list/columns.tsx index d5d70311884fb..2e9bf92a2126d 100644 --- a/client/a8c-for-agencies/sections/team/primary/team-list/columns.tsx +++ b/client/a8c-for-agencies/sections/team/primary/team-list/columns.tsx @@ -183,6 +183,29 @@ export const ActionColumn = ( { label: translate( 'Send password reset' ), isEnabled: false, // FIXME: Implement this action }, + { + name: 'transfer-ownership', + label: translate( 'Transfer ownership' ), + isEnabled: member.status === 'active' && currentUser.email !== member.email, + confirmationDialog: { + title: translate( 'Transfer agency ownership' ), + children: translate( + 'Are you sure you want to transfer ownership of %(agencyName)s to {{b}}%(memberName)s{{/b}}? {{br/}}This action cannot be undone and you will become a regular team member.', + { + args: { + agencyName: agency?.name ?? '', + memberName: member.displayName ?? member.email, + }, + components: { + b: , + br:
    , + }, + comment: '%(agencyName)s is the agency name, %(memberName)s is the member name', + } + ), + ctaLabel: translate( 'Transfer ownership' ), + }, + }, { name: 'delete-user', label: isSelfAction ? translate( 'Leave agency' ) : translate( 'Remove team member' ), @@ -226,6 +249,7 @@ export const ActionColumn = ( { canRemove, isSelfAction, agency?.name, + currentUser.email, ] ); const activeActions = actions.filter( ( { isEnabled } ) => isEnabled ); diff --git a/client/blocks/login/login-form.jsx b/client/blocks/login/login-form.jsx index 37012b8ba623c..ee043c7f1ee7e 100644 --- a/client/blocks/login/login-form.jsx +++ b/client/blocks/login/login-form.jsx @@ -168,7 +168,8 @@ export class LoginForm extends Component { currentRoute && currentRoute.includes( '/log-in/jetpack' ) && config.isEnabled( 'jetpack/magic-link-signup' ) && - requestError.code === 'unknown_user' + requestError.code === 'unknown_user' && + ! this.props.isWooPasswordlessJPC ) { this.jetpackCreateAccountWithMagicLink(); } @@ -1025,6 +1026,7 @@ export class LoginForm extends Component { { requestError && requestError.field === 'usernameOrEmail' && ( { 'unknown_user' === requestError.code && + ! this.props.isWooPasswordlessJPC && this.props.translate( ' Would you like to {{newAccountLink}}create a new account{{/newAccountLink}}?', { diff --git a/client/blocks/stats-navigation/constants.ts b/client/blocks/stats-navigation/constants.ts index 64ca13afe4d18..9a78f3fd06209 100644 --- a/client/blocks/stats-navigation/constants.ts +++ b/client/blocks/stats-navigation/constants.ts @@ -10,7 +10,7 @@ const week = { value: 'week', label: translate( 'Weeks' ) }; const month = { value: 'month', label: translate( 'Months' ) }; const year = { value: 'year', label: translate( 'Years' ) }; -export const intervals = [ hour, day, week, month, year ]; +export const intervals = [ day, week, month, year ]; export const emailIntervals = [ hour, day ]; export const AVAILABLE_PAGE_MODULES = { diff --git a/client/components/hosting-hero/style.scss b/client/components/hosting-hero/style.scss index dee9c24e77444..64743489bf60e 100644 --- a/client/components/hosting-hero/style.scss +++ b/client/components/hosting-hero/style.scss @@ -20,7 +20,6 @@ } .hosting-hero-button { - margin: 0 5px; padding: 10px 14px; font-size: rem(14px); height: 40px; diff --git a/client/components/stats-interval-dropdown/index.jsx b/client/components/stats-interval-dropdown/index.jsx index e64b53feb81e2..c80df41e68b1a 100644 --- a/client/components/stats-interval-dropdown/index.jsx +++ b/client/components/stats-interval-dropdown/index.jsx @@ -80,7 +80,7 @@ const IntervalDropdown = ( { slug, period, queryParams, intervals, onGatedHandle function getDisplayLabel() { // If the period is not in the intervals list, capitalize it and show in the label - however user wouldn't be able to select it. // TODO: this is a temporary solution, we should remove this once we have all the intervals in the list. - return intervals[ period ]?.label ?? capitalize( period ); + return intervals[ period ]?.label ?? `${ capitalize( period ) }s`; } function onSelectionHandler( interval ) { @@ -93,7 +93,10 @@ const IntervalDropdown = ( { slug, period, queryParams, intervals, onGatedHandle page( generateNewLink( interval ) ); } - return ( + // Check if the selected period is in the intervals list. + const selectedPeriod = intervals[ period ]; + + return selectedPeriod ? ( ( @@ -114,6 +117,11 @@ const IntervalDropdown = ( { slug, period, queryParams, intervals, onGatedHandle ) } focusOnMount={ false } /> + ) : ( + // TODO: Tweak the styles or move this to another place for better UX. +
    + +
    ); }; diff --git a/client/components/stats-interval-dropdown/style.scss b/client/components/stats-interval-dropdown/style.scss index d67e2329567ee..a2960a34c7681 100644 --- a/client/components/stats-interval-dropdown/style.scss +++ b/client/components/stats-interval-dropdown/style.scss @@ -64,3 +64,11 @@ background-color: var(--color-accent-5); } } + +// Mimic the style of Button component. +.stats-interval-display { + font-family: $font-sf-pro-text; + font-size: 13px; // stylelint-disable-line declaration-property-unit-allowed-list + font-weight: 400; + line-height: 36px; +} \ No newline at end of file diff --git a/client/data/promote-post/use-campaign-chart-stats-query.ts b/client/data/promote-post/use-campaign-chart-stats-query.ts index e1cc0fade63dd..e59647550b195 100644 --- a/client/data/promote-post/use-campaign-chart-stats-query.ts +++ b/client/data/promote-post/use-campaign-chart-stats-query.ts @@ -39,7 +39,8 @@ export type CampaignChartStatsResponse = { export const useCampaignChartStatsQuery = ( siteId: number, campaignId: number, - startDate: string + startDate: string, + hasStats: boolean ) => { return useQuery( { queryKey: [ 'promote-post-campaign-stats', siteId, campaignId, startDate ], @@ -55,7 +56,7 @@ export const useCampaignChartStatsQuery = ( '1.1' ); }, - enabled: !! siteId && !! campaignId && !! startDate, + enabled: !! siteId && !! campaignId && !! startDate && hasStats, retryDelay: 3000, meta: { persist: false, diff --git a/client/hosting/hosting-features/components/hosting-features.tsx b/client/hosting/hosting-features/components/hosting-features.tsx index 024f7fe2cad6b..72eb975af05b7 100644 --- a/client/hosting/hosting-features/components/hosting-features.tsx +++ b/client/hosting/hosting-features/components/hosting-features.tsx @@ -1,16 +1,14 @@ import { FEATURE_SFTP, getPlan, PLAN_BUSINESS } from '@automattic/calypso-products'; import page from '@automattic/calypso-router'; -import { Dialog } from '@automattic/components'; import { useHasEnTranslation } from '@automattic/i18n-utils'; import { Spinner } from '@wordpress/components'; -import { addQueryArgs } from '@wordpress/url'; import { translate } from 'i18n-calypso'; -import { useRef, useState, useEffect } from 'react'; -import EligibilityWarnings from 'calypso/blocks/eligibility-warnings'; +import { useRef, useEffect } from 'react'; import { HostingCard, HostingCardGrid } from 'calypso/components/hosting-card'; import { HostingHero, HostingHeroButton } from 'calypso/components/hosting-hero'; import InlineSupportLink from 'calypso/components/inline-support-link'; import { useSiteTransferStatusQuery } from 'calypso/landing/stepper/hooks/use-site-transfer/query'; +import HostingActivationButton from 'calypso/sites/hosting-features/components/hosting-activation-button'; import { useSelector, useDispatch } from 'calypso/state'; import { recordTracksEvent } from 'calypso/state/analytics/actions'; import { transferStates } from 'calypso/state/atomic-transfer/constants'; @@ -18,6 +16,7 @@ import isSiteWpcomAtomic from 'calypso/state/selectors/is-site-wpcom-atomic'; import siteHasFeature from 'calypso/state/selectors/site-has-feature'; import { getSiteSlug } from 'calypso/state/sites/selectors'; import { getSelectedSite, getSelectedSiteId } from 'calypso/state/ui/selectors'; + import './style.scss'; type PromoCardProps = { @@ -40,9 +39,6 @@ const PromoCard = ( { title, text, supportContext }: PromoCardProps ) => ( const HostingFeatures = () => { const dispatch = useDispatch(); const { searchParams } = new URL( document.location.toString() ); - const showActivationModal = searchParams.get( 'activate' ) !== null; - const redirectToParam = searchParams.get( 'redirect_to' ); - const [ showEligibility, setShowEligibility ] = useState( showActivationModal ); const siteId = useSelector( getSelectedSiteId ); const { siteSlug, isSiteAtomic, hasSftpFeature, isPlanExpired } = useSelector( ( state ) => ( { siteSlug: getSiteSlug( state, siteId ) || '', @@ -115,19 +111,6 @@ const HostingFeatures = () => { const canSiteGoAtomic = ! isSiteAtomic && hasSftpFeature; const showActivationButton = canSiteGoAtomic; - const handleTransfer = ( options: { geo_affinity?: string } ) => { - dispatch( recordTracksEvent( 'calypso_hosting_features_activate_confirm' ) ); - const params = new URLSearchParams( { - siteId: String( siteId ), - redirect_to: addQueryArgs( redirectToParam ?? redirectUrl, { - hosting_features: 'activated', - } ), - feature: FEATURE_SFTP, - initiate_transfer_context: 'hosting', - initiate_transfer_geo_affinity: options.geo_affinity || '', - } ); - page( `/setup/transferring-hosted-site?${ params }` ); - }; const activateTitle = hasEnTranslation( 'Activate all hosting features' ) ? translate( 'Activate all hosting features' ) @@ -183,37 +166,7 @@ const HostingFeatures = () => { } else if ( showActivationButton ) { title = activateTitle; description = activateDescription; - buttons = ( - <> - { - if ( showActivationButton ) { - dispatch( recordTracksEvent( 'calypso_hosting_features_activate_click' ) ); - return setShowEligibility( true ); - } - } } - > - { translate( 'Activate now' ) } - - - setShowEligibility( false ) } - showCloseIcon - > - - - - ); + buttons = ; } else { title = unlockTitle; description = unlockDescription; diff --git a/client/jetpack-cloud/sections/comparison/table/useProductsToCompare.ts b/client/jetpack-cloud/sections/comparison/table/useProductsToCompare.ts index 556a3c4cd366e..c8ce886bda07b 100644 --- a/client/jetpack-cloud/sections/comparison/table/useProductsToCompare.ts +++ b/client/jetpack-cloud/sections/comparison/table/useProductsToCompare.ts @@ -2,7 +2,7 @@ import { PLAN_JETPACK_SECURITY_T1_YEARLY, PLAN_JETPACK_COMPLETE, PLAN_JETPACK_FREE, - PLAN_JETPACK_GROWTH_YEARLY, + //PLAN_JETPACK_GROWTH_YEARLY, } from '@automattic/calypso-products'; import { useTranslate } from 'i18n-calypso'; import { useMemo } from 'react'; @@ -28,11 +28,12 @@ export const useProductsToCompare = () => { name: translate( 'Complete', { context: 'Jetpack plan name' } ), productSlug: PLAN_JETPACK_COMPLETE, }, - { - id: 'GROWTH', - name: translate( 'Growth', { context: 'Jetpack plan name' } ), - productSlug: PLAN_JETPACK_GROWTH_YEARLY, - }, + // This will be added with features once Growth is launched + // { + // id: 'GROWTH', + // name: translate( 'Growth', { context: 'Jetpack plan name' } ), + // productSlug: PLAN_JETPACK_GROWTH_YEARLY, + // }, ], [ translate ] ); diff --git a/client/landing/stepper/declarative-flow/internals/steps-repository/design-setup/categories.ts b/client/landing/stepper/declarative-flow/internals/steps-repository/design-setup/categories.ts index 963a34b76765f..bc8118c8b7d50 100644 --- a/client/landing/stepper/declarative-flow/internals/steps-repository/design-setup/categories.ts +++ b/client/landing/stepper/declarative-flow/internals/steps-repository/design-setup/categories.ts @@ -1,8 +1,14 @@ +import { Onboard } from '@automattic/data-stores'; import { Category } from '@automattic/design-picker'; const CATEGORY_BLOG = 'blog'; const CATEGORY_STORE = 'store'; const CATEGORY_BUSINESS = 'business'; +const CATEGORY_COMMUNITY_NON_PROFIT = 'community-non-profit'; +const CATEGORY_PORTFOLIO = 'portfolio'; +const CATEGORY_AUTHORS_WRITERS = 'authors-writers'; +const CATEGORY_EDUCATION = 'education'; +const CATEGORY_ENTERTAINMENT = 'entertainment'; /** * Ensures the category appears at the top of the design category list @@ -25,7 +31,18 @@ const sortBlogToTop = makeSortCategoryToTop( CATEGORY_BLOG ); const sortStoreToTop = makeSortCategoryToTop( CATEGORY_STORE ); const sortBusinessToTop = makeSortCategoryToTop( CATEGORY_BUSINESS ); -export function getCategorizationOptions( intent: string ) { +export function getCategorizationOptions( + intent: string, + goals: Onboard.SiteGoal[], + useGoals: boolean +) { + if ( useGoals ) { + return getCategorizationFromGoals( goals ); + } + return getCategorizationFromIntent( intent ); +} + +function getCategorizationFromIntent( intent: string ) { const result = { defaultSelection: null, } as { @@ -59,3 +76,82 @@ export function getCategorizationOptions( intent: string ) { }; } } + +function getCategorizationFromGoals( goals: Onboard.SiteGoal[] ) { + // Sorted according to which theme category makes the most consequential impact + // on the user's site e.g. if you want a store, then choosing a Woo compatible + // theme is more important than choosing a theme that is good for blogging. + // Missing categories are treated as equally inconsequential. + const mostConsequentialDesignCategories = [ + CATEGORY_STORE, + CATEGORY_EDUCATION, + CATEGORY_COMMUNITY_NON_PROFIT, + CATEGORY_ENTERTAINMENT, + CATEGORY_PORTFOLIO, + CATEGORY_BLOG, + CATEGORY_AUTHORS_WRITERS, + ]; + + const defaultSelection = + goals + .map( getGoalsPreferredCategory ) + .sort( ( a, b ) => { + let aIndex = mostConsequentialDesignCategories.indexOf( a ); + let bIndex = mostConsequentialDesignCategories.indexOf( b ); + + // If the category is not in the list, it should be sorted to the end. + if ( aIndex === -1 ) { + aIndex = mostConsequentialDesignCategories.length; + } + if ( bIndex === -1 ) { + bIndex = mostConsequentialDesignCategories.length; + } + + return aIndex - bIndex; + } ) + .shift() ?? CATEGORY_BUSINESS; + + return { + defaultSelection, + sort: makeSortCategoryToTop( defaultSelection ), + }; +} + +function getGoalsPreferredCategory( goal: Onboard.SiteGoal ): string { + switch ( goal ) { + case Onboard.SiteGoal.Write: + return CATEGORY_BLOG; + + case Onboard.SiteGoal.CollectDonations: + case Onboard.SiteGoal.BuildNonprofit: + return CATEGORY_COMMUNITY_NON_PROFIT; + + case Onboard.SiteGoal.Porfolio: + return CATEGORY_PORTFOLIO; + + case Onboard.SiteGoal.Newsletter: + case Onboard.SiteGoal.PaidSubscribers: + return CATEGORY_AUTHORS_WRITERS; + + case Onboard.SiteGoal.SellDigital: + case Onboard.SiteGoal.SellPhysical: + case Onboard.SiteGoal.Sell: + return CATEGORY_STORE; + + case Onboard.SiteGoal.Courses: + return CATEGORY_EDUCATION; + + case Onboard.SiteGoal.Videos: + case Onboard.SiteGoal.AnnounceEvents: + return CATEGORY_ENTERTAINMENT; + + case Onboard.SiteGoal.Engagement: + case Onboard.SiteGoal.Promote: + case Onboard.SiteGoal.ContactForm: + case Onboard.SiteGoal.Import: + case Onboard.SiteGoal.ImportSubscribers: + case Onboard.SiteGoal.Other: + case Onboard.SiteGoal.DIFM: + return CATEGORY_BUSINESS; + } +} diff --git a/client/landing/stepper/declarative-flow/internals/steps-repository/design-setup/unified-design-picker.tsx b/client/landing/stepper/declarative-flow/internals/steps-repository/design-setup/unified-design-picker.tsx index 698c557c91879..e602c7704e263 100644 --- a/client/landing/stepper/declarative-flow/internals/steps-repository/design-setup/unified-design-picker.tsx +++ b/client/landing/stepper/declarative-flow/internals/steps-repository/design-setup/unified-design-picker.tsx @@ -112,6 +112,10 @@ const UnifiedDesignPickerStep: Step = ( { navigation, flow, stepName } ) => { const variantName = experimentAssignment?.variationName; const oldHighResImageLoading = ! isLoadingExperiment && variantName === 'treatment'; + const [ isAddedGoalsExpLoading, addedGoalsExpAssignment ] = useExperiment( + 'calypso_onboarding_goals_step_added_goals' + ); + const queryParams = useQuery(); const { goBack, submit, exitFlow } = navigation; @@ -120,10 +124,13 @@ const UnifiedDesignPickerStep: Step = ( { navigation, flow, stepName } ) => { const translate = useTranslate(); const locale = useLocale(); - const intent = useSelect( - ( select ) => ( select( ONBOARD_STORE ) as OnboardSelect ).getIntent(), - [] - ); + const { intent, goals } = useSelect( ( select ) => { + const onboardStore = select( ONBOARD_STORE ) as OnboardSelect; + return { + intent: onboardStore.getIntent(), + goals: onboardStore.getGoals(), + }; + }, [] ); const { site, siteSlug, siteSlugOrId } = useSiteData(); const siteTitle = site?.name; @@ -217,7 +224,11 @@ const UnifiedDesignPickerStep: Step = ( { navigation, flow, stepName } ) => { } }, [ hasTrackedView, designs ] ); - const categorizationOptions = getCategorizationOptions( intent ); + const categorizationOptions = getCategorizationOptions( + intent, + goals, + addedGoalsExpAssignment?.variationName === 'treatment' + ); const categorization = useCategorizationFromApi( allDesigns?.filters?.subject || EMPTY_OBJECT, categorizationOptions @@ -791,7 +802,7 @@ const UnifiedDesignPickerStep: Step = ( { navigation, flow, stepName } ) => { // ********** Main render logic // Don't render until we've done fetching all the data needed for initial render. - if ( ! site || isLoadingDesigns ) { + if ( ! site || isLoadingDesigns || isAddedGoalsExpLoading ) { return ; } diff --git a/client/me/purchases/manage-purchase/payment-method-selector/index.tsx b/client/me/purchases/manage-purchase/payment-method-selector/index.tsx index 7a3d130f9f134..f7e45118c287a 100644 --- a/client/me/purchases/manage-purchase/payment-method-selector/index.tsx +++ b/client/me/purchases/manage-purchase/payment-method-selector/index.tsx @@ -186,7 +186,8 @@ export default function PaymentMethodSelector( { onPageLoadError={ logError } paymentMethods={ paymentMethods } paymentProcessors={ { - paypal: ( data: unknown ) => assignPayPalProcessor( purchase, reduxDispatch, data ), + 'paypal-express': ( data: unknown ) => + assignPayPalProcessor( purchase, reduxDispatch, data ), 'existing-card': ( data: unknown ) => assignExistingCardProcessor( purchase, reduxDispatch, data ), 'existing-card-ebanx': ( data: unknown ) => diff --git a/client/my-sites/checkout/src/lib/get-jetpack-product-features.ts b/client/my-sites/checkout/src/lib/get-jetpack-product-features.ts index d7ed0be94c356..6be6d264d116c 100644 --- a/client/my-sites/checkout/src/lib/get-jetpack-product-features.ts +++ b/client/my-sites/checkout/src/lib/get-jetpack-product-features.ts @@ -97,7 +97,7 @@ function getFeatureStrings( ]; case 'growth': return [ - translate( 'Stats (Up to 10K site views, upgradeable)' ), + translate( 'Stats (10K site views, upgradeable)' ), translate( 'Social' ), translate( 'Display ads with WordAds' ), translate( 'Pay with PayPal' ), diff --git a/client/my-sites/checkout/src/payment-methods/paypal-js.tsx b/client/my-sites/checkout/src/payment-methods/paypal-js.tsx index 45b04fcc62b37..34154c30c9d85 100644 --- a/client/my-sites/checkout/src/payment-methods/paypal-js.tsx +++ b/client/my-sites/checkout/src/payment-methods/paypal-js.tsx @@ -31,7 +31,7 @@ function PayPalLabel() { return ( <>
    - PayPal + PayPal (PPCP)
    diff --git a/client/my-sites/plans-features-main/components/plan-notice.tsx b/client/my-sites/plans-features-main/components/plan-notice.tsx index 79e835a36d3fe..29c44c8a50b7e 100644 --- a/client/my-sites/plans-features-main/components/plan-notice.tsx +++ b/client/my-sites/plans-features-main/components/plan-notice.tsx @@ -21,7 +21,7 @@ export type PlanNoticeProps = { showLegacyStorageFeature?: boolean; mediaStorage?: SiteMediaStorage; discountInformation?: { - withDiscount: string; + coupon: string; discountEndDate: Date; }; }; @@ -60,7 +60,7 @@ function useResolveNoticeType( ); const activeDiscount = discountInformation && - getDiscountByName( discountInformation.withDiscount, discountInformation.discountEndDate ); + getDiscountByName( discountInformation.coupon, discountInformation.discountEndDate ); const planUpgradeCreditsApplicable = usePlanUpgradeCreditsApplicable( siteId, visiblePlans ); const sitePlan = useSelector( ( state ) => getSitePlan( state, siteId ) ); const sitePlanSlug = sitePlan?.product_slug ?? ''; @@ -98,7 +98,7 @@ export default function PlanNotice( props: PlanNoticeProps ) { const handleDismissNotice = () => setIsNoticeDismissed( true ); let activeDiscount = discountInformation && - getDiscountByName( discountInformation.withDiscount, discountInformation.discountEndDate ); + getDiscountByName( discountInformation.coupon, discountInformation.discountEndDate ); switch ( noticeType ) { case NO_NOTICE: diff --git a/client/my-sites/plans-features-main/hooks/use-generate-action-callback.ts b/client/my-sites/plans-features-main/hooks/use-generate-action-callback.ts index 8c7ed1deca6fc..97626f8e595cf 100644 --- a/client/my-sites/plans-features-main/hooks/use-generate-action-callback.ts +++ b/client/my-sites/plans-features-main/hooks/use-generate-action-callback.ts @@ -28,11 +28,11 @@ import type { MinimalRequestCartProduct } from '@automattic/shopping-cart'; function useUpgradeHandler( { siteSlug, - withDiscount, + coupon, cartHandler, }: { siteSlug?: string | null; - withDiscount?: string; + coupon?: string; cartHandler?: ( cartItems?: MinimalRequestCartProduct[] | null ) => void; } ) { const processCartItems = useCallback( @@ -62,15 +62,12 @@ function useUpgradeHandler( { ? `/checkout/${ siteSlug }/${ planPath },${ cartItemForStorageAddOn.product_slug }:-q-${ cartItemForStorageAddOn.quantity }` : `/checkout/${ siteSlug }/${ planPath }`; - const checkoutUrlWithArgs = addQueryArgs( - { ...( withDiscount && { coupon: withDiscount } ) }, - checkoutUrl - ); + const checkoutUrlWithArgs = addQueryArgs( { ...( coupon && { coupon } ) }, checkoutUrl ); page( checkoutUrlWithArgs ); return; }, - [ siteSlug, withDiscount, cartHandler ] + [ siteSlug, coupon, cartHandler ] ); return useCallback( @@ -154,7 +151,7 @@ function useGenerateActionCallback( { showModalAndExit, sitePlanSlug, siteId, - withDiscount, + coupon, }: { currentPlan: Plans.SitePlan | undefined; eligibleForFreeHostingTrial: boolean; @@ -164,7 +161,7 @@ function useGenerateActionCallback( { showModalAndExit?: ( planSlug: PlanSlug ) => boolean; sitePlanSlug?: PlanSlug | null; siteId?: number | null; - withDiscount?: string; + coupon?: string; } ): UseActionCallback { const siteSlug = useSelector( ( state: IAppState ) => getSiteSlug( state, siteId ) ); const freeTrialPlanSlugs = useFreeTrialPlanSlugs( { @@ -177,7 +174,7 @@ function useGenerateActionCallback( { ? ! isCurrentPlanPaid( state, siteId ) || isCurrentUserCurrentPlanOwner( state, siteId ) : null ); - const handleUpgradeClick = useUpgradeHandler( { siteSlug, withDiscount, cartHandler } ); + const handleUpgradeClick = useUpgradeHandler( { siteSlug, coupon, cartHandler } ); const handleDowngradeClick = useDowngradeHandler( { siteSlug, currentPlan, diff --git a/client/my-sites/plans-features-main/hooks/use-generate-action-hook.tsx b/client/my-sites/plans-features-main/hooks/use-generate-action-hook.tsx index 28c5beb81583f..588345198206b 100644 --- a/client/my-sites/plans-features-main/hooks/use-generate-action-hook.tsx +++ b/client/my-sites/plans-features-main/hooks/use-generate-action-hook.tsx @@ -63,7 +63,7 @@ export default function useGenerateActionHook( { isInSignup, isLaunchPage, showModalAndExit, - withDiscount, + coupon, }: { siteId?: number | null; cartHandler?: ( cartItems?: MinimalRequestCartProduct[] | null ) => void; @@ -72,7 +72,7 @@ export default function useGenerateActionHook( { isInSignup: boolean; isLaunchPage: boolean | null; showModalAndExit?: ( planSlug: PlanSlug ) => boolean; - withDiscount?: string; + coupon?: string; } ): UseAction { const translate = useTranslate(); const currentPlan = Plans.useCurrentPlan( { siteId } ); @@ -102,7 +102,7 @@ export default function useGenerateActionHook( { showModalAndExit, sitePlanSlug, siteId, - withDiscount, + coupon, } ); const useActionHook = ( { diff --git a/client/my-sites/plans-features-main/hooks/use-plan-type-destination-callback.ts b/client/my-sites/plans-features-main/hooks/use-plan-type-destination-callback.ts index d6a76369600d7..95bc65c3b7613 100644 --- a/client/my-sites/plans-features-main/hooks/use-plan-type-destination-callback.ts +++ b/client/my-sites/plans-features-main/hooks/use-plan-type-destination-callback.ts @@ -17,7 +17,7 @@ const usePlanTypeDestinationCallback = () => { const { intervalType = '' } = additionalArgs; const defaultArgs = { customerType: undefined, - discount: props.withDiscount, + coupon: props.coupon, feature: props.selectedFeature, plan: props.selectedPlan, }; diff --git a/client/my-sites/plans-features-main/index.tsx b/client/my-sites/plans-features-main/index.tsx index 6711accf0e510..aeeea88653481 100644 --- a/client/my-sites/plans-features-main/index.tsx +++ b/client/my-sites/plans-features-main/index.tsx @@ -123,7 +123,6 @@ export interface PlansFeaturesMainProps { Extract< UrlFriendlyTermType, 'monthly' | 'yearly' | '2yearly' | '3yearly' > >; planTypeSelector?: 'interval'; - withDiscount?: string; discountEndDate?: Date; hidePlansFeatureComparison?: boolean; coupon?: string; @@ -192,7 +191,6 @@ const PlansFeaturesMain = ( { basePlansPath, selectedFeature, plansWithScroll, - withDiscount, discountEndDate, hideFreePlan, hidePersonalPlan, @@ -366,7 +364,7 @@ const PlansFeaturesMain = ( { isInSignup, isLaunchPage, showModalAndExit, - withDiscount, + coupon, } ); const isDomainOnlySite = useSelector( ( state: IAppState ) => @@ -485,7 +483,6 @@ const PlansFeaturesMain = ( { recordTracksEvent, coupon, selectedSiteId: siteId, - withDiscount, intent, }; @@ -541,7 +538,6 @@ const PlansFeaturesMain = ( { sitePlanSlug, coupon, siteId, - withDiscount, getPlanTypeDestination, onPlanIntervalUpdate, intent, @@ -771,10 +767,10 @@ const PlansFeaturesMain = ( { siteId={ siteId } isInSignup={ isInSignup } showLegacyStorageFeature={ showLegacyStorageFeature } - { ...( withDiscount && + { ...( coupon && discountEndDate && { discountInformation: { - withDiscount, + coupon, discountEndDate, }, } ) } diff --git a/client/my-sites/plans/controller.jsx b/client/my-sites/plans/controller.jsx index 5ac1ee5d91ed6..8f6f611bc09c3 100644 --- a/client/my-sites/plans/controller.jsx +++ b/client/my-sites/plans/controller.jsx @@ -36,9 +36,9 @@ export function plans( context, next ) { // from the Calypso admin plans page. The `/start` onboarding flow // plans page, however, relies on the `coupon` query param for the // same purpose. We handle both coupon and discount here for the time - // being to avoid confusion. We'll probably consolidate to just `coupon` - // in the future. - const withDiscount = context.query.coupon || context.query.discount; + // being to avoid confusion and to continue support for legacy + // coupons. We'll consolidate to just `coupon` in the future. + const coupon = context.query.coupon || context.query.discount; context.primary = ( ); @@ -273,7 +276,7 @@ class PlansComponent extends Component { selectedFeature={ this.props.selectedFeature } selectedPlan={ this.props.selectedPlan } redirectTo={ this.props.redirectTo } - withDiscount={ this.props.withDiscount } + coupon={ this.props.coupon } discountEndDate={ this.props.discountEndDate } siteId={ selectedSite?.ID } plansWithScroll={ false } diff --git a/client/my-sites/plugins/categories/use-categories.tsx b/client/my-sites/plugins/categories/use-categories.tsx index 910ccf944f634..18ba4f11991ba 100644 --- a/client/my-sites/plugins/categories/use-categories.tsx +++ b/client/my-sites/plugins/categories/use-categories.tsx @@ -337,23 +337,6 @@ export const getCategories: () => Record< string, Category > = () => ( { description: __( 'Building a money-making blog doesn’t have to be as hard as you might think' ), tags: [ 'affiliate-marketing', 'advertising', 'adwords' ], preview: [ - // TikTok for Business is only promoted for the first two weeks of November 2024 - ...( () => { - const currentDate = new Date(); - const isNovember2024 = currentDate.getFullYear() === 2024 && currentDate.getMonth() === 10; - const isFirstTwoWeeks = currentDate.getDate() <= 14; - - return isNovember2024 && isFirstTwoWeeks - ? [ - { - slug: 'tiktok-for-business', - name: __( 'Find new prospects through TikTok' ), - icon: 'https://ps.w.org/tiktok-for-business/assets/icon-256x256.jpg?rev=2721531', - short_description: __( 'Run Lead Generation Ads and improve targeting' ), - }, - ] - : []; - } )(), { slug: 'wordpress-seo-premium', name: __( 'Yoast SEO Premium' ), @@ -387,7 +370,7 @@ export const getCategories: () => Record< string, Category > = () => ( { { slug: 'elementor', name: __( 'Elementor' ), - icon: 'https://ps.w.org/elementor/assets/icon.svg', + icon: 'https://ps.w.org/elementor/assets/icon-256x256.gif?rev=3111597', short_description: __( 'Drag and drop page builder' ), }, ], diff --git a/client/my-sites/plugins/plugin-management-v2/plugin-action-status/style.scss b/client/my-sites/plugins/plugin-management-v2/plugin-action-status/style.scss index a5f05e386e645..e7c24a880bc0c 100644 --- a/client/my-sites/plugins/plugin-management-v2/plugin-action-status/style.scss +++ b/client/my-sites/plugins/plugin-management-v2/plugin-action-status/style.scss @@ -26,7 +26,30 @@ } } -.plugin-action-status-inProgress, +.plugin-action-status-inProgress { + background: #b8e6bf; + color: #00450c; + /* reference busy state from https://github.com/WordPress/gutenberg/blob/trunk/packages/components/src/button/style.scss */ + animation: plugin-action-status-inProgress__busy-animation 2500ms infinite linear; + @media (prefers-reduced-motion: reduce) { + animation-duration: 0s; + } + background-size: 100px 100%; + background-image: linear-gradient( + -45deg, + darken( #b8e6bf, 2%) 33%, + darken( #b8e6bf, 12%) 33%, + darken( #b8e6bf, 12%) 70%, + darken( #b8e6bf, 2%) 70% + ); +} + +@keyframes plugin-action-status-inProgress__busy-animation { + 0% { + background-position: 200px 0; + } +} + .plugin-action-status-up-to-date { background: #f6f7f7; color: #50575e; diff --git a/client/my-sites/promote-post-i2/components/campaign-item-details/index.tsx b/client/my-sites/promote-post-i2/components/campaign-item-details/index.tsx index 59ad8aa35e386..1e56efd0e4ee0 100644 --- a/client/my-sites/promote-post-i2/components/campaign-item-details/index.tsx +++ b/client/my-sites/promote-post-i2/components/campaign-item-details/index.tsx @@ -16,7 +16,6 @@ import Main from 'calypso/components/main'; import Notice from 'calypso/components/notice'; import { CampaignChartSeriesData, - CampaignChartStatsResponse, useCampaignChartStatsQuery, } from 'calypso/data/promote-post/use-campaign-chart-stats-query'; import useBillingSummaryQuery from 'calypso/data/promote-post/use-promote-post-billing-summary-query'; @@ -57,7 +56,6 @@ interface Props { isLoading?: boolean; siteId: number; campaign: CampaignResponse; - campaignChartStats?: CampaignChartStatsResponse; } const FlexibleSkeleton = () => { @@ -129,10 +127,6 @@ export default function CampaignItemDetails( props: Props ) { const { data, isLoading: isLoadingBillingSummary } = useBillingSummaryQuery(); const paymentBlocked = data?.paymentsBlocked ?? false; - const campaignStatsQuery = useCampaignChartStatsQuery( siteId, campaignId, campaign.start_date ); - const { isLoading: campaignsStatsIsLoading } = campaignStatsQuery; - const { data: campaignStats } = campaignStatsQuery; - const { audience_list, content_config, @@ -169,6 +163,15 @@ export default function CampaignItemDetails( props: Props ) { conversion_last_currency_found, } = campaign_stats || {}; + const campaignStatsQuery = useCampaignChartStatsQuery( + siteId, + campaignId, + campaign.start_date, + !! impressions_total + ); + const { isLoading: campaignsStatsIsLoading } = campaignStatsQuery; + const { data: campaignStats } = campaignStatsQuery; + const { card_name, payment_method, credits, total, orders, payment_links } = billing_data || {}; const { title, clickUrl } = content_config || {}; const canDisplayPaymentSection = @@ -626,63 +629,63 @@ export default function CampaignItemDetails( props: Props ) { ) }
    - - { ! campaignsStatsIsLoading && campaignStats && ( - <> -
    -
    -
    - setChartSource( ChartSourceOptions.Clicks ), - title: __( 'Clicks' ), - isDisabled: chartSource === ChartSourceOptions.Clicks, - }, - { - onClick: () => setChartSource( ChartSourceOptions.Impressions ), - title: __( 'Impressions' ), - isDisabled: chartSource === ChartSourceOptions.Impressions, - }, - ] } - icon={ chevronDown } - text={ - chartSource === ChartSourceOptions.Clicks - ? __( 'Clicks' ) - : __( 'Impressions' ) - } - label={ chartSource } - /> - { getCampaignStatsChart( - campaignStats?.series[ chartSource ], - chartSource - ) } + { ! campaignsStatsIsLoading && campaignStats && ( + <> +
    +
    +
    + setChartSource( ChartSourceOptions.Clicks ), + title: __( 'Clicks' ), + isDisabled: chartSource === ChartSourceOptions.Clicks, + }, + { + onClick: () => setChartSource( ChartSourceOptions.Impressions ), + title: __( 'Impressions' ), + isDisabled: chartSource === ChartSourceOptions.Impressions, + }, + ] } + icon={ chevronDown } + text={ + chartSource === ChartSourceOptions.Clicks + ? __( 'Clicks' ) + : __( 'Impressions' ) + } + label={ chartSource } + /> + { getCampaignStatsChart( + campaignStats?.series[ chartSource ], + chartSource + ) } +
    -
    -
    -
    -
    - - { chartSource === ChartSourceOptions.Clicks - ? __( 'Clicks by location' ) - : __( 'Impressions by location' ) } - -
    - +
    +
    +
    + + { chartSource === ChartSourceOptions.Clicks + ? __( 'Clicks by location' ) + : __( 'Impressions by location' ) } + +
    + +
    -
    - - ) } + + ) } +
    ) } diff --git a/client/my-sites/promote-post-i2/components/campaign-item-details/style.scss b/client/my-sites/promote-post-i2/components/campaign-item-details/style.scss index f33a0e9b1e826..2ecda2a4eb650 100644 --- a/client/my-sites/promote-post-i2/components/campaign-item-details/style.scss +++ b/client/my-sites/promote-post-i2/components/campaign-item-details/style.scss @@ -339,6 +339,10 @@ body.is-section-promote-post-i2 .layout__content .campaign-item-details.main { .campaign-item-details__impressions .campaign-item-details__main-stats-row { border-bottom: 1px solid #ddd; + + &:only-child { + border-bottom: none; + } } .campaign-item-details__main-stats-row-bottom { diff --git a/client/my-sites/stats/controller.jsx b/client/my-sites/stats/controller.jsx index 15557584c64dc..5187e6b84e2ad 100644 --- a/client/my-sites/stats/controller.jsx +++ b/client/my-sites/stats/controller.jsx @@ -283,6 +283,14 @@ export function summary( context, next ) { : momentSiteZone.endOf( activeFilter.period ).locale( 'en' ); const period = rangeOfPeriod( activeFilter.period, date ); + // Support for custom date ranges. + // Evaluate the endDate param if provided and create a date range object if valid. + // Valid means endDate is a valid date and is not before the startDate. + const isValidEndDate = queryOptions.endDate && moment( queryOptions.endDate ).isValid(); + const endDate = isValidEndDate ? moment( queryOptions.endDate ).locale( 'en' ) : null; + const isValidRange = isValidEndDate && ! endDate.isBefore( date ); + const dateRange = isValidRange ? { startDate: date, endDate: endDate } : null; + const extraProps = context.params.module === 'videodetails' ? { postId: parseInt( queryOptions.post, 10 ) } : {}; @@ -302,6 +310,7 @@ export function summary( context, next ) { path={ context.pathname } statsQueryOptions={ statsQueryOptions } date={ date } + dateRange={ dateRange } context={ context } period={ period } { ...extraProps } diff --git a/client/my-sites/stats/features/modules/stats-authors/stats-authors.tsx b/client/my-sites/stats/features/modules/stats-authors/stats-authors.tsx index 17839ecef17c7..463a89ee7a38e 100644 --- a/client/my-sites/stats/features/modules/stats-authors/stats-authors.tsx +++ b/client/my-sites/stats/features/modules/stats-authors/stats-authors.tsx @@ -69,7 +69,13 @@ const StatAuthors: React.FC< StatsDefaultModuleProps > = ( { { comment: '{{link}} links to support documentation.', components: { - link:
    , + link: ( + + ), }, context: 'Stats: Link in a popover for the Posts & Pages when the module has data', @@ -102,7 +108,13 @@ const StatAuthors: React.FC< StatsDefaultModuleProps > = ( { { comment: '{{link}} links to support documentation.', components: { - link: , + link: ( + + ), }, context: 'Stats: Info box label when the Authors module is empty', } diff --git a/client/my-sites/stats/features/modules/stats-clicks/stats-clicks.tsx b/client/my-sites/stats/features/modules/stats-clicks/stats-clicks.tsx index 3942a1ff84a27..2d3859a9990e2 100644 --- a/client/my-sites/stats/features/modules/stats-clicks/stats-clicks.tsx +++ b/client/my-sites/stats/features/modules/stats-clicks/stats-clicks.tsx @@ -69,7 +69,13 @@ const StatsClicks: React.FC< StatsDefaultModuleProps > = ( { { comment: '{{link}} links to support documentation.', components: { - link: , + link: ( + + ), }, context: 'Stats: Link in a popover for the Clicks module when it has data', } @@ -101,7 +107,13 @@ const StatsClicks: React.FC< StatsDefaultModuleProps > = ( { { comment: '{{link}} links to support documentation.', components: { - link: , + link: ( + + ), }, context: 'Stats: Info box label when the Clicks module is empty', } diff --git a/client/my-sites/stats/features/modules/stats-comments/stats-comments.tsx b/client/my-sites/stats/features/modules/stats-comments/stats-comments.tsx index 5307ef1b070b4..cb4413d8d704d 100644 --- a/client/my-sites/stats/features/modules/stats-comments/stats-comments.tsx +++ b/client/my-sites/stats/features/modules/stats-comments/stats-comments.tsx @@ -126,7 +126,11 @@ const StatsComments: React.FC< StatsDefaultModuleProps > = ( { className } ) => comment: '{{link}} links to support documentation.', components: { link: ( - + ), }, context: 'Stats: Info box label when the Comments module is empty', @@ -171,7 +175,11 @@ const StatsComments: React.FC< StatsDefaultModuleProps > = ( { className } ) => comment: '{{link}} links to support documentation.', components: { link: ( - + ), }, context: 'Stats: Info box label when the Comments module is empty', diff --git a/client/my-sites/stats/features/modules/stats-countries/stats-countries.tsx b/client/my-sites/stats/features/modules/stats-countries/stats-countries.tsx index 08d14d8e8702b..477cdcad37399 100644 --- a/client/my-sites/stats/features/modules/stats-countries/stats-countries.tsx +++ b/client/my-sites/stats/features/modules/stats-countries/stats-countries.tsx @@ -70,7 +70,13 @@ const StatsCountries: React.FC< StatsDefaultModuleProps > = ( { { translate( 'Stats on visitors and their {{link}}viewing location{{/link}}.', { comment: '{{link}} links to support documentation.', components: { - link: , + link: ( + + ), }, context: 'Stats: Link in a popover for Countries module when the module has data', } ) } @@ -103,7 +109,13 @@ const StatsCountries: React.FC< StatsDefaultModuleProps > = ( { { comment: '{{link}} links to support documentation.', components: { - link: , + link: ( + + ), }, context: 'Stats: Info box label when the Countries module is empty', } diff --git a/client/my-sites/stats/features/modules/stats-devices/stats-module-devices.tsx b/client/my-sites/stats/features/modules/stats-devices/stats-module-devices.tsx index 4935b7312387a..3b4c65456bf13 100644 --- a/client/my-sites/stats/features/modules/stats-devices/stats-module-devices.tsx +++ b/client/my-sites/stats/features/modules/stats-devices/stats-module-devices.tsx @@ -158,7 +158,13 @@ const StatsModuleDevices: React.FC< StatsModuleDevicesProps > = ( { { comment: '{{link}} links to support documentation.', components: { - link: , + link: ( + + ), }, context: 'Stats: Info popover content when the Devices module has data.', } @@ -197,6 +203,8 @@ const StatsModuleDevices: React.FC< StatsModuleDevicesProps > = ( { components: { link: ( = ( { { translate( 'Most {{link}}downloaded files{{/link}} from your site.', { comment: '{{link}} links to support documentation.', components: { - link: , + link: ( + + ), }, context: 'Stats: Info popover content when the file downloads module has data.', } ) } @@ -100,7 +106,13 @@ const StatsDownloads: React.FC< StatsDefaultModuleProps > = ( { { comment: '{{link}} links to support documentation.', components: { - link: , + link: ( + + ), }, context: 'Stats: Info box label when the file downloads module is empty', } diff --git a/client/my-sites/stats/features/modules/stats-emails/stats-emails.tsx b/client/my-sites/stats/features/modules/stats-emails/stats-emails.tsx index 6a52c86dc745c..809f9030f9973 100644 --- a/client/my-sites/stats/features/modules/stats-emails/stats-emails.tsx +++ b/client/my-sites/stats/features/modules/stats-emails/stats-emails.tsx @@ -66,7 +66,7 @@ const StatsEmails: React.FC< StatsDefaultModuleProps > = ( { { translate( '{{link}}Latest emails sent{{/link}} and their performance.', { comment: '{{link}} links to support documentation.', components: { - link: , + link: , }, context: 'Stats: Header popower information when the Emails module has data.', } ) } @@ -101,7 +101,7 @@ const StatsEmails: React.FC< StatsDefaultModuleProps > = ( { { comment: '{{link}} links to support documentation.', components: { - link: , + link: , }, context: 'Stats: Info box label when the Emails module is empty', } diff --git a/client/my-sites/stats/features/modules/stats-referrers/stats-referrers.tsx b/client/my-sites/stats/features/modules/stats-referrers/stats-referrers.tsx index 99f2087912d44..53bb8e3899610 100644 --- a/client/my-sites/stats/features/modules/stats-referrers/stats-referrers.tsx +++ b/client/my-sites/stats/features/modules/stats-referrers/stats-referrers.tsx @@ -69,7 +69,13 @@ const StatsReferrers: React.FC< StatsDefaultModuleProps > = ( { { comment: '{{link}} links to support documentation.', components: { - link: , + link: ( + + ), }, context: 'Stats: Link in a popover for the Referrers when the module has data', } @@ -101,7 +107,13 @@ const StatsReferrers: React.FC< StatsDefaultModuleProps > = ( { { comment: '{{link}} links to support documentation.', components: { - link: , + link: ( + + ), }, context: 'Stats: Info box label when the Referrers module is empty', } diff --git a/client/my-sites/stats/features/modules/stats-search/stats-search.tsx b/client/my-sites/stats/features/modules/stats-search/stats-search.tsx index 9e60bf4450d70..fe48fcbc32b07 100644 --- a/client/my-sites/stats/features/modules/stats-search/stats-search.tsx +++ b/client/my-sites/stats/features/modules/stats-search/stats-search.tsx @@ -67,7 +67,13 @@ const StatSearch: React.FC< StatsDefaultModuleProps > = ( { { comment: '{{link}} links to support documentation.', components: { - link: , + link: ( + + ), }, context: 'Stats: Info box label when the Search module is empty', } @@ -98,7 +104,13 @@ const StatSearch: React.FC< StatsDefaultModuleProps > = ( { { comment: '{{link}} links to support documentation.', components: { - link: , + link: ( + + ), }, context: 'Stats: Info box label when the Search module is empty', } diff --git a/client/my-sites/stats/features/modules/stats-tags/stats-tags.tsx b/client/my-sites/stats/features/modules/stats-tags/stats-tags.tsx index cc7ed60c01d6e..7ffc36017a9a5 100644 --- a/client/my-sites/stats/features/modules/stats-tags/stats-tags.tsx +++ b/client/my-sites/stats/features/modules/stats-tags/stats-tags.tsx @@ -67,7 +67,7 @@ const StatsTags: React.FC< StatsDefaultModuleProps > = ( { { comment: '{{link}} links to support documentation.', components: { - link: , + link: , }, context: 'Stats: Info box label when the Tags & Categories module has data', } @@ -96,7 +96,7 @@ const StatsTags: React.FC< StatsDefaultModuleProps > = ( { { comment: '{{link}} links to support documentation.', components: { - link: , + link: , }, context: 'Stats: Info box label when the Tags & Categories module is empty', } diff --git a/client/my-sites/stats/features/modules/stats-top-posts/stats-top-posts.tsx b/client/my-sites/stats/features/modules/stats-top-posts/stats-top-posts.tsx index 52599e3d4dc47..99b2ec13e7b80 100644 --- a/client/my-sites/stats/features/modules/stats-top-posts/stats-top-posts.tsx +++ b/client/my-sites/stats/features/modules/stats-top-posts/stats-top-posts.tsx @@ -71,7 +71,7 @@ const StatsTopPosts: React.FC< StatsDefaultModuleProps > = ( { { comment: '{{link}} links to support documentation.', components: { - link: , + link: , }, context: 'Stats: Link in a popover for the Posts & Pages when the module has data', @@ -104,7 +104,7 @@ const StatsTopPosts: React.FC< StatsDefaultModuleProps > = ( { { comment: '{{link}} links to support documentation.', components: { - link: , + link: , }, context: 'Stats: Info box label when the Posts & Pages module is empty', } diff --git a/client/my-sites/stats/features/modules/stats-utm/stats-module-utm.jsx b/client/my-sites/stats/features/modules/stats-utm/stats-module-utm.jsx index 38719cefd477d..02a137a845abe 100644 --- a/client/my-sites/stats/features/modules/stats-utm/stats-module-utm.jsx +++ b/client/my-sites/stats/features/modules/stats-utm/stats-module-utm.jsx @@ -113,6 +113,9 @@ const StatsModuleUTM = ( { const showLoader = isLoading || isFetchingUTM; const getHref = () => { + if ( ! hideSummaryLink && summaryUrl ) { + return summaryUrl; + } // Some modules do not have view all abilities if ( ! summary && period && path && siteSlug ) { return `/stats/${ period.period }/${ path }/${ siteSlug }?startDate=${ period.startOf.format( @@ -135,6 +138,8 @@ const StatsModuleUTM = ( { components: { link: ( = ( { { comment: '{{link}} links to support documentation.', components: { - link: , + link: , }, context: 'Stats: Header popover with information when the Videos module has data.', @@ -98,7 +98,7 @@ const StatsVideos: React.FC< StatsDefaultModuleProps > = ( { { comment: '{{link}} links to support documentation.', components: { - link: , + link: , }, context: 'Stats: Info box label when the Videos module is empty', } diff --git a/client/my-sites/stats/modernized-chart-tabs-styles.scss b/client/my-sites/stats/modernized-chart-tabs-styles.scss index f40ae22984412..80249d5c05bbf 100644 --- a/client/my-sites/stats/modernized-chart-tabs-styles.scss +++ b/client/my-sites/stats/modernized-chart-tabs-styles.scss @@ -1,8 +1,9 @@ @use "sass:math"; @import "@wordpress/base-styles/breakpoints"; @import "@automattic/components/src/highlight-cards/variables"; +@import "@automattic/components/src/styles/typography"; -$custom-stats-tab-mobile-break: $break-small; +$custom-stats-tab-mobile-break: $break-medium; .stats > .stats-content { padding-top: $vertical-margin; @@ -28,6 +29,9 @@ $custom-stats-tab-mobile-break: $break-small; display: flex; align-items: center; + justify-content: space-between; + flex-flow: row wrap; + gap: 24px; @media (max-width: $custom-mobile-breakpoint) { padding: 24px math.div($vertical-margin, 2) 16px; @@ -44,17 +48,14 @@ $custom-stats-tab-mobile-break: $break-small; // Adjust new stats-tabs styling beyond the mobile layout .stats-tab { - flex: 1; - width: auto; + font-family: $font-sf-pro-text; border: 0; - &:not(:first-child) { - margin-left: 16px; - } - // Keep the original mobile stats-tabs styles @media (max-width: $custom-stats-tab-mobile-break) { width: 100%; + flex: 1 1 100%; + max-width: initial; float: none; border-bottom: 1px solid var(--color-neutral-5); @@ -67,6 +68,18 @@ $custom-stats-tab-mobile-break: $break-small; } } + // Keep the original mobile stats-tabs styles + @media (min-width: $custom-stats-tab-mobile-break) { + flex: 1 1 45%; + width: 45%; + } + + @media (min-width: $break-xlarge) { + flex: 1 1 155px; + // For wrap layout of three items in a row. + max-width: 31%; + } + .gridicon { @media (max-width: $custom-stats-tab-mobile-break) { float: left; @@ -84,8 +97,8 @@ $custom-stats-tab-mobile-break: $break-small; padding: 10px 6px; @media (max-width: $custom-stats-tab-mobile-break) { - display: flex; - flex-direction: row; + display: grid; + grid-template-columns: 2fr 2fr 3fr; align-items: center; justify-content: space-between; } @@ -137,6 +150,11 @@ $custom-stats-tab-mobile-break: $break-small; } } + &.tab-disabled a { + pointer-events: none; + cursor: default; + } + &.is-low { a { .value { @@ -184,6 +202,44 @@ $custom-stats-tab-mobile-break: $break-small; } } } + + // Apply highlight card styles to stats-tabs. + &.is-highlighted { + text-align: left; + + .stats-tabs__highlight { + display: flex; + align-items: center; + justify-content: end; + } + // TODO: The relevant class names could be refactored to be more comprehensive. + .highlight-card-difference { + font-size: $font-body-small; + font-weight: 600; + line-height: 25px; + letter-spacing: -0.24px; + margin-left: 8px; + } + .stats-tabs__value.value { + display: none; + } + @media (min-width: $custom-stats-tab-mobile-break) { + & > a { + padding: 8px 16px; + } + .stats-tabs__highlight-value { + font-weight: 500; + font-size: $font-title-medium; + line-height: 40px; + // Prevent different heights of Flexbox items. + white-space: nowrap; + } + .stats-tabs__highlight { + display: flex; + justify-content: start; + } + } + } } } } diff --git a/client/my-sites/stats/site.jsx b/client/my-sites/stats/site.jsx index ca4a9fb7e0e8e..23c87d4dcb836 100644 --- a/client/my-sites/stats/site.jsx +++ b/client/my-sites/stats/site.jsx @@ -25,6 +25,7 @@ import InlineSupportLink from 'calypso/components/inline-support-link'; import JetpackColophon from 'calypso/components/jetpack-colophon'; import Main from 'calypso/components/main'; import NavigationHeader from 'calypso/components/navigation-header'; +import StickyPanel from 'calypso/components/sticky-panel'; import memoizeLast from 'calypso/lib/memoize-last'; import { STATS_FEATURE_DATE_CONTROL_LAST_30_DAYS } from 'calypso/my-sites/stats/constants'; import { getMomentSiteZone } from 'calypso/my-sites/stats/hooks/use-moment-site-zone'; @@ -243,6 +244,8 @@ class StatsSite extends Component { // Used in case no starting date is present in the URL. getDefaultDaysForPeriod( period, defaultSevenDaysForPeriodDay = false ) { switch ( period ) { + case 'hour': + return 1; case 'day': // TODO: Temporary fix for the new date filtering feature. if ( defaultSevenDaysForPeriodDay ) { @@ -261,17 +264,24 @@ class StatsSite extends Component { } } - getStatHref( period, path, siteSlug ) { - return period && path && siteSlug - ? '/stats/' + - period?.period + - '/' + - path + - '/' + - siteSlug + - '?startDate=' + - period?.startOf?.format( 'YYYY-MM-DD' ) - : undefined; + // Note: This is only used in the empty version of the module. + // There's a similar function inside stats-module/index.jsx that is used when we have content. + getStatHref( path, query ) { + const { period, slug } = this.props; + const paramsValid = period && path && slug; + if ( ! paramsValid ) { + return undefined; + } + + let url = `/stats/${ period.period }/${ path }/${ slug }`; + + if ( query?.start_date ) { + url += `?startDate=${ query.start_date }&endDate=${ query.date }`; + } else { + url += `?startDate=${ period.endOf.format( 'YYYY-MM-DD' ) }`; + } + + return url; } renderStats( isInternal ) { @@ -342,15 +352,15 @@ class StatsSite extends Component { .format( 'YYYY-MM-DD' ); } + customChartRange.daysInRange = daysInRange; + // TODO: all the date logic should be done in controllers, otherwise it affects the performance. // If it's single day period, redirect to hourly stats. if ( period === 'day' && daysInRange === 1 ) { - page( '/stats/hour/' + slug + window.location.search ); + page.redirect( `/stats/hour/${ slug }${ window.location.search }` ); return; } - customChartRange.daysInRange = daysInRange; - // Calculate diff between requested start and end in `priod` units. // Move end point (most recent) to the end of period to account for partial periods // (e.g. requesting period between June 2020 and Feb 2021 would require 2 `yearly` units but would return 1 unit without the shift to the end of period) @@ -460,35 +470,38 @@ class StatsSite extends Component { ) } { isNewDateFilteringEnabled && ( // moves date range block into new location - - - { ' ' } - + + - - + period={ period } + url={ `/stats/${ period }/${ slug }` } + queryParams={ context.query } + pathTemplate={ pathTemplate } + charts={ CHARTS } + availableLegend={ this.getAvailableLegend() } + activeTab={ getActiveTab( this.props.chartTab ) } + activeLegend={ this.state.activeLegend } + onChangeLegend={ this.onChangeLegend } + isWithNewDateFiltering // @TODO:remove this prop once we release new date filtering + isWithNewDateControl + showArrows + slug={ slug } + dateRange={ customChartRange } + > + { ' ' } + + + + ) }
    <> @@ -568,14 +581,14 @@ class StatsSite extends Component { moduleStrings={ moduleStrings.posts } period={ this.props.period } query={ query } - summaryUrl={ this.getStatHref( this.props.period, 'posts', slug ) } + summaryUrl={ this.getStatHref( 'posts', query ) } className={ halfWidthModuleClasses } /> @@ -583,7 +596,7 @@ class StatsSite extends Component { moduleStrings={ moduleStrings.countries } period={ this.props.period } query={ query } - summaryUrl={ this.getStatHref( this.props.period, 'countryviews', slug ) } + summaryUrl={ this.getStatHref( 'countryviews', query ) } className={ clsx( 'stats__flexible-grid-item--full' ) } /> @@ -593,7 +606,7 @@ class StatsSite extends Component { siteId={ siteId } period={ this.props.period } query={ query } - summaryUrl={ this.getStatHref( this.props.period, 'utm', slug ) } + summaryUrl={ this.getStatHref( 'utm', query ) } summary={ false } className={ halfWidthModuleClasses } /> @@ -617,7 +630,7 @@ class StatsSite extends Component { moduleStrings={ moduleStrings.clicks } period={ this.props.period } query={ query } - summaryUrl={ this.getStatHref( this.props.period, 'clicks', slug ) } + summaryUrl={ this.getStatHref( 'clicks', query ) } className={ halfWidthModuleClasses } /> @@ -626,7 +639,7 @@ class StatsSite extends Component { moduleStrings={ moduleStrings.authors } period={ this.props.period } query={ query } - summaryUrl={ this.getStatHref( this.props.period, 'authors', slug ) } + summaryUrl={ this.getStatHref( 'authors', query ) } className={ halfWidthModuleClasses } /> ) } @@ -637,7 +650,7 @@ class StatsSite extends Component { period={ this.props.period } moduleStrings={ moduleStrings.emails } query={ query } - summaryUrl={ this.getStatHref( this.props.period, 'emails', slug ) } + summaryUrl={ this.getStatHref( 'emails', query ) } className={ halfWidthModuleClasses } /> ) } @@ -646,7 +659,7 @@ class StatsSite extends Component { moduleStrings={ moduleStrings.search } period={ this.props.period } query={ query } - summaryUrl={ this.getStatHref( this.props.period, 'searchterms', slug ) } + summaryUrl={ this.getStatHref( 'searchterms', query ) } className={ halfWidthModuleClasses } /> @@ -655,7 +668,7 @@ class StatsSite extends Component { moduleStrings={ moduleStrings.videoplays } period={ this.props.period } query={ query } - summaryUrl={ this.getStatHref( this.props.period, 'videoplays', slug ) } + summaryUrl={ this.getStatHref( 'videoplays', query ) } className={ halfWidthModuleClasses } /> ) } @@ -667,7 +680,7 @@ class StatsSite extends Component { moduleStrings={ moduleStrings.filedownloads } period={ this.props.period } query={ query } - summaryUrl={ this.getStatHref( this.props.period, 'filedownloads', slug ) } + summaryUrl={ this.getStatHref( 'filedownloads', query ) } className={ halfWidthModuleClasses } /> ) diff --git a/client/my-sites/stats/stats-card-upsell/stats-card-upsell-jetpack.tsx b/client/my-sites/stats/stats-card-upsell/stats-card-upsell-jetpack.tsx index 72a5785e9e526..17376127a5783 100644 --- a/client/my-sites/stats/stats-card-upsell/stats-card-upsell-jetpack.tsx +++ b/client/my-sites/stats/stats-card-upsell/stats-card-upsell-jetpack.tsx @@ -6,7 +6,6 @@ import { localizeUrl } from '@automattic/i18n-utils'; import clsx from 'clsx'; import { useTranslate } from 'i18n-calypso'; import React from 'react'; -import InlineSupportLink from 'calypso/components/inline-support-link'; import { SUPPORT_URL, INSIGHTS_SUPPORT_URL } from 'calypso/my-sites/stats/const'; import { useSelector } from 'calypso/state'; import getIsSiteWPCOM from 'calypso/state/selectors/is-site-wpcom'; @@ -54,29 +53,27 @@ const insightsSupportLinkWithAnchor = ( anchor: string ) => { ); }; -const utmLearnMoreLink = ( isOdysseyStats: boolean ) => { - return isOdysseyStats ? ( +const utmLearnMoreLink = () => { + return ( - ) : ( - ); }; -const useUpsellCopy = ( statType: string, isOdysseyStats: boolean ) => { +const useUpsellCopy = ( statType: string ) => { const translate = useTranslate(); switch ( statType ) { case STATS_FEATURE_DATE_CONTROL: return translate( 'Compare different time periods to analyze your site’s growth.' ); case STATS_FEATURE_UTM_STATS: return translate( - 'Generate UTM parameters and track your campaign performance data. {{link}}Learn more{{/link}}.', + 'Generate UTM parameters and track your campaign performance data. {{link}}Learn more{{/link}}', { components: { - link: utmLearnMoreLink( isOdysseyStats ), + link: utmLearnMoreLink(), }, } ); @@ -164,7 +161,7 @@ const useUpsellCopy = ( statType: string, isOdysseyStats: boolean ) => { const StatsCardUpsellJetpack: React.FC< Props > = ( { className, siteId, statType } ) => { const translate = useTranslate(); const isOdysseyStats = config.isEnabled( 'is_running_in_jetpack_site' ); - const copyText = useUpsellCopy( statType, isOdysseyStats ); + const copyText = useUpsellCopy( statType ); const siteSlug = useSelector( ( state ) => getSiteSlug( state, siteId ) ); const isWPCOMSite = useSelector( ( state ) => siteId && getIsSiteWPCOM( state, siteId ) ); diff --git a/client/my-sites/stats/stats-chart-tabs/index.jsx b/client/my-sites/stats/stats-chart-tabs/index.jsx index c54796a39f1dc..74ffefc41263c 100644 --- a/client/my-sites/stats/stats-chart-tabs/index.jsx +++ b/client/my-sites/stats/stats-chart-tabs/index.jsx @@ -2,6 +2,7 @@ import config from '@automattic/calypso-config'; import clsx from 'clsx'; import { localize } from 'i18n-calypso'; import { flowRight } from 'lodash'; +import moment from 'moment'; import PropTypes from 'prop-types'; import { Component } from 'react'; import { connect } from 'react-redux'; @@ -96,7 +97,12 @@ class StatModuleChartTabs extends Component { this.intervalId = setInterval( this.makeQuery, DEFAULT_HEARTBEAT ); } - makeQuery = () => this.props.requestChartCounts( this.props.query ); + makeQuery = () => { + this.props.requestChartCounts( this.props.query ); + this.props.queryComp && this.props.requestChartCounts( this.props.queryComp ); + this.props.queryDay && this.props.requestChartCounts( this.props.queryDay ); + this.props.queryDayComp && this.props.requestChartCounts( this.props.queryDayComp ); + }; render() { const { @@ -106,6 +112,7 @@ class StatModuleChartTabs extends Component { selectedPeriod, isActiveTabLoading, className, + countsComp, showChartHeader = false, } = this.props; const classes = [ @@ -139,6 +146,9 @@ class StatModuleChartTabs extends Component { .components-button { diff --git a/client/my-sites/stats/stats-date-picker/index.jsx b/client/my-sites/stats/stats-date-picker/index.jsx index ee3934ae14e37..8fffe8378ef00 100644 --- a/client/my-sites/stats/stats-date-picker/index.jsx +++ b/client/my-sites/stats/stats-date-picker/index.jsx @@ -33,6 +33,11 @@ class StatsDatePicker extends Component { dateForSummarize() { const { query, moment, translate } = this.props; + + if ( query.start_date ) { + return this.dateForCustomRange(); + } + const localizedDate = moment(); switch ( query.num ) { @@ -51,17 +56,68 @@ class StatsDatePicker extends Component { } } + dateForCustomRange() { + const { query, moment, translate } = this.props; + + const localizedStartDate = moment( query.start_date ); + const localizedEndDate = moment( query.date ); + + return translate( '%(startDate)s ~ %(endDate)s', { + context: 'Date range for which stats are being displayed', + args: { + startDate: localizedStartDate.format( 'll' ), + endDate: localizedEndDate.format( 'll' ), + }, + } ); + } + dateForDisplay() { - const { date, moment, period, translate, isShort } = this.props; + const { date, moment, period, translate, isShort, dateRange } = this.props; + const weekPeriodFormat = isShort ? 'll' : 'LL'; + + // If we have chartStart/chartEnd in dateRange, use those for the date range + if ( dateRange?.chartStart && dateRange?.chartEnd ) { + const startDate = moment( dateRange.chartStart ); + const endDate = moment( dateRange.chartEnd ); + + // If it's the same day, show single date + if ( startDate.isSame( endDate, 'day' ) ) { + return startDate.format( 'LL' ); + } + + // If it's a full month + if ( + startDate.isSame( startDate.clone().startOf( 'month' ), 'day' ) && + endDate.isSame( endDate.clone().endOf( 'month' ), 'day' ) && + startDate.isSame( endDate, 'month' ) + ) { + return startDate.format( 'MMMM YYYY' ); + } + + // If it's a full year + if ( + startDate.isSame( startDate.clone().startOf( 'year' ), 'day' ) && + endDate.isSame( endDate.clone().endOf( 'year' ), 'day' ) && + startDate.isSame( endDate, 'year' ) + ) { + return startDate.format( 'YYYY' ); + } + + // Default to date range + return translate( '%(startDate)s - %(endDate)s', { + context: 'Date range for which stats are being displayed', + args: { + startDate: startDate.format( weekPeriodFormat ), + endDate: endDate.format( weekPeriodFormat ), + }, + } ); + } // Ensure we have a moment instance here to work with. const momentDate = moment.isMoment( date ) ? date : moment( date ); const localizedDate = moment( momentDate.format( 'YYYY-MM-DD' ) ); let formattedDate; - // ll is a date localized with abbreviated Month by momentjs - const weekPeriodFormat = isShort ? 'll' : 'LL'; - switch ( period ) { case 'week': formattedDate = translate( '%(startDate)s - %(endDate)s', { diff --git a/client/my-sites/stats/stats-followers/index.jsx b/client/my-sites/stats/stats-followers/index.jsx index 64583baa89c50..00b0765337f75 100644 --- a/client/my-sites/stats/stats-followers/index.jsx +++ b/client/my-sites/stats/stats-followers/index.jsx @@ -121,7 +121,13 @@ class StatModuleFollowers extends Component { { comment: '{{link}} links to support documentation.', components: { - link: , + link: ( + + ), }, context: 'Stats: Info box label when the Subscribers module is empty', } diff --git a/client/my-sites/stats/stats-module/all-time-nav.jsx b/client/my-sites/stats/stats-module/all-time-nav.jsx index bf78cb33663d7..d8cc8ee29df5a 100644 --- a/client/my-sites/stats/stats-module/all-time-nav.jsx +++ b/client/my-sites/stats/stats-module/all-time-nav.jsx @@ -38,6 +38,9 @@ export const StatsModuleSummaryLinks = ( props ) => { const dispatch = useDispatch(); const getSummaryPeriodLabel = () => { + if ( query.start_date ) { + return translate( 'Custom Range Summary' ); + } switch ( period.period ) { case 'day': return translate( 'Day Summary' ); @@ -62,12 +65,21 @@ export const StatsModuleSummaryLinks = ( props ) => { recordStats( item ); }; + // Template for standard range options (7, 30, Quarter, Year, All Time). const summaryPath = `/stats/day/${ path }/${ siteSlug }?startDate=${ moment().format( 'YYYY-MM-DD' ) }&summarize=1&num=`; - const summaryPeriodPath = `/stats/${ + + // Path for summary or custom range option. ie: The first button in the row. + // Defaults to one day/week/month/year. + let summaryPeriodPath = `/stats/${ period.period }/${ path }/${ siteSlug }?startDate=${ period.endOf.format( 'YYYY-MM-DD' ) }`; + // Override if custom range was used in query. + if ( query.start_date ) { + summaryPeriodPath = `/stats/${ period.period }/${ path }/${ siteSlug }?startDate=${ query.start_date }&endDate=${ query.date }`; + } + const options = [ { value: '0', diff --git a/client/my-sites/stats/stats-module/index.jsx b/client/my-sites/stats/stats-module/index.jsx index d95eb32f9f3a6..fd32bea80c235 100644 --- a/client/my-sites/stats/stats-module/index.jsx +++ b/client/my-sites/stats/stats-module/index.jsx @@ -79,22 +79,26 @@ class StatsModule extends Component { return ; } - getHref() { - const { summary, period, path, siteSlug } = this.props; - - // Some modules do not have view all abilities - if ( ! summary && period && path && siteSlug ) { - return ( - '/stats/' + - period.period + - '/' + - path + - '/' + - siteSlug + - '?startDate=' + - period.startOf.format( 'YYYY-MM-DD' ) - ); + getSummaryLink() { + const { summary, period, path, siteSlug, query } = this.props; + if ( summary ) { + return; } + + const paramsValid = period && path && siteSlug; + if ( ! paramsValid ) { + return undefined; + } + + let url = `/stats/${ period.period }/${ path }/${ siteSlug }`; + + if ( query?.start_date ) { + url += `?startDate=${ query.start_date }&endDate=${ query.date }`; + } else { + url += `?startDate=${ period.endOf.format( 'YYYY-MM-DD' ) }`; + } + + return url; } isAllTimeList() { @@ -166,7 +170,7 @@ class StatsModule extends Component { showMore={ displaySummaryLink && ! summary ? { - url: this.getHref(), + url: this.getSummaryLink(), label: data.length >= 10 ? translate( 'View all', { diff --git a/client/my-sites/stats/stats-period-navigation/index.jsx b/client/my-sites/stats/stats-period-navigation/index.jsx index 64a4808c1ebbe..ff64df385e14f 100644 --- a/client/my-sites/stats/stats-period-navigation/index.jsx +++ b/client/my-sites/stats/stats-period-navigation/index.jsx @@ -28,10 +28,12 @@ import { recordGoogleEvent as recordGoogleEventAction } from 'calypso/state/anal import { isJetpackSite } from 'calypso/state/sites/selectors'; import { toggleUpsellModal } from 'calypso/state/stats/paid-stats-upsell/actions'; import { getSelectedSiteId } from 'calypso/state/ui/selectors'; +import { getMomentSiteZone } from '../hooks/use-moment-site-zone'; import { shouldGateStats } from '../hooks/use-should-gate-stats'; import { withStatsPurchases } from '../hooks/use-stats-purchases'; import NavigationArrows from '../navigation-arrows'; import StatsCardUpsell from '../stats-card-upsell'; +import { getPathWithUpdatedQueryString } from '../utils'; import './style.scss'; @@ -104,6 +106,19 @@ class StatsPeriodNavigation extends PureComponent { return newParams; }; + handleArrowPrevious = () => { + const { date, moment, period, url, queryParams, isEmailStats, maxBars } = this.props; + const numberOfDAys = this.getNumberOfDays( isEmailStats, period, maxBars ); + const usedPeriod = this.calculatePeriod( period ); + const previousDay = moment( date ).subtract( numberOfDAys, usedPeriod ).format( 'YYYY-MM-DD' ); + const newQueryParams = this.queryParamsForPreviousDate( previousDay ); + const previousDayQuery = qs.stringify( Object.assign( {}, queryParams, newQueryParams ), { + addQueryPrefix: true, + } ); + const href = `${ url }${ previousDayQuery }`; + this.handleArrowEvent( 'previous', href ); + }; + handleArrowNext = () => { const { date, moment, period, url, queryParams, isEmailStats, maxBars } = this.props; const numberOfDAys = this.getNumberOfDays( isEmailStats, period, maxBars ); @@ -117,6 +132,39 @@ class StatsPeriodNavigation extends PureComponent { this.handleArrowEvent( 'next', href ); }; + handlePreviousDateRangeNavigation = () => { + this.handleArrowNavigation( true ); + }; + + handleNextRangeDateNavigation = () => { + this.handleArrowNavigation( false ); + }; + + handleArrowNavigation = ( previousOrNext = false ) => { + const { moment, period, slug, dateRange } = this.props; + + const navigationStart = moment( dateRange.chartStart ); + const navigationEnd = moment( dateRange.chartEnd ); + + if ( previousOrNext ) { + // Navigate to the previous date range. + navigationStart.subtract( dateRange.daysInRange, 'days' ); + navigationEnd.subtract( dateRange.daysInRange, 'days' ); + } else { + // Navigate to the next date range. + navigationStart.add( dateRange.daysInRange, 'days' ); + navigationEnd.add( dateRange.daysInRange, 'days' ); + } + + const chartStart = navigationStart.format( 'YYYY-MM-DD' ); + const chartEnd = navigationEnd.format( 'YYYY-MM-DD' ); + + const path = `/stats/${ period }/${ slug }`; + const url = getPathWithUpdatedQueryString( { chartStart, chartEnd }, path ); + + page( url ); + }; + queryParamsForPreviousDate = ( previousDay ) => { const { dateRange, moment } = this.props; // Takes a 'YYYY-MM-DD' string. @@ -138,19 +186,6 @@ class StatsPeriodNavigation extends PureComponent { return newParams; }; - handleArrowPrevious = () => { - const { date, moment, period, url, queryParams, isEmailStats, maxBars } = this.props; - const numberOfDAys = this.getNumberOfDays( isEmailStats, period, maxBars ); - const usedPeriod = this.calculatePeriod( period ); - const previousDay = moment( date ).subtract( numberOfDAys, usedPeriod ).format( 'YYYY-MM-DD' ); - const newQueryParams = this.queryParamsForPreviousDate( previousDay ); - const previousDayQuery = qs.stringify( Object.assign( {}, queryParams, newQueryParams ), { - addQueryPrefix: true, - } ); - const href = `${ url }${ previousDayQuery }`; - this.handleArrowEvent( 'previous', href ); - }; - // Copied from`client/my-sites/stats/stats-chart-tabs/index.jsx` onLegendClick = ( chartItem ) => { const activeLegend = this.props.activeLegend.slice(); @@ -198,9 +233,14 @@ class StatsPeriodNavigation extends PureComponent { gateDateControl, intervals, siteId, + momentSiteZone, } = this.props; - const isToday = moment( date ).isSame( moment(), period ); + const isToday = moment( date ).isSame( momentSiteZone, period ); + + // TODO: Refactor the isWithNewDateFiltering dedicated variables. + const isChartRangeEndToday = moment( dateRange?.chartEnd ).isSame( momentSiteZone, period ); + const showArrowsForDateRange = showArrows && dateRange?.daysInRange <= 31; return (
    - { showArrows && ( + { showArrowsForDateRange && ( ) }
    @@ -407,6 +447,7 @@ const connectComponent = connect( intervals, siteId, isSiteJetpackNotAtomic, + momentSiteZone: getMomentSiteZone( state, siteId ), }; }, { recordGoogleEvent: recordGoogleEventAction, toggleUpsellModal } diff --git a/client/my-sites/stats/stats-strings.js b/client/my-sites/stats/stats-strings.js index 475487f1e6b64..e45d1000a5c4a 100644 --- a/client/my-sites/stats/stats-strings.js +++ b/client/my-sites/stats/stats-strings.js @@ -14,7 +14,13 @@ export default function () { { comment: '{{link}} links to support documentation.', components: { - link: , + link: ( + + ), }, context: 'Stats: Info box label when the Posts & Pages module is empty', } @@ -32,7 +38,13 @@ export default function () { { comment: '{{link}} links to support documentation.', components: { - link: , + link: ( + + ), }, context: 'Stats: Info box label when the Referrers module is empty', } @@ -48,7 +60,9 @@ export default function () { empty: translate( 'Your most {{link}}clicked external links{{/link}} will display here.', { comment: '{{link}} links to support documentation.', components: { - link: , + link: ( + + ), }, context: 'Stats: Info box label when the Clicks module is empty', } ), @@ -65,7 +79,13 @@ export default function () { { comment: '{{link}} links to support documentation.', components: { - link: , + link: ( + + ), }, context: 'Stats: Info box label when the Countries module is empty', } @@ -83,7 +103,13 @@ export default function () { { comment: '{{link}} links to support documentation.', components: { - link: , + link: ( + + ), }, context: 'Stats: Info box label when the UTM module is empty', } @@ -101,7 +127,13 @@ export default function () { empty: translate( 'See {{link}}terms that visitors search{{/link}} to find your site, here. ', { comment: '{{link}} links to support documentation.', components: { - link: , + link: ( + + ), }, context: 'Stats: Info box label when the Search Terms module is empty', } ), @@ -116,7 +148,9 @@ export default function () { empty: translate( '{{link}}Traffic that authors have generated{{/link}} will show here.', { comment: '{{link}} links to support documentation.', components: { - link: , + link: ( + + ), }, context: 'Stats: Info box label when the Authors module is empty', } ), @@ -131,7 +165,9 @@ export default function () { empty: translate( 'Your most viewed {{link}}video stats{{/link}} will show up here.', { comment: '{{link}} links to support documentation.', components: { - link: , + link: ( + + ), }, context: 'Stats: Info box label when the Videos module is empty', } ), @@ -146,7 +182,13 @@ export default function () { empty: translate( 'Stats from any {{link}}downloaded files{{/link}} will display here.', { comment: '{{link}} links to support documentation.', components: { - link: , + link: ( + + ), }, context: 'Stats: Info box label when the file downloads module is empty', } ), @@ -162,7 +204,11 @@ export default function () { comment: '{{link}} links to support documentation.', components: { link: ( - + ), }, context: 'Stats: Info box label when the Tags module is empty', @@ -187,7 +233,9 @@ export default function () { empty: translate( 'Stats from {{link}}your emails{{/link}} will display here.', { comment: '{{link}} links to support documentation.', components: { - link: , + link: ( + + ), }, context: 'Stats: Info box label when the Email Open module is empty', } ), @@ -215,7 +263,13 @@ export default function () { { comment: '{{link}} links to support documentation.', components: { - link: , + link: ( + + ), }, context: 'Stats: Info box label when the Devices module is empty', } diff --git a/client/my-sites/stats/stats-tabs/index.jsx b/client/my-sites/stats/stats-tabs/index.jsx index 5ceae46768103..556a62df7e20b 100644 --- a/client/my-sites/stats/stats-tabs/index.jsx +++ b/client/my-sites/stats/stats-tabs/index.jsx @@ -1,3 +1,5 @@ +import { TrendComparison } from '@automattic/components/src/highlight-cards/count-comparison-card'; +import formatNumber from '@automattic/components/src/number-formatters/lib/format-number'; import clsx from 'clsx'; import { localize } from 'i18n-calypso'; import { find } from 'lodash'; @@ -11,65 +13,88 @@ class StatsTabs extends Component { static displayName = 'StatsTabs'; static propTypes = { - activeKey: PropTypes.string, + children: PropTypes.node, + data: PropTypes.array, + previousData: PropTypes.array, activeIndex: PropTypes.string, - selectedTab: PropTypes.string, - switchTab: PropTypes.func, + activeKey: PropTypes.string, tabs: PropTypes.array, + switchTab: PropTypes.func, + selectedTab: PropTypes.string, borderless: PropTypes.bool, aggregate: PropTypes.bool, }; + formatData = ( data, aggregate = true ) => { + const { activeIndex, activeKey, tabs } = this.props; + let activeData = {}; + if ( ! aggregate ) { + activeData = find( data, { [ activeKey ]: activeIndex } ); + } else { + data?.map( ( day ) => + tabs.map( ( tab ) => { + if ( isFinite( day[ tab.attr ] ) ) { + if ( ! ( tab.attr in activeData ) ) { + activeData[ tab.attr ] = 0; + } + activeData[ tab.attr ] = activeData[ tab.attr ] + day[ tab.attr ]; + } + } ) + ); + } + return activeData; + }; + render() { const { children, data, - activeIndex, - activeKey, + previousData, tabs, switchTab, selectedTab, borderless, aggregate, + tabCountsAlt, + tabCountsAltComp, } = this.props; let statsTabs; if ( data && ! children ) { - let activeData = {}; - if ( ! aggregate ) { - activeData = find( data, { [ activeKey ]: activeIndex } ); - } else { - // TODO: not major but we might want to cache the data. - data.map( ( day ) => - tabs.map( ( tab ) => { - if ( isFinite( day[ tab.attr ] ) ) { - if ( ! ( tab.attr in activeData ) ) { - activeData[ tab.attr ] = 0; - } - activeData[ tab.attr ] = activeData[ tab.attr ] + day[ tab.attr ]; - } - } ) - ); - } + const trendData = this.formatData( data, aggregate ); + const activeData = { ...tabCountsAlt, ...trendData }; + const activePreviousData = { ...tabCountsAltComp, ...this.formatData( previousData ) }; statsTabs = tabs.map( ( tab ) => { - const hasData = - activeData && activeData[ tab.attr ] >= 0 && activeData[ tab.attr ] !== null; + const hasTrend = trendData?.[ tab.attr ] >= 0 && trendData[ tab.attr ] !== null; + const hasData = activeData?.[ tab.attr ] >= 0 && activeData[ tab.attr ] !== null; + const value = hasData ? activeData[ tab.attr ] : null; + const previousValue = + activePreviousData?.[ tab.attr ] !== null ? activePreviousData[ tab.attr ] : null; const tabOptions = { attr: tab.attr, icon: tab.icon, - className: tab.className, + className: clsx( tab.className, { 'is-highlighted': previousData } ), label: tab.label, loading: ! hasData, selected: selectedTab === tab.attr, - tabClick: switchTab, - value: hasData ? activeData[ tab.attr ] : null, + tabClick: hasTrend ? switchTab : undefined, + value, format: tab.format, }; - return ; + return ( + + { previousData && ( +
    + { formatNumber( value ) } + +
    + ) } +
    + ); } ); } diff --git a/client/my-sites/stats/stats-tabs/style.scss b/client/my-sites/stats/stats-tabs/style.scss index 4c476458a2769..27f003f914f45 100644 --- a/client/my-sites/stats/stats-tabs/style.scss +++ b/client/my-sites/stats/stats-tabs/style.scss @@ -4,7 +4,6 @@ $stats-tab-outer-padding: 10px; .stats-tabs { - @include clear-fix; background: var(--color-surface); border-top: 1px solid var(--color-border-subtle); list-style: none; @@ -98,7 +97,6 @@ $stats-tab-outer-padding: 10px; @include breakpoint-deprecated( ">480px" ) { @include mobile-link-element; - @include clear-fix; padding-bottom: $stats-tab-outer-padding; -webkit-touch-callout: none; } diff --git a/client/my-sites/stats/stats-tabs/tab.jsx b/client/my-sites/stats/stats-tabs/tab.jsx index ba68eb54a517e..e37e12a88edad 100644 --- a/client/my-sites/stats/stats-tabs/tab.jsx +++ b/client/my-sites/stats/stats-tabs/tab.jsx @@ -55,22 +55,16 @@ class StatsTabsTab extends Component { return ( // eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-noninteractive-element-interactions -
  • - { hasClickAction ? ( - - { tabIcon } - { tabLabel } - { tabValue } - { children } - - ) : ( - - { tabIcon } - { tabLabel } - { tabValue } - { children } - - ) } +
  • + + { tabIcon } + { tabLabel } + { tabValue } + { children } +
  • ); } diff --git a/client/my-sites/stats/style.scss b/client/my-sites/stats/style.scss index 07d2f9baea74c..e0e4443459ac4 100644 --- a/client/my-sites/stats/style.scss +++ b/client/my-sites/stats/style.scss @@ -47,16 +47,20 @@ $stats-card-min-width: 390px; } } -.stats__sticky-navigation.is-sticky .sticky-panel__content { +.stats__sticky-navigation.is-sticky .sticky-panel__content, .sticky-panel.is-sticky .sticky-panel__content { background: var(--studio-white); box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1), 0 0 56px rgba(0, 0, 0, 0.075); .stats-period-navigation { margin: 9px 0; } +} - .stats-date-picker__refresh-status { - display: none; +.stats__period-header { + padding: 0; + + .is-sticky & { + padding: 0 32px; } } @@ -226,6 +230,11 @@ $stats-card-min-width: 390px; &.color-scheme.is-classic-dark { @include apply-improved-classic-dark-colors(); } + + .sticky-panel.is-sticky { + padding: 0; + } + } .list-emails { diff --git a/client/my-sites/stats/summary/index.jsx b/client/my-sites/stats/summary/index.jsx index a5ee2d078f708..314984d6b239a 100644 --- a/client/my-sites/stats/summary/index.jsx +++ b/client/my-sites/stats/summary/index.jsx @@ -88,6 +88,15 @@ class StatsSummary extends Component { date: endOf.format( 'YYYY-MM-DD' ), max: 0, }; + + // Update query with date range if it provided. + const dateRange = this.props.dateRange; + if ( dateRange ) { + query.start_date = dateRange.startDate.format( 'YYYY-MM-DD' ); + query.date = dateRange.endDate.format( 'YYYY-MM-DD' ); + query.summarize = 1; + } + const moduleQuery = merge( {}, statsQueryOptions, query ); const urlParams = new URLSearchParams( this.props.context.querystring ); const listItemClassName = 'stats__summary--narrow-mobile'; diff --git a/client/sites-dashboard/components/sites-site-name.ts b/client/sites-dashboard/components/sites-site-name.ts index 885bcc32d7c4f..509950df7eb9a 100644 --- a/client/sites-dashboard/components/sites-site-name.ts +++ b/client/sites-dashboard/components/sites-site-name.ts @@ -8,16 +8,11 @@ export const SiteName = styled.a< { fontSize?: number } >` font-weight: 500; font-size: ${ ( props ) => `${ props.fontSize }px` }; letter-spacing: -0.4px; + color: var( --studio-gray-100 ); &:is( a ):hover { text-decoration: underline; } - - &, - &:hover, - &:visited { - color: var( --studio-gray-100 ); - } `; SiteName.defaultProps = { diff --git a/client/sites/components/dotcom-style.scss b/client/sites/components/dotcom-style.scss index 51de8db2a7d2e..5562dca338398 100644 --- a/client/sites/components/dotcom-style.scss +++ b/client/sites/components/dotcom-style.scss @@ -18,10 +18,9 @@ .a4a-layout__body { > * { padding-inline: 48px; - max-width: none; - max-width: 1400px !important; - margin-inline: auto !important; - + // Override the max-width set in a8c-for-agencies/components/layout/style.scss + // as the title aligns with DataViews (full width). + max-width: revert; @media (max-width: 402px) { padding-inline: 24px; } @@ -107,16 +106,6 @@ } } -// Style the sortable table headers. -.wpcom-site .dataviews-view-table .components-button.is-tertiary { - &:active:not(:disabled), - &:hover:not(:disabled) { - box-shadow: none; - background-color: inherit; - color: var(--color-accent) !important; - } -} - .wpcom-site { .layout__content { min-height: 100vh; @@ -392,41 +381,14 @@ display: flex; flex-direction: column; height: 100%; - - .dataviews-wrapper { - .dataviews-pagination { - border: 0; - margin: 0; - padding-left: 0; - padding-right: 0; - position: relative; - } - - .spinner-wrapper { - position: absolute; - left: 50%; - top: 70px; - z-index: 2; - } - } } } .wpcom-site .main.a4a-layout.sites-dashboard.sites-dashboard__layout.preview-hidden { - .dataviews-wrapper { - margin: 0 auto; - max-width: 1400px; - box-sizing: border-box; - - .dataviews-pagination { - width: auto; - } - } - div.a4a-layout__viewport { - margin: 0 auto; - max-width: 1400px; - box-sizing: border-box; + // margin: 0 auto; + // max-width: 1400px; + // box-sizing: border-box; } } diff --git a/client/sites/components/panel/style.scss b/client/sites/components/panel/style.scss index 1544d8ef97824..92944382239c9 100644 --- a/client/sites/components/panel/style.scss +++ b/client/sites/components/panel/style.scss @@ -12,6 +12,10 @@ .header-cake__back { padding: 0; } + + .upsell-nudge { + width: 100%; + } } .panel-section { diff --git a/client/sites/components/site-preview-pane/dotcom-preview-pane.tsx b/client/sites/components/site-preview-pane/dotcom-preview-pane.tsx index 715188eeecd9d..c71f41f192569 100644 --- a/client/sites/components/site-preview-pane/dotcom-preview-pane.tsx +++ b/client/sites/components/site-preview-pane/dotcom-preview-pane.tsx @@ -5,7 +5,7 @@ import { useI18n } from '@wordpress/react-i18n'; import React, { useMemo, useEffect } from 'react'; import ItemPreviewPane from 'calypso/a8c-for-agencies/components/items-dashboard/item-preview-pane'; import HostingFeaturesIcon from 'calypso/hosting/hosting-features/components/hosting-features-icon'; -import { areHostingFeaturesSupported } from 'calypso/sites/features'; +import { areHostingFeaturesSupported } from 'calypso/sites/hosting-features/features'; import { useStagingSite } from 'calypso/sites/tools/staging-site/hooks/use-staging-site'; import { getMigrationStatus } from 'calypso/sites-dashboard/utils'; import { useSelector } from 'calypso/state'; diff --git a/client/sites/components/sites-dashboard.tsx b/client/sites/components/sites-dashboard.tsx index 22fb809bac9d3..83e68be884429 100644 --- a/client/sites/components/sites-dashboard.tsx +++ b/client/sites/components/sites-dashboard.tsx @@ -23,6 +23,7 @@ import LayoutTop from 'calypso/a8c-for-agencies/components/layout/top'; import { GuidedTourContextProvider } from 'calypso/a8c-for-agencies/data/guided-tours/guided-tour-context'; import DocumentHead from 'calypso/components/data/document-head'; import { useSiteExcerptsQuery } from 'calypso/data/sites/use-site-excerpts-query'; +import { recordTracksEvent } from 'calypso/lib/analytics/tracks'; import { isP2Theme } from 'calypso/lib/site/utils'; import { SitesDashboardQueryParams, @@ -76,9 +77,14 @@ const DEFAULT_SITE_TYPE = 'non-p2'; // Limit fields on breakpoints smaller than 960px wide. const desktopFields = [ 'site', 'plan', 'status', 'last-publish', 'stats' ]; const mobileFields = [ 'site' ]; +const listViewFields = [ 'site' ]; -const getFieldsByBreakpoint = ( isDesktop: boolean ) => - isDesktop ? desktopFields : mobileFields; +const getFieldsByBreakpoint = ( selectedSite: boolean, isDesktop: boolean ) => { + if ( selectedSite ) { + return listViewFields; + } + return isDesktop ? desktopFields : mobileFields; +}; export function showSitesPage( route: string ) { const currentParams = new URL( window.location.href ).searchParams; @@ -171,7 +177,7 @@ const SitesDashboard = ( { page, perPage, search: search ?? '', - fields: getFieldsByBreakpoint( isDesktop ), + fields: getFieldsByBreakpoint( !! selectedSite, isDesktop ), ...( status ? { filters: [ @@ -211,7 +217,7 @@ const SitesDashboard = ( { const [ dataViewsState, setDataViewsState ] = useState< View >( defaultDataViewsState ); useEffect( () => { - const fields = getFieldsByBreakpoint( isDesktop ); + const fields = getFieldsByBreakpoint( !! selectedSite, isDesktop ); const fieldsForBreakpoint = [ ...fields ].sort().toString(); const existingFields = [ ...( dataViewsState?.fields ?? [] ) ].sort().toString(); // Compare the content of the arrays, not its referrences that will always be different. @@ -238,7 +244,7 @@ const SitesDashboard = ( { }, } ); } - }, [ isDesktop, isWide, dataViewsState ] ); + }, [ isDesktop, isWide, dataViewsState, selectedSite ] ); // Ensure site sort preference is applied when it loads in. This isn't always available on // initial mount. @@ -336,7 +342,14 @@ const SitesDashboard = ( { } }; - const openSitePreviewPane = ( site: SiteExcerptData ) => { + const openSitePreviewPane = ( + site: SiteExcerptData, + source: 'site_field' | 'action' | 'list_row_click' | 'environment_switcher' + ) => { + recordTracksEvent( 'calypso_sites_dashboard_open_site_preview_pane', { + site_id: site.ID, + source, + } ); showSitesPage( `/${ FEATURE_TO_ROUTE_MAP[ initialSiteFeature ].replace( ':site', site.slug ) }` ); @@ -345,7 +358,7 @@ const SitesDashboard = ( { const changeSitePreviewPane = ( siteId: number ) => { const targetSite = allSites.find( ( site ) => site.ID === siteId ); if ( targetSite ) { - openSitePreviewPane( targetSite ); + openSitePreviewPane( targetSite, 'environment_switcher' ); } }; diff --git a/client/sites/components/sites-dataviews/actions.tsx b/client/sites/components/sites-dataviews/actions.tsx index 2f6e0d6ee1222..d6b5c859ffefe 100644 --- a/client/sites/components/sites-dataviews/actions.tsx +++ b/client/sites/components/sites-dataviews/actions.tsx @@ -1,8 +1,18 @@ import { FEATURE_SFTP, WPCOM_FEATURES_COPY_SITE } from '@automattic/calypso-products'; import page from '@automattic/calypso-router'; +import { + SiteExcerptData, + SITE_EXCERPT_REQUEST_FIELDS, + SITE_EXCERPT_REQUEST_OPTIONS, +} from '@automattic/sites'; +import { useQueryClient } from '@tanstack/react-query'; +import { drawerLeft, wordpress, external } from '@wordpress/icons'; import { useI18n } from '@wordpress/react-i18n'; import { addQueryArgs } from '@wordpress/url'; import { useMemo } from 'react'; +import { USE_SITE_EXCERPTS_QUERY_KEY } from 'calypso/data/sites/use-site-excerpts-query'; +import { navigate } from 'calypso/lib/navigate'; +import useRestoreSiteMutation from 'calypso/sites/hooks/use-restore-site-mutation'; import { getAdminInterface, getPluginsUrl, @@ -13,19 +23,149 @@ import { isNotAtomicJetpack, isP2Site, isSimpleSite, + isDisconnectedJetpackAndNotAtomic, } from 'calypso/sites-dashboard/utils'; -import { useDispatch as useReduxDispatch } from 'calypso/state'; +import { useDispatch as useReduxDispatch, useSelector } from 'calypso/state'; import { recordTracksEvent } from 'calypso/state/analytics/actions'; +import { errorNotice, successNotice } from 'calypso/state/notices/actions'; import { launchSiteOrRedirectToLaunchSignupFlow } from 'calypso/state/sites/launch/actions'; -import type { SiteExcerptData } from '@automattic/sites'; import type { Action } from '@wordpress/dataviews'; -export function useActions(): Action< SiteExcerptData >[] { +export function useActions( { + openSitePreviewPane, + selectedItem, +}: { + openSitePreviewPane?: ( + site: SiteExcerptData, + source: 'site_field' | 'action' | 'list_row_click' | 'environment_switcher' + ) => void; + selectedItem?: SiteExcerptData | null; +} ): Action< SiteExcerptData >[] { const { __ } = useI18n(); const dispatch = useReduxDispatch(); + const queryClient = useQueryClient(); + const reduxDispatch = useReduxDispatch(); + const { mutate: restoreSite } = useRestoreSiteMutation( { + onSuccess() { + queryClient.invalidateQueries( { + queryKey: [ + USE_SITE_EXCERPTS_QUERY_KEY, + SITE_EXCERPT_REQUEST_FIELDS, + SITE_EXCERPT_REQUEST_OPTIONS, + [], + 'all', + ], + } ); + queryClient.invalidateQueries( { + queryKey: [ + USE_SITE_EXCERPTS_QUERY_KEY, + SITE_EXCERPT_REQUEST_FIELDS, + SITE_EXCERPT_REQUEST_OPTIONS, + [], + 'deleted', + ], + } ); + reduxDispatch( + successNotice( __( 'The site has been restored.' ), { + duration: 3000, + } ) + ); + }, + onError: ( error ) => { + if ( error.status === 403 ) { + reduxDispatch( + errorNotice( __( 'Only an administrator can restore a deleted site.' ), { + duration: 5000, + } ) + ); + } else { + reduxDispatch( + errorNotice( __( 'We were unable to restore the site.' ), { duration: 5000 } ) + ); + } + }, + } ); + + const capabilities = useSelector< + { + currentUser: { + capabilities: Record< string, Record< string, boolean > >; + }; + }, + Record< string, Record< string, boolean > > + >( ( state ) => state.currentUser.capabilities ); + return useMemo( () => [ + { + id: 'site-overview', + isPrimary: true, + label: __( 'Overview' ), + icon: drawerLeft, + callback: ( sites ) => { + const site = sites[ 0 ]; + const adminUrl = site.options?.admin_url ?? ''; + const isAdmin = capabilities[ site.ID ]?.manage_options; + if ( + isAdmin && + ! isP2Site( site ) && + ! isNotAtomicJetpack( site ) && + ! isDisconnectedJetpackAndNotAtomic( site ) + ) { + openSitePreviewPane && openSitePreviewPane( site, 'action' ); + } else { + navigate( adminUrl ); + } + }, + isEligible: ( site ) => { + if ( site.ID === selectedItem?.ID ) { + return false; + } + if ( site.is_deleted ) { + return false; + } + return true; + }, + }, + { + id: 'open-site', + isPrimary: true, + label: __( 'Open site' ), + icon: external, + callback: ( sites ) => { + const site = sites[ 0 ]; + const siteUrl = window.open( site.URL, '_blank' ); + if ( siteUrl ) { + siteUrl.opener = null; + siteUrl.focus(); + } + }, + isEligible: ( site ) => { + if ( site.is_deleted ) { + return false; + } + return true; + }, + }, + { + id: 'admin', + isPrimary: true, + label: __( 'WP Admin' ), + icon: wordpress, + callback: ( sites ) => { + const site = sites[ 0 ]; + window.location.href = site.options?.admin_url ?? ''; + dispatch( recordTracksEvent( 'calypso_sites_dashboard_site_action_wpadmin_click' ) ); + }, + isEligible: ( site ) => { + if ( site.is_deleted ) { + return false; + } + return true; + }, + }, + { id: 'launch-site', label: __( 'Launch site' ), @@ -34,6 +174,10 @@ export function useActions(): Action< SiteExcerptData >[] { dispatch( recordTracksEvent( 'calypso_sites_dashboard_site_action_launch_click' ) ); }, isEligible: ( site ) => { + if ( site.is_deleted ) { + return false; + } + const isLaunched = site.launch_status !== 'unlaunched'; const isA4ADevSite = site.is_a4a_dev_site; const isWpcomStagingSite = site.is_wpcom_staging_site; @@ -52,6 +196,10 @@ export function useActions(): Action< SiteExcerptData >[] { ); }, isEligible: ( site ) => { + if ( site.is_deleted ) { + return false; + } + const isLaunched = site.launch_status !== 'unlaunched'; const isA4ADevSite = site.is_a4a_dev_site; const isWpcomStagingSite = site.is_wpcom_staging_site; @@ -67,12 +215,22 @@ export function useActions(): Action< SiteExcerptData >[] { page( getSettingsUrl( sites[ 0 ].slug ) ); dispatch( recordTracksEvent( 'calypso_sites_dashboard_site_action_settings_click' ) ); }, + isEligible: ( site ) => { + if ( site.is_deleted ) { + return false; + } + return true; + }, }, { id: 'general-settings', label: __( 'General settings' ), isEligible: ( site ) => { + if ( site.is_deleted ) { + return false; + } + const adminInterface = getAdminInterface( site ); const isWpAdminInterface = adminInterface === 'wp-admin'; return isWpAdminInterface; @@ -100,6 +258,10 @@ export function useActions(): Action< SiteExcerptData >[] { dispatch( recordTracksEvent( 'calypso_sites_dashboard_site_action_hosting_click' ) ); }, isEligible: ( site ) => { + if ( site.is_deleted ) { + return false; + } + const isSiteJetpackNotAtomic = isNotAtomicJetpack( site ); return ! isSiteJetpackNotAtomic && ! isP2Site( site ); }, @@ -115,6 +277,10 @@ export function useActions(): Action< SiteExcerptData >[] { ); }, isEligible: ( site ) => { + if ( site.is_deleted ) { + return false; + } + return !! site.is_wpcom_atomic; }, }, @@ -135,6 +301,10 @@ export function useActions(): Action< SiteExcerptData >[] { dispatch( recordTracksEvent( 'calypso_sites_dashboard_site_action_plugins_click' ) ); }, isEligible: ( site ) => { + if ( site.is_deleted ) { + return false; + } + return ! isP2Site( site ); }, }, @@ -152,6 +322,10 @@ export function useActions(): Action< SiteExcerptData >[] { dispatch( recordTracksEvent( 'calypso_sites_dashboard_site_action_copy_site_click' ) ); }, isEligible: ( site ) => { + if ( site.is_deleted ) { + return false; + } + const isWpcomStagingSite = site.is_wpcom_staging_site; const shouldShowSiteCopyItem = !! site.plan?.features.active.includes( WPCOM_FEATURES_COPY_SITE ); @@ -177,6 +351,10 @@ export function useActions(): Action< SiteExcerptData >[] { ); }, isEligible: ( site ) => { + if ( site.is_deleted ) { + return false; + } + const adminInterface = getAdminInterface( site ); const isWpAdminInterface = adminInterface === 'wp-admin'; const isClassicSimple = isWpAdminInterface && isSimpleSite( site ); @@ -195,6 +373,10 @@ export function useActions(): Action< SiteExcerptData >[] { ); }, isEligible: ( site ) => { + if ( site.is_deleted ) { + return false; + } + const isLaunched = site.launch_status !== 'unlaunched'; return isLaunched; }, @@ -211,6 +393,10 @@ export function useActions(): Action< SiteExcerptData >[] { ); }, isEligible: ( site ) => { + if ( site.is_deleted ) { + return false; + } + const hasCustomDomain = isCustomDomain( site.slug ); const isSiteJetpackNotAtomic = isNotAtomicJetpack( site ); return hasCustomDomain && ! isSiteJetpackNotAtomic; @@ -218,15 +404,15 @@ export function useActions(): Action< SiteExcerptData >[] { }, { - id: 'admin', - label: __( 'WP Admin' ), + id: 'restore', + label: __( 'Restore' ), callback: ( sites ) => { const site = sites[ 0 ]; - window.location.href = site.options?.admin_url ?? ''; - dispatch( recordTracksEvent( 'calypso_sites_dashboard_site_action_wpadmin_click' ) ); + restoreSite( site.ID ); }, + isEligible: ( site ) => !! site?.is_deleted, }, ], - [ __, dispatch ] + [ __, capabilities, dispatch, openSitePreviewPane, restoreSite, selectedItem?.ID ] ); } diff --git a/client/sites/components/sites-dataviews/dataview-style.scss b/client/sites/components/sites-dataviews/dataview-style.scss index f0b14b9e81b29..d2aa43962c29b 100644 --- a/client/sites/components/sites-dataviews/dataview-style.scss +++ b/client/sites/components/sites-dataviews/dataview-style.scss @@ -37,10 +37,6 @@ background: var(--studio-white); } - tbody tr.dataviews-view-table__row { - cursor: pointer; - } - tr.dataviews-view-table__row { background: var(--studio-white); @@ -132,98 +128,6 @@ color: inherit; } } - - .sites-dataviews__actions { - display: flex; - flex-direction: row; - align-items: center; - justify-content: end; - flex-wrap: nowrap; - - @media (min-width: 1080px) { - .site-actions__actions-large-screen { - float: none; - margin-inline-end: 20px; - } - } - - > *:not(:last-child) { - margin-inline-end: 10px; - } - - .components-dropdown-menu__toggle, - .site-preview__open { - .gridicon { - width: 18px; - height: 18px; - } - } - - &.sites-dataviews__actions-error { - svg { - color: var(--color-accent-5); - } - } - } - - .dataviews-pagination { - flex-shrink: 0; - background: #fff; - border-top: 1px solid #f1f1f1; - border-bottom-left-radius: 4px; - border-bottom-right-radius: 4px; - bottom: 0; - color: var(--Gray-Gray-40, #787c82); - @include a4a-font-body-sm; - justify-content: space-between !important; - padding: 12px 16px 12px 16px; - margin-top: auto; - z-index: 1; - - .components-input-control__backdrop { - border-color: var(--Gray-Gray-5, #dcdcde); - } - - .components-input-control__container { - padding: 0 5px; - } - - @include breakpoint-deprecated( ">1400px" ) { - bottom: 0; - } - } - - // DataView overrides: - // Using the List view for the fly-out panel requires hiding certain elements - // to properly fit the site list when the panel is open. - @media (min-width: $break-large) { - ul.dataviews-view-list { - // This override could potentially be removed if we’re able to pass the Actions column via data.actions - .components-h-stack, - .dataviews-view-list__item { - width: 100%; - } - - // This styling is a bit of a hack: To make the site name clickable area larger, will need - // to hide the other fields when in preview mode. - .dataviews-view-list__field { - // Hide the field except first (Site name) and last (actions menu) - &:not(:first-child) { - display: none; - } - - // Force the site name to take up the full width - &:first-child { - flex-grow: 1; - - .sites-dataviews__site { - width: 100%; - justify-content: flex-start; - } - } - } - } - } } // Selected and hover states on the site list. diff --git a/client/sites/components/sites-dataviews/dataviews-fields/site-field.tsx b/client/sites/components/sites-dataviews/dataviews-fields/site-field.tsx index 1d18f50de5d49..c18eca9ed19e3 100644 --- a/client/sites/components/sites-dataviews/dataviews-fields/site-field.tsx +++ b/client/sites/components/sites-dataviews/dataviews-fields/site-field.tsx @@ -1,7 +1,6 @@ import { ListTile, Button } from '@automattic/components'; import { css } from '@emotion/css'; import styled from '@emotion/styled'; -import { Icon, external } from '@wordpress/icons'; import { useI18n } from '@wordpress/react-i18n'; import clsx from 'clsx'; import { translate } from 'i18n-calypso'; @@ -31,7 +30,10 @@ import type { SiteExcerptData } from '@automattic/sites'; type Props = { site: SiteExcerptData; - openSitePreviewPane?: ( site: SiteExcerptData ) => void; + openSitePreviewPane?: ( + site: SiteExcerptData, + source: 'site_field' | 'action' | 'list_row_click' | 'environment_switcher' + ) => void; }; const SiteListTile = styled( ListTile )` @@ -43,6 +45,13 @@ const SiteListTile = styled( ListTile )` gap: 12px; max-width: 500px; width: 100%; + /* + * Ensures the row fits within the device width on mobile in most cases, + * as it's not apparent to users that they can scroll horizontally. + */ + @media ( max-width: 480px ) { + width: 250px; + } } `; @@ -64,7 +73,7 @@ const SiteField = ( { site, openSitePreviewPane }: Props ) => { } const title = __( 'View Site Details' ); - const { adminLabel, adminUrl } = useSiteAdminInterfaceData( site.ID ); + const { adminUrl } = useSiteAdminInterfaceData( site.ID ); const isP2Site = site.options?.theme_slug && isP2Theme( site.options?.theme_slug ); const isWpcomStagingSite = isStagingSite( site ); @@ -79,7 +88,7 @@ const SiteField = ( { site, openSitePreviewPane }: Props ) => { ! isNotAtomicJetpack( site ) && ! isDisconnectedJetpackAndNotAtomic( site ) ) { - openSitePreviewPane && openSitePreviewPane( site ); + openSitePreviewPane && openSitePreviewPane( site, 'site_field' ); } else { navigate( adminUrl ); } @@ -90,7 +99,7 @@ const SiteField = ( { site, openSitePreviewPane }: Props ) => { const siteTitle = isMigrationPending ? translate( 'Incoming Migration' ) : site.title; return ( -
    +
    + ); }; diff --git a/client/sites/components/sites-dataviews/index.tsx b/client/sites/components/sites-dataviews/index.tsx index 51b042e6eb565..6bebaa232ce6f 100644 --- a/client/sites/components/sites-dataviews/index.tsx +++ b/client/sites/components/sites-dataviews/index.tsx @@ -1,7 +1,8 @@ +import { SiteExcerptData } from '@automattic/sites'; import { usePrevious } from '@wordpress/compose'; import { DataViews, Field } from '@wordpress/dataviews'; import { useI18n } from '@wordpress/react-i18n'; -import { useCallback, useEffect, useMemo, useRef, useLayoutEffect } from 'react'; +import { useCallback, useMemo, useRef, useLayoutEffect } from 'react'; import JetpackLogo from 'calypso/components/jetpack-logo'; import TimeSince from 'calypso/components/time-since'; import { SitePlan } from 'calypso/sites-dashboard/components/sites-site-plan'; @@ -11,7 +12,6 @@ import { useActions } from './actions'; import SiteField from './dataviews-fields/site-field'; import { SiteStats } from './sites-site-stats'; import { SiteStatus } from './sites-site-status'; -import type { SiteExcerptData } from '@automattic/sites'; import type { View } from '@wordpress/dataviews'; import './style.scss'; @@ -24,7 +24,10 @@ type Props = { dataViewsState: View; setDataViewsState: ( callback: ( prevState: View ) => View ) => void; selectedItem: SiteExcerptData | null | undefined; - openSitePreviewPane: ( site: SiteExcerptData ) => void; + openSitePreviewPane: ( + site: SiteExcerptData, + source: 'site_field' | 'action' | 'list_row_click' | 'environment_switcher' + ) => void; }; export function useSiteStatusGroups() { @@ -88,46 +91,28 @@ const DotcomSitesDataViews = ( { // To prevent that, we want to use DataViews in "controlled" mode, so that we can pass an initial selection during initial mount. // // To do that, we need to pass a required `onSelectionChange` callback to signal that it is being used in controlled mode. - // However, when don't need to do anything in the callback, because we already maintain a selectedItem state. // The current selection is a derived value which is [selectedItem.ID] (see getSelection()). - const onSelectionChange = () => {}; + const onSelectionChange = useCallback( + ( selectedSiteIds: string[] ) => { + // In table view, when a row is clicked, the item is selected for a bulk action, so the panel should not open. + if ( dataViewsState.type !== 'list' ) { + return; + } + if ( selectedSiteIds.length === 0 ) { + return; + } + const site = sites.find( ( s ) => s.ID === Number( selectedSiteIds[ 0 ] ) ); + if ( site ) { + openSitePreviewPane( site, 'list_row_click' ); + } + }, + [ dataViewsState.type, openSitePreviewPane, sites ] + ); const getSelection = useCallback( () => ( selectedItem ? [ selectedItem.ID.toString() ] : undefined ), [ selectedItem ] ); - useEffect( () => { - // If the user clicks on a row, open the site preview pane by triggering the site button click. - const handleRowClick = ( event: Event ) => { - const target = event.target as HTMLElement; - const row = target.closest( - '.dataviews-view-table__row, li:has(.dataviews-view-list__item)' - ); - if ( row ) { - const isButtonOrLink = target.closest( 'button, a' ); - if ( ! isButtonOrLink ) { - const button = row.querySelector( - '.sites-dataviews__preview-trigger' - ) as HTMLButtonElement; - if ( button ) { - button.click(); - } - } - } - }; - - const rowsContainer = document.querySelector( '.dataviews-view-table, .dataviews-view-list' ); - if ( rowsContainer ) { - rowsContainer.addEventListener( 'click', handleRowClick as EventListener ); - } - - return () => { - if ( rowsContainer ) { - rowsContainer.removeEventListener( 'click', handleRowClick as EventListener ); - } - }; - }, [] ); - const siteStatusGroups = useSiteStatusGroups(); // Generate DataViews table field-columns @@ -199,7 +184,7 @@ const DotcomSitesDataViews = ( { [ __, openSitePreviewPane, userId, siteStatusGroups ] ); - const actions = useActions(); + const actions = useActions( { openSitePreviewPane, selectedItem } ); return (
    @@ -214,8 +199,6 @@ const DotcomSitesDataViews = ( { selection={ getSelection() } paginationInfo={ paginationInfo } getItemId={ ( item ) => { - // @ts-expect-error -- From ItemsDataViews, this item.id assignation is to fix an issue with the DataViews component and item selection. It should be removed once the issue is fixed. - item.id = item.ID.toString(); return item.ID.toString(); } } isLoading={ isLoading } diff --git a/client/sites/components/sites-dataviews/sites-site-status.tsx b/client/sites/components/sites-dataviews/sites-site-status.tsx index 6a2763d002588..c63a9c6673feb 100644 --- a/client/sites/components/sites-dataviews/sites-site-status.tsx +++ b/client/sites/components/sites-dataviews/sites-site-status.tsx @@ -1,23 +1,12 @@ -import { Button } from '@automattic/components'; -import { - SITE_EXCERPT_REQUEST_FIELDS, - SITE_EXCERPT_REQUEST_OPTIONS, - useSiteLaunchStatusLabel, -} from '@automattic/sites'; +import { useSiteLaunchStatusLabel } from '@automattic/sites'; import styled from '@emotion/styled'; -import { useQueryClient } from '@tanstack/react-query'; -import { Spinner } from '@wordpress/components'; import { useI18n } from '@wordpress/react-i18n'; -import { useDispatch as useReduxDispatch } from 'react-redux'; -import { USE_SITE_EXCERPTS_QUERY_KEY } from 'calypso/data/sites/use-site-excerpts-query'; import { SiteLaunchNag } from 'calypso/sites-dashboard/components/sites-site-launch-nag'; import TransferNoticeWrapper from 'calypso/sites-dashboard/components/sites-transfer-notice-wrapper'; import { WithAtomicTransfer } from 'calypso/sites-dashboard/components/with-atomic-transfer'; import { getMigrationStatus, MEDIA_QUERIES } from 'calypso/sites-dashboard/utils'; import { useSelector } from 'calypso/state'; -import { errorNotice, successNotice } from 'calypso/state/notices/actions'; import isDIFMLiteInProgress from 'calypso/state/selectors/is-difm-lite-in-progress'; -import useRestoreSiteMutation from '../../hooks/use-restore-site-mutation'; import type { SiteExcerptData } from '@automattic/sites'; const BadgeDIFM = styled.span` @@ -42,80 +31,20 @@ const DeletedStatus = styled.div` } `; -const RestoreButton = styled( Button )` - color: var( --color-link ) !important; - font-size: 12px; - text-decoration: underline; -`; - interface SiteStatusProps { site: SiteExcerptData; } export const SiteStatus = ( { site }: SiteStatusProps ) => { const { __ } = useI18n(); - const queryClient = useQueryClient(); - const reduxDispatch = useReduxDispatch(); const translatedStatus = useSiteLaunchStatusLabel( site ); const isDIFMInProgress = useSelector( ( state ) => isDIFMLiteInProgress( state, site.ID ) ); - const { mutate: restoreSite, isPending: isRestoring } = useRestoreSiteMutation( { - onSuccess() { - queryClient.invalidateQueries( { - queryKey: [ - USE_SITE_EXCERPTS_QUERY_KEY, - SITE_EXCERPT_REQUEST_FIELDS, - SITE_EXCERPT_REQUEST_OPTIONS, - [], - 'all', - ], - } ); - queryClient.invalidateQueries( { - queryKey: [ - USE_SITE_EXCERPTS_QUERY_KEY, - SITE_EXCERPT_REQUEST_FIELDS, - SITE_EXCERPT_REQUEST_OPTIONS, - [], - 'deleted', - ], - } ); - reduxDispatch( - successNotice( __( 'The site has been restored.' ), { - duration: 3000, - } ) - ); - }, - onError: ( error ) => { - if ( error.status === 403 ) { - reduxDispatch( - errorNotice( __( 'Only an administrator can restore a deleted site.' ), { - duration: 5000, - } ) - ); - } else { - reduxDispatch( - errorNotice( __( 'We were unable to restore the site.' ), { duration: 5000 } ) - ); - } - }, - } ); - - const handleRestoreSite = () => { - restoreSite( site.ID ); - }; - if ( site.is_deleted ) { return ( { __( 'Deleted' ) } - { isRestoring ? ( - - ) : ( - - { __( 'Restore' ) } - - ) } ); } diff --git a/client/sites/components/sites-dataviews/style.scss b/client/sites/components/sites-dataviews/style.scss index 9aaec4df8e1a6..12f4907bb49b1 100644 --- a/client/sites/components/sites-dataviews/style.scss +++ b/client/sites/components/sites-dataviews/style.scss @@ -7,22 +7,25 @@ overflow: hidden; padding-bottom: 0; - - .dataviews-view-table__row td:last-of-type { - pointer-events: none; - - button, - a { - pointer-events: auto; - } - } - .sites-dataviews__site { display: flex; flex-direction: row; padding-bottom: 8px; padding-top: 8px; + // not to cut off the focus outline + padding-left: 2px; overflow: hidden; + cursor: pointer; + + &:hover, + &:focus { + .sites-dataviews__site-title { + color: var(--color-accent); + } + .sites-dataviews__site-url { + color: var(--color-accent); + } + } .list-tile { margin-right: 0; @@ -35,7 +38,7 @@ } .sites-dataviews__site-name { - align-self: flex-start; + align-self: center; display: inline-block; text-align: left; text-overflow: ellipsis; @@ -52,31 +55,10 @@ white-space: nowrap; } - .dataviews-view-list .sites-dataviews__site-url, - .dataviews-view-list .sites-dataviews__site-wp-admin-url, - .dataviews-view-table .sites-dataviews__site-url, - .dataviews-view-table .sites-dataviews__site-wp-admin-url { + .sites-dataviews__site-url { color: var(--color-neutral-70); font-size: rem(14px); font-weight: 400; - - &:focus { - border-radius: 2px; - box-shadow: 0 0 0 2px var(--color-primary-light); - } - - &:visited { - color: var(--color-neutral-70); - } - - &:hover, - &:hover:visited { - color: var(--color-link); - } - - svg { - vertical-align: sub; - } } .site-sort__clickable { @@ -138,20 +120,12 @@ flex-shrink: 0; } - .sites-dataviews__site-name { - padding: 0; + .sites-dataviews__site { + padding: 2px 0 2px 2px; } - .dataviews-pagination { - .components-base-control { - width: unset !important; - margin-right: 0 !important; - } + .sites-dataviews__site-name { + padding: 0; + align-self: flex-start; } } - -.main.sites-dashboard.sites-dashboard__layout:has(.dataviews-pagination) { - .dataviews-view-table { - margin: 0; - } -} \ No newline at end of file diff --git a/client/sites/components/style.scss b/client/sites/components/style.scss index 6d94e68736665..cf4cdb1416a0e 100644 --- a/client/sites/components/style.scss +++ b/client/sites/components/style.scss @@ -114,10 +114,6 @@ } } - .components-button.is-compact.has-icon:not(.has-text).dataviews-filters-button { - min-width: 40px; - } - .item-preview__content { padding: 10px 10px 88px; /* 88px matches the padding from PR #39201. */ @@ -198,13 +194,6 @@ } } - thead .dataviews-view-table__row th span { - font-size: rem(11px); - font-weight: 500; - line-height: 14px; - color: var(--color-accent-80); - } - .sites-overview__content-wrapper { max-width: none; } diff --git a/client/sites/controller.tsx b/client/sites/controller.tsx index 8790e0a7946e7..e63f7e6291d7b 100644 --- a/client/sites/controller.tsx +++ b/client/sites/controller.tsx @@ -2,13 +2,14 @@ import page from '@automattic/calypso-router'; import { siteLaunchStatusGroupValues } from '@automattic/sites'; import { Global, css } from '@emotion/react'; import { removeQueryArgs } from '@wordpress/url'; +import i18n from 'i18n-calypso'; import AsyncLoad from 'calypso/components/async-load'; import PageViewTracker from 'calypso/lib/analytics/page-view-tracker'; -import { removeNotice } from 'calypso/state/notices/actions'; +import { removeNotice, successNotice } from 'calypso/state/notices/actions'; import { setAllSitesSelected } from 'calypso/state/ui/actions'; import { getSelectedSite } from 'calypso/state/ui/selectors'; import SitesDashboard from './components/sites-dashboard'; -import { areHostingFeaturesSupported } from './features'; +import { areHostingFeaturesSupported } from './hosting-features/features'; import type { Context, Context as PageJSContext } from '@automattic/calypso-router'; const getStatusFilterValue = ( status?: string ) => { @@ -87,10 +88,6 @@ export function sitesDashboard( context: Context, next: () => void ) { } } - .main.sites-dashboard.sites-dashboard__layout:has( .dataviews-pagination ) { - padding-bottom: 0; - } - // Update body margin to account for the sidebar width @media only screen and ( min-width: 782px ) { div.layout.is-global-sidebar-visible { @@ -160,3 +157,22 @@ export function redirectToHostingFeaturesIfNotAtomic( context: PageJSContext, ne next(); } + +export function showHostingFeaturesNoticeIfPresent( context: PageJSContext, next: () => void ) { + // Update the url and show the notice after a redirect + if ( context.query && context.query.hosting_features === 'activated' ) { + context.store.dispatch( + successNotice( i18n.translate( 'Hosting features activated successfully!' ), { + displayOnNextPage: true, + } ) + ); + // Remove query param without triggering a re-render + window.history.replaceState( + null, + '', + removeQueryArgs( window.location.href, 'hosting_features' ) + ); + } + + next(); +} diff --git a/client/sites/hooks/use-restore-site-mutation.ts b/client/sites/hooks/use-restore-site-mutation.ts index dc56fca33c186..02ee01e0706af 100644 --- a/client/sites/hooks/use-restore-site-mutation.ts +++ b/client/sites/hooks/use-restore-site-mutation.ts @@ -12,7 +12,6 @@ interface APIError { interface APIResponse { success: true; } - function restoreSite( siteId: number ) { return wpcom.req.post( { method: 'put', diff --git a/client/sites/hosting-features/components/hosting-activation-button.tsx b/client/sites/hosting-features/components/hosting-activation-button.tsx new file mode 100644 index 0000000000000..228ef8612198c --- /dev/null +++ b/client/sites/hosting-features/components/hosting-activation-button.tsx @@ -0,0 +1,68 @@ +import { FEATURE_SFTP } from '@automattic/calypso-products'; +import page from '@automattic/calypso-router'; +import { Dialog } from '@automattic/components'; +import { addQueryArgs } from '@wordpress/url'; +import { translate } from 'i18n-calypso'; +import { useState } from 'react'; +import EligibilityWarnings from 'calypso/blocks/eligibility-warnings'; +import { HostingHeroButton } from 'calypso/components/hosting-hero'; +import { useSelector, useDispatch } from 'calypso/state'; +import { recordTracksEvent } from 'calypso/state/analytics/actions'; +import { getSelectedSiteId } from 'calypso/state/ui/selectors'; + +interface HostingActivationButtonProps { + redirectUrl?: string; +} + +export default function HostingActivationButton( { redirectUrl }: HostingActivationButtonProps ) { + const dispatch = useDispatch(); + const { searchParams } = new URL( document.location.toString() ); + const showActivationModal = searchParams.get( 'activate' ) !== null; + const [ showEligibility, setShowEligibility ] = useState( showActivationModal ); + + const siteId = useSelector( getSelectedSiteId ); + + const handleTransfer = ( options: { geo_affinity?: string } ) => { + dispatch( recordTracksEvent( 'calypso_hosting_features_activate_confirm' ) ); + const params = new URLSearchParams( { + siteId: String( siteId ), + redirect_to: addQueryArgs( redirectUrl, { + hosting_features: 'activated', + } ), + feature: FEATURE_SFTP, + initiate_transfer_context: 'hosting', + initiate_transfer_geo_affinity: options.geo_affinity || '', + } ); + page( `/setup/transferring-hosted-site?${ params }` ); + }; + + return ( + <> + { + dispatch( recordTracksEvent( 'calypso_hosting_features_activate_click' ) ); + return setShowEligibility( true ); + } } + > + { translate( 'Activate now' ) } + + + setShowEligibility( false ) } + showCloseIcon + > + + + + ); +} diff --git a/client/sites/hosting-features/components/hosting-activation.tsx b/client/sites/hosting-features/components/hosting-activation.tsx new file mode 100644 index 0000000000000..b5460e77ec392 --- /dev/null +++ b/client/sites/hosting-features/components/hosting-activation.tsx @@ -0,0 +1,17 @@ +import { useTranslate } from 'i18n-calypso'; +import { PanelDescription, PanelHeading, PanelSection } from 'calypso/sites/components/panel'; +import HostingActivationButton from './hosting-activation-button'; + +export default function HostingActivation( { redirectUrl }: { redirectUrl: string } ) { + const translate = useTranslate(); + + return ( + + { translate( 'Activate hosting features' ) } + + { translate( 'Activate now to start using this hosting feature.' ) } + + + + ); +} diff --git a/client/sites/features.ts b/client/sites/hosting-features/features.ts similarity index 75% rename from client/sites/features.ts rename to client/sites/hosting-features/features.ts index 9711cfb0e10e2..294f7ce2103b7 100644 --- a/client/sites/features.ts +++ b/client/sites/hosting-features/features.ts @@ -1,4 +1,4 @@ -import { FEATURE_SFTP } from '@automattic/calypso-products'; +import { FEATURE_SFTP, WPCOM_FEATURES_ATOMIC } from '@automattic/calypso-products'; import { SiteExcerptData } from '@automattic/sites'; import { useSelector } from 'calypso/state'; import getSiteFeatures from 'calypso/state/selectors/get-site-features'; @@ -18,6 +18,17 @@ export function useAreHostingFeaturesSupported() { return areHostingFeaturesSupported( site ); } +export function useAreHostingFeaturesSupportedAfterActivation() { + const features = useSelectedSiteSelector( getSiteFeatures ); + const hasAtomicFeature = useSelectedSiteSelector( siteHasFeature, WPCOM_FEATURES_ATOMIC ); + + if ( ! features ) { + return null; + } + + return hasAtomicFeature; +} + export function useAreAdvancedHostingFeaturesSupported() { const site = useSelector( getSelectedSite ); const features = useSelectedSiteSelector( getSiteFeatures ); diff --git a/client/sites/marketing/tools/index.tsx b/client/sites/marketing/tools/index.tsx index 499cfb44d7c5b..8609de40cde8a 100644 --- a/client/sites/marketing/tools/index.tsx +++ b/client/sites/marketing/tools/index.tsx @@ -1,14 +1,17 @@ +import { recordTracksEvent } from '@automattic/calypso-analytics'; import page from '@automattic/calypso-router'; import { Gridicon } from '@automattic/components'; import { localizeUrl } from '@automattic/i18n-utils'; +import Search from '@automattic/search'; +import { isMobile } from '@automattic/viewport'; import { Button } from '@wordpress/components'; +import clsx from 'clsx'; import { useTranslate } from 'i18n-calypso'; -import { Fragment } from 'react'; +import { Fragment, useMemo, useState } from 'react'; import QueryJetpackPlugins from 'calypso/components/data/query-jetpack-plugins'; import PageViewTracker from 'calypso/lib/analytics/page-view-tracker'; import { pluginsPath } from 'calypso/my-sites/marketing/paths'; -import { useDispatch, useSelector } from 'calypso/state'; -import { recordTracksEvent as recordTracksEventAction } from 'calypso/state/analytics/actions'; +import { useSelector } from 'calypso/state'; import { getSelectedSiteId, getSelectedSiteSlug } from 'calypso/state/ui/selectors'; import * as T from 'calypso/types'; import MarketingToolsFeature from './feature'; @@ -19,17 +22,26 @@ import './style.scss'; export default function MarketingTools() { const translate = useTranslate(); - const dispatch = useDispatch(); - const recordTracksEvent = ( event: string ) => dispatch( recordTracksEventAction( event ) ); + const [ searchTerm, setSearchTerm ] = useState( '' ); const selectedSiteSlug: T.SiteSlug | null = useSelector( getSelectedSiteSlug ); const siteId = useSelector( getSelectedSiteId ) || 0; - const marketingFeatures = getMarketingFeaturesData( - selectedSiteSlug, - recordTracksEvent, - translate, - localizeUrl - ); + const marketingFeatures = getMarketingFeaturesData( selectedSiteSlug, translate, localizeUrl ); + + const marketingFeaturesFiltered = useMemo( () => { + return marketingFeatures.filter( + ( feature ) => + feature.title.toLowerCase().includes( searchTerm.toLowerCase() ) || + feature.description.toLowerCase().includes( searchTerm.toLowerCase() ) + ); + }, [ searchTerm, marketingFeatures ] ); + + const handleSearch = ( term: string ) => { + setSearchTerm( term ); + recordTracksEvent( `calypso_marketing_tools_business_tools_search`, { + search_term: term, + } ); + }; const handleBusinessToolsClick = () => { recordTracksEvent( 'calypso_marketing_tools_business_tools_button_click' ); @@ -43,9 +55,26 @@ export default function MarketingTools() { - +
    + +
    - { marketingFeatures.map( ( feature, index ) => { + { marketingFeaturesFiltered.map( ( feature, index ) => { return ( void, translate: ( text: string ) => string, localizeUrl: ( url: string ) => string ): MarketingToolsFeatureData[] => { diff --git a/client/sites/marketing/tools/style.scss b/client/sites/marketing/tools/style.scss index b45dbfc88a93a..da3402e6cfc8a 100644 --- a/client/sites/marketing/tools/style.scss +++ b/client/sites/marketing/tools/style.scss @@ -20,6 +20,33 @@ background-color: #fff; } +.search-component.marketing-tools__searchbox { + box-shadow: 0 0 0 1px var(--studio-gray-10); + + .search-component__input[type="search"] { + padding-left: 16px; + } +} + + + +.marketing-tools__searchbox-container { + position: relative; + width: 250px; + height: 40px; + margin-top: 24px; + margin-bottom: 16px; + &.marketing-tools__searchbox-container--mobile { + width: auto; + margin-left: 16px; + margin-right: 16px; + } +} +.panel-with-sidebar .marketing-tools__searchbox-container--mobile { + margin-left: 0; + margin-right: 0; +} + .tools__header-body { display: flex; flex-direction: row; @@ -29,7 +56,7 @@ flex-wrap: wrap; border-radius: 4px; background-color: var(--color-neutral-0); - box-shadow: none; + box-shadow: none; } .tools__header-info { diff --git a/client/sites/settings/caching/index.tsx b/client/sites/settings/caching/index.tsx index f75eabc39f3cc..15f8bbc5acf12 100644 --- a/client/sites/settings/caching/index.tsx +++ b/client/sites/settings/caching/index.tsx @@ -1,26 +1,68 @@ +import { WPCOM_FEATURES_ATOMIC, getPlanBusinessTitle } from '@automattic/calypso-products'; +import { addQueryArgs } from '@wordpress/url'; import { useTranslate } from 'i18n-calypso'; +import UpsellNudge from 'calypso/blocks/upsell-nudge'; import InlineSupportLink from 'calypso/components/inline-support-link'; import NavigationHeader from 'calypso/components/navigation-header'; -import Notice from 'calypso/components/notice'; -import { useAreHostingFeaturesSupported } from 'calypso/sites/features'; +import { Panel } from 'calypso/sites/components/panel'; +import HostingActivation from 'calypso/sites/hosting-features/components/hosting-activation'; +import { + useAreHostingFeaturesSupported, + useAreHostingFeaturesSupportedAfterActivation, +} from 'calypso/sites/hosting-features/features'; +import { useSelector } from 'calypso/state'; +import { getSelectedSiteId, getSelectedSiteSlug } from 'calypso/state/ui/selectors'; import CachingForm from './form'; -import './style.scss'; - export default function CachingSettings() { const translate = useTranslate(); const isSupported = useAreHostingFeaturesSupported(); + const isSupportedAfterActivation = useAreHostingFeaturesSupportedAfterActivation(); + + const siteId = useSelector( getSelectedSiteId ); + const siteSlug = useSelector( getSelectedSiteSlug ); + + const renderSetting = () => { + if ( isSupported ) { + return ; + } + + if ( isSupportedAfterActivation === null ) { + return null; + } + + const redirectUrl = `/sites/settings/caching/${ siteId }`; + + if ( isSupportedAfterActivation ) { + return ; + } + + const href = addQueryArgs( `/checkout/${ siteId }/business`, { + redirect_to: redirectUrl, + } ); - const renderNotSupportedNotice = () => { return ( - - { translate( 'This setting is not supported for this site.' ) } - + }, + args: { businessPlanName: getPlanBusinessTitle() }, + } + ) } + tracksImpressionName="calypso_settings_caching_upgrade_impression" + event="calypso_settings_caching_upgrade_upsell" + tracksClickName="calypso_settings_caching_upgrade_click" + href={ href } + callToAction={ translate( 'Upgrade' ) } + feature={ WPCOM_FEATURES_ATOMIC } + showIcon + /> ); }; return ( -
    + - { isSupported ? : renderNotSupportedNotice() } -
    + { renderSetting() } + ); } diff --git a/client/sites/settings/caching/style.scss b/client/sites/settings/caching/style.scss deleted file mode 100644 index 16661f0c717a3..0000000000000 --- a/client/sites/settings/caching/style.scss +++ /dev/null @@ -1,5 +0,0 @@ -.tools-caching { - display: flex; - flex-direction: column; - gap: 16px; -} diff --git a/client/sites/settings/controller.tsx b/client/sites/settings/controller.tsx index 8a842ccbff446..cb97be2bed810 100644 --- a/client/sites/settings/controller.tsx +++ b/client/sites/settings/controller.tsx @@ -2,10 +2,7 @@ import { __ } from '@wordpress/i18n'; import { useSelector } from 'react-redux'; import { getSelectedSiteSlug } from 'calypso/state/ui/selectors'; import { SidebarItem, Sidebar, PanelWithSidebar } from '../components/panel-sidebar'; -import { - useAreAdvancedHostingFeaturesSupported, - useAreHostingFeaturesSupported, -} from '../features'; +import { useAreAdvancedHostingFeaturesSupported } from '../hosting-features/features'; import AdministrationSettings from './administration'; import useIsAdministrationSettingSupported from './administration/hooks/use-is-administration-setting-supported'; import DeleteSite from './administration/tools/delete-site'; @@ -22,7 +19,6 @@ export function SettingsSidebar() { const shouldShowAdministration = useIsAdministrationSettingSupported(); - const shouldShowHostingFeatures = useAreHostingFeaturesSupported(); const shouldShowAdvancedHostingFeatures = useAreAdvancedHostingFeaturesSupported(); return ( @@ -34,12 +30,7 @@ export function SettingsSidebar() { > { __( 'Administration' ) } - - { __( 'Caching' ) } - + { __( 'Caching' ) } { - const sitePlans = useSitePlans( { siteId } ); + const sitePlans = useSitePlans( { coupon: undefined, siteId } ); return useMemo( () => diff --git a/packages/data-stores/src/plans/hooks/use-intro-offers.ts b/packages/data-stores/src/plans/hooks/use-intro-offers.ts index 8c0c3ee44a87d..7cccdf2732d2e 100644 --- a/packages/data-stores/src/plans/hooks/use-intro-offers.ts +++ b/packages/data-stores/src/plans/hooks/use-intro-offers.ts @@ -20,7 +20,7 @@ interface Props { * or `undefined` if we haven't observed any metadata yet */ const useIntroOffers = ( { siteId, coupon }: Props ): IntroOffersIndex | undefined => { - const sitePlans = useSitePlans( { siteId } ); + const sitePlans = useSitePlans( { coupon: undefined, siteId } ); const plans = usePlans( { coupon } ); return useMemo( () => { diff --git a/packages/data-stores/src/plans/hooks/use-pricing-meta-for-grid-plans.ts b/packages/data-stores/src/plans/hooks/use-pricing-meta-for-grid-plans.ts index 22f94d4034a95..a110a0f7c389e 100644 --- a/packages/data-stores/src/plans/hooks/use-pricing-meta-for-grid-plans.ts +++ b/packages/data-stores/src/plans/hooks/use-pricing-meta-for-grid-plans.ts @@ -83,7 +83,7 @@ const usePricingMetaForGridPlans = ( { // plans - should have a definition for all plans, being the main source of API data const plans = Plans.usePlans( { coupon } ); // sitePlans - unclear if all plans are included - const sitePlans = Plans.useSitePlans( { siteId } ); + const sitePlans = Plans.useSitePlans( { coupon, siteId } ); const currentPlan = Plans.useCurrentPlan( { siteId } ); const introOffers = Plans.useIntroOffers( { siteId, coupon } ); const purchasedPlan = Purchases.useSitePurchaseById( { @@ -227,7 +227,11 @@ const usePricingMetaForGridPlans = ( { }; // Do not return discounted prices if discount is due to plan proration + // If there is, however, a sale coupon, show the discounted price + // without proration. This isn't ideal, but is intentional. Because of + // this, the price will differ between the plans grid and checkout screen. if ( + ! sitePlan?.pricing?.hasSaleCoupon && ! withProratedDiscounts && sitePlan?.pricing?.costOverrides?.[ 0 ]?.overrideCode === COST_OVERRIDE_REASONS.RECENT_PLAN_PRORATION diff --git a/packages/data-stores/src/plans/queries/lib/use-query-keys-factory.ts b/packages/data-stores/src/plans/queries/lib/use-query-keys-factory.ts index 1cb54697b58ae..bdb5f3671fa6b 100644 --- a/packages/data-stores/src/plans/queries/lib/use-query-keys-factory.ts +++ b/packages/data-stores/src/plans/queries/lib/use-query-keys-factory.ts @@ -1,5 +1,9 @@ const useQueryKeysFactory = () => ( { - sitePlans: ( siteId?: string | number | null ) => [ 'site-plans', siteId ], + sitePlans: ( coupon?: string, siteId?: string | number | null ) => [ + 'site-plans', + siteId, + coupon, + ], plans: ( coupon?: string ) => [ 'plans', coupon ], } ); diff --git a/packages/data-stores/src/plans/queries/use-site-plans.ts b/packages/data-stores/src/plans/queries/use-site-plans.ts index 8a309bbc19a23..15a022a82d845 100644 --- a/packages/data-stores/src/plans/queries/use-site-plans.ts +++ b/packages/data-stores/src/plans/queries/use-site-plans.ts @@ -14,6 +14,11 @@ interface PricedAPISitePlansIndex { } interface Props { + /** + * To match the use-plans hook, `coupon` is required on purpose to mitigate risk of not passing + * something through when we should + */ + coupon: string | undefined; siteId: string | number | null | undefined; } @@ -22,15 +27,18 @@ interface Props { * - Plans from `/sites/[siteId]/plans`, unlike `/plans`, are returned indexed by product_id, and do not include that in the plan's payload. * - UI works with product/plan slugs everywhere, so returned index is transformed to be keyed by product_slug */ -function useSitePlans( { siteId }: Props ): UseQueryResult< SitePlansIndex > { +function useSitePlans( { coupon, siteId }: Props ): UseQueryResult< SitePlansIndex > { const queryKeys = useQueryKeysFactory(); + const params = new URLSearchParams(); + coupon && params.append( 'coupon_code', coupon ); return useQuery( { - queryKey: queryKeys.sitePlans( siteId ), + queryKey: queryKeys.sitePlans( coupon, siteId ), queryFn: async (): Promise< SitePlansIndex > => { const data: PricedAPISitePlansIndex = await wpcomRequest( { path: `/sites/${ encodeURIComponent( siteId as string ) }/plans`, apiVersion: '1.3', + query: params.toString(), } ); return Object.fromEntries( @@ -52,6 +60,7 @@ function useSitePlans( { siteId }: Props ): UseQueryResult< SitePlansIndex > { hasRedeemedDomainCredit: plan?.has_redeemed_domain_credit, purchaseId: plan.id ? Number( plan.id ) : undefined, pricing: { + hasSaleCoupon: plan.has_sale_coupon, currencyCode: plan.currency_code, introOffer: unpackIntroOffer( plan ), costOverrides: unpackCostOverrides( plan ), diff --git a/packages/data-stores/src/plans/types.ts b/packages/data-stores/src/plans/types.ts index 106722bf58c6f..d4d748d7e9098 100644 --- a/packages/data-stores/src/plans/types.ts +++ b/packages/data-stores/src/plans/types.ts @@ -120,6 +120,7 @@ export interface PlanPricing { } export interface SitePlanPricing extends Omit< PlanPricing, 'billPeriod' > { + hasSaleCoupon?: boolean; costOverrides?: CostOverride[]; } @@ -269,6 +270,7 @@ export interface PricedAPIPlan extends PricedAPIPlanPricing, PricedAPIPlanIntrod export interface PricedAPISitePlan extends PricedAPISitePlanPricing, PricedAPIPlanIntroductoryOffer { + has_sale_coupon?: boolean; /* product_id: number; // not included in the plan's payload */ product_slug: StorePlanSlug; current_plan?: boolean; diff --git a/packages/domain-picker/src/utils/index.ts b/packages/domain-picker/src/utils/index.ts index 4fc6ae5d3a35f..085ecf1bdbcc8 100644 --- a/packages/domain-picker/src/utils/index.ts +++ b/packages/domain-picker/src/utils/index.ts @@ -5,6 +5,7 @@ import { WOOEXPRESS_FLOW, DOMAIN_FOR_GRAVATAR_FLOW, isDomainForGravatarFlow, + isHundredYearPlanFlow, isHundredYearDomainFlow, } from '@automattic/onboarding'; import type { DomainSuggestions } from '@automattic/data-stores'; @@ -66,7 +67,7 @@ export function getDomainSuggestionsVendor( if ( isDomainForGravatarFlow( options.flowName ) ) { return 'gravatar'; } - if ( isHundredYearDomainFlow( options.flowName ) ) { + if ( isHundredYearPlanFlow( options.flowName ) || isHundredYearDomainFlow( options.flowName ) ) { return '100-year-domains'; } if ( options.flowName === LINK_IN_BIO_TLD_FLOW ) { diff --git a/packages/help-center/src/components/_variables.scss b/packages/help-center/src/components/_variables.scss index 05992fe3a7568..8cd56a50fa2bb 100644 --- a/packages/help-center/src/components/_variables.scss +++ b/packages/help-center/src/components/_variables.scss @@ -1,2 +1,3 @@ $head-foot-height: 50px; $help-center-z-index: 100000; +$help-center-blue: #3858e9; diff --git a/packages/help-center/src/components/help-center-chat-history.tsx b/packages/help-center/src/components/help-center-chat-history.tsx index b85cef158e562..39c756947a4f7 100644 --- a/packages/help-center/src/components/help-center-chat-history.tsx +++ b/packages/help-center/src/components/help-center-chat-history.tsx @@ -68,12 +68,12 @@ export const HelpCenterChatHistory = () => { const [ conversations, setConversations ] = useState< ZendeskConversation[] >( [] ); const [ selectedTab, setSelectedTab ] = useState( TAB_STATES.recent ); const { getConversations } = useSmooch(); - const { data: supportInteractionsResolved } = useGetSupportInteractions( - 'zendesk', - 100, - 'resolved' - ); - const { data: supportInteractionsOpen } = useGetSupportInteractions( 'zendesk', 10, 'open' ); + const { data: supportInteractionsResolved, isLoading: isLoadingResolvedInteractions } = + useGetSupportInteractions( 'zendesk', 100, 'resolved' ); + const { data: supportInteractionsClosed, isLoading: isLoadingClosedInteractions } = + useGetSupportInteractions( 'zendesk', 100, 'closed' ); + const { data: supportInteractionsOpen, isLoading: isLoadingOpenInteractions } = + useGetSupportInteractions( 'zendesk', 10, 'open' ); const { isChatLoaded, unreadCount } = useSelect( ( select ) => { const store = select( HELP_CENTER_STORE ) as HelpCenterSelect; @@ -89,16 +89,15 @@ export const HelpCenterChatHistory = () => { const { setUnreadCount } = useDataStoreDispatch( HELP_CENTER_STORE ); useEffect( () => { - if ( - isChatLoaded && - getConversations && - ( ( supportInteractionsResolved && supportInteractionsResolved?.length > 0 ) || - ( supportInteractionsOpen && supportInteractionsOpen?.length > 0 ) ) - ) { + const isLoadingInteractions = + isLoadingResolvedInteractions || isLoadingClosedInteractions || isLoadingOpenInteractions; + + if ( isChatLoaded && getConversations && ! isLoadingInteractions ) { const conversations = getConversations(); const supportInteractions = [ ...( supportInteractionsResolved || [] ), ...( supportInteractionsOpen || [] ), + ...( supportInteractionsClosed || [] ), ]; const filteredConversations = getConversationsFromSupportInteractions( diff --git a/packages/help-center/src/components/help-center-chat.tsx b/packages/help-center/src/components/help-center-chat.tsx index a92ef33ad9e81..f9468e4533345 100644 --- a/packages/help-center/src/components/help-center-chat.tsx +++ b/packages/help-center/src/components/help-center-chat.tsx @@ -37,6 +37,8 @@ export function HelpCenterChat( { } }, [] ); + const odieVersion = config.isEnabled( 'help-center-experience' ) ? '14.0.3' : null; + return ( } + version={ odieVersion } >
    diff --git a/packages/help-center/src/components/help-center-header.scss b/packages/help-center/src/components/help-center-header.scss index b06e63e0c8c62..e08e1a8979ba9 100644 --- a/packages/help-center/src/components/help-center-header.scss +++ b/packages/help-center/src/components/help-center-header.scss @@ -89,3 +89,39 @@ } } } + +.clear-conversation__wrapper { + padding: 4px 0; + text-align: center; + + svg { + fill: var(--studio-gray-70); + } + + :hover, :focus { + background-color: $help-center-blue; + color: var(--studio-white); + + svg { + fill: var(--studio-white); + } + } + + button { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + padding: 8px; + cursor: pointer; + gap: 4px; + color: var(--studio-gray-70); + border: none; + background: unset; + + div { + margin-bottom: 2px; + font-size: 0.875rem; + } + } +} \ No newline at end of file diff --git a/packages/help-center/src/components/help-center-header.tsx b/packages/help-center/src/components/help-center-header.tsx index 396bb0700316a..e3e98e5b04ef1 100644 --- a/packages/help-center/src/components/help-center-header.tsx +++ b/packages/help-center/src/components/help-center-header.tsx @@ -12,7 +12,6 @@ import { useI18n } from '@wordpress/react-i18n'; import clsx from 'clsx'; import { Route, Routes, useLocation, useSearchParams } from 'react-router-dom'; import { v4 as uuidv4 } from 'uuid'; -import PopoverMenuItem from 'calypso/components/popover-menu/item'; import { usePostByUrl } from '../hooks'; import { useResetSupportInteraction } from '../hooks/use-reset-support-interaction'; import { DragIcon } from '../icons'; @@ -85,13 +84,12 @@ const ChatEllipsisMenu = () => { popoverClassName="help-center help-center__container-header-menu" position="bottom" > - - - { __( 'Clear Conversation' ) } - +
    + +
    ); }; diff --git a/packages/odie-client/src/components/closed-conversation-footer/index.tsx b/packages/odie-client/src/components/closed-conversation-footer/index.tsx new file mode 100644 index 0000000000000..3b7792df107eb --- /dev/null +++ b/packages/odie-client/src/components/closed-conversation-footer/index.tsx @@ -0,0 +1,37 @@ +import { Button } from '@wordpress/components'; +import { comment, Icon } from '@wordpress/icons'; +import { useI18n } from '@wordpress/react-i18n'; +import { v4 as uuidv4 } from 'uuid'; +import { useOdieAssistantContext } from '../../context'; +import { useManageSupportInteraction } from '../../data'; +import './style.scss'; + +export const ClosedConversationFooter = () => { + const { __ } = useI18n(); + const { trackEvent, chat, shouldUseHelpCenterExperience } = useOdieAssistantContext(); + + const { startNewInteraction } = useManageSupportInteraction(); + + const handleOnClick = async () => { + trackEvent( 'chat_new_from_closed_conversation' ); + await startNewInteraction( { + event_source: 'help-center', + event_external_id: uuidv4(), + } ); + }; + + if ( ! shouldUseHelpCenterExperience || chat.status !== 'closed' ) { + return null; + } + + return ( +
    + { __( 'This conversation has been completed', __i18n_text_domain__ ) } + + +
    + ); +}; diff --git a/packages/odie-client/src/components/closed-conversation-footer/style.scss b/packages/odie-client/src/components/closed-conversation-footer/style.scss new file mode 100644 index 0000000000000..37dd65a94abff --- /dev/null +++ b/packages/odie-client/src/components/closed-conversation-footer/style.scss @@ -0,0 +1,56 @@ +@import "@automattic/typography/styles/variables"; + +.odie-closed-conversation-footer { + border-top: 1px solid var(--studio-gray-5, #DCDCDE); + background: var(--studio-white, #FFF); + box-sizing: border-box; + + width: 100%; + display: flex; + padding: 16px; + flex-direction: column; + align-items: center; + gap: 10px; + + span { + color: var(--studio-gray-40, #787C82); + font-size: $font-body-small; + line-height: 20px; + } + + .odie-closed-conversation-footer__button { + display: flex; + height: 40px; + padding: 10px 24px; + justify-content: center; + align-items: center; + gap: 10px; + align-self: stretch; + + border-radius: 4px; + border: 1px solid var(--studio-gray-10, #C3C4C7); + background: var(--studio-white, #FFF); + color: var(--studio-gray-100, #101517); + + svg { + color: var(--studio-gray-40); + } + + &:hover { + border-color: var(--color-neutral-20) !important; + } + + &:focus { + outline: var(--studio-blue-50, #0675c4) solid 2px !important; + } + + &:focus, + &:hover { + color: var(--studio-gray-100, #101517) !important; + + svg { + color: var(--studio-gray-40); + } + } + } +} diff --git a/packages/odie-client/src/components/message/messages-container.tsx b/packages/odie-client/src/components/message/messages-container.tsx index 915295d719bca..db97142a193e9 100644 --- a/packages/odie-client/src/components/message/messages-container.tsx +++ b/packages/odie-client/src/components/message/messages-container.tsx @@ -44,7 +44,7 @@ export const MessagesContainer = ( { currentUser }: ChatMessagesProps ) => { useZendeskMessageListener(); useAutoScroll( messagesContainerRef ); useEffect( () => { - chat?.status === 'loaded' && setChatLoaded( true ); + ( chat?.status === 'loaded' || chat?.status === 'closed' ) && setChatLoaded( true ); }, [ chat ] ); const shouldLoadChat: boolean = diff --git a/packages/odie-client/src/components/message/style_redesign.scss b/packages/odie-client/src/components/message/style_redesign.scss index 9c04b50bc7d15..8d696aa7bcdc1 100644 --- a/packages/odie-client/src/components/message/style_redesign.scss +++ b/packages/odie-client/src/components/message/style_redesign.scss @@ -463,7 +463,7 @@ font-size: $font-body; line-height: 1; appearance: none; - background-color: var(--color-primary); + background-color: $blueberry-color; color: var(--studio-white); border: 1px none; display: flex; @@ -480,6 +480,16 @@ } } + .odie-send-message-input-container { + textarea.odie-send-message-input { + &:focus { + box-shadow: 0 0 0 2px $blueberry-color; + outline: none; + border-color: transparent; + } + } + } + .odie-chatbox-message-avatar-wapuu-liked { -webkit-animation: wapuu-joy-animation 1300ms both; animation: wapuu-joy-animation 1300ms both; @@ -636,4 +646,8 @@ text-align: center; padding-top: 6px; } + + .odie-send-message-input-spinner { + color: $blueberry-color !important; + } } diff --git a/packages/odie-client/src/data/use-odie-chat.ts b/packages/odie-client/src/data/use-odie-chat.ts index 7daea78433fb6..f6b95aafcf1c7 100644 --- a/packages/odie-client/src/data/use-odie-chat.ts +++ b/packages/odie-client/src/data/use-odie-chat.ts @@ -2,26 +2,33 @@ import { useQuery } from '@tanstack/react-query'; import apiFetch from '@wordpress/api-fetch'; import wpcomRequest, { canAccessWpcomApis } from 'wpcom-proxy-request'; import { useOdieAssistantContext } from '../context'; -import { OdieChat, ReturnedChat } from '../types'; +import type { OdieChat, ReturnedChat } from '../types'; /** * Get the ODIE chat and manage the cache to save on API calls. */ export const useOdieChat = ( chatId: number | null ) => { - const { botNameSlug } = useOdieAssistantContext(); + const { botNameSlug, version } = useOdieAssistantContext(); return useQuery< OdieChat, Error >( { - queryKey: [ 'odie-chat', botNameSlug, chatId ], + queryKey: [ 'odie-chat', botNameSlug, chatId, version ], queryFn: async (): Promise< OdieChat > => { + const queryParams = new URLSearchParams( { + page_number: '1', + items_per_page: '30', + include_feedback: 'true', + ...( version && { version } ), + } ).toString(); + const data = ( canAccessWpcomApis() ? await wpcomRequest( { method: 'GET', - path: `/odie/chat/${ botNameSlug }/${ chatId }?page_number=1&items_per_page=30&include_feedback=true`, + path: `/odie/chat/${ botNameSlug }/${ chatId }?${ queryParams }`, apiNamespace: 'wpcom/v2', } ) : await apiFetch( { - path: `/help-center/odie/chat/${ botNameSlug }/${ chatId }?page_number=1&items_per_page=30&include_feedback=true`, + path: `/help-center/odie/chat/${ botNameSlug }/${ chatId }?${ queryParams }`, method: 'GET', } ) ) as ReturnedChat; diff --git a/packages/odie-client/src/data/use-send-odie-feedback.ts b/packages/odie-client/src/data/use-send-odie-feedback.ts index 4cfd65d23b819..4612c379983f5 100644 --- a/packages/odie-client/src/data/use-send-odie-feedback.ts +++ b/packages/odie-client/src/data/use-send-odie-feedback.ts @@ -8,7 +8,7 @@ import type { Chat } from '../types'; * @returns useMutation return object. */ export const useSendOdieFeedback = () => { - const { botNameSlug, chat } = useOdieAssistantContext(); + const { botNameSlug, chat, version } = useOdieAssistantContext(); const queryClient = useQueryClient(); return useMutation( { @@ -17,7 +17,7 @@ export const useSendOdieFeedback = () => { method: 'POST', path: `/odie/chat/${ botNameSlug }/${ chat.odieId }/${ messageId }/feedback`, apiNamespace: 'wpcom/v2', - body: { rating_value: ratingValue }, + body: { rating_value: ratingValue, ...( version && { version } ) }, } ); }, onSuccess: ( data, { messageId, ratingValue } ) => { diff --git a/packages/odie-client/src/data/use-send-odie-message.ts b/packages/odie-client/src/data/use-send-odie-message.ts index 8d879a23b4ab7..5ac0ac157a755 100644 --- a/packages/odie-client/src/data/use-send-odie-message.ts +++ b/packages/odie-client/src/data/use-send-odie-message.ts @@ -61,12 +61,20 @@ export const useSendOdieMessage = () => { method: 'POST', path: `/odie/chat/${ botNameSlug }${ chatIdSegment }`, apiNamespace: 'wpcom/v2', - body: { message: message.content, version, context: { selectedSiteId } }, + body: { + message: message.content, + ...( version && { version } ), + context: { selectedSiteId }, + }, } ) : await apiFetch( { path: `/help-center/odie/chat/${ botNameSlug }${ chatIdSegment }`, method: 'POST', - data: { message: message.content, version, context: { selectedSiteId } }, + data: { + message: message.content, + ...( version && { version } ), + context: { selectedSiteId }, + }, } ); }, onMutate: () => { diff --git a/packages/odie-client/src/hooks/use-get-combined-chat.ts b/packages/odie-client/src/hooks/use-get-combined-chat.ts index 759fd0b673f67..4dbc4dc3cfefc 100644 --- a/packages/odie-client/src/hooks/use-get-combined-chat.ts +++ b/packages/odie-client/src/hooks/use-get-combined-chat.ts @@ -62,7 +62,7 @@ export const useGetCombinedChat = ( shouldUseHelpCenterExperience: boolean | und ...( conversation.messages as Message[] ), ], provider: 'zendesk', - status: 'loaded', + status: currentSupportInteraction?.status === 'closed' ? 'closed' : 'loaded', } ); } } ); diff --git a/packages/odie-client/src/index.tsx b/packages/odie-client/src/index.tsx index ef0a310fafc71..f60948866338c 100644 --- a/packages/odie-client/src/index.tsx +++ b/packages/odie-client/src/index.tsx @@ -1,5 +1,9 @@ +import { HelpCenterSelect } from '@automattic/data-stores'; +import { HELP_CENTER_STORE } from '@automattic/help-center/src/stores'; +import { useSelect } from '@wordpress/data'; import clsx from 'clsx'; import { useEffect } from 'react'; +import { ClosedConversationFooter } from './components/closed-conversation-footer'; import { MessagesContainer } from './components/message/messages-container'; import { OdieSendMessageButton } from './components/send-message-input'; import { useOdieAssistantContext, OdieAssistantProvider } from './context'; @@ -8,6 +12,12 @@ import './style.scss'; export const OdieAssistant: React.FC = () => { const { trackEvent, shouldUseHelpCenterExperience, currentUser } = useOdieAssistantContext(); + const { currentSupportInteraction } = useSelect( ( select ) => { + const store = select( HELP_CENTER_STORE ) as HelpCenterSelect; + return { + currentSupportInteraction: store.getCurrentSupportInteraction(), + }; + }, [] ); useEffect( () => { trackEvent( 'chatbox_view' ); @@ -23,7 +33,8 @@ export const OdieAssistant: React.FC = () => {
    - + { currentSupportInteraction?.status !== 'closed' && } + { currentSupportInteraction?.status === 'closed' && }
    ); }; diff --git a/packages/odie-client/src/types.ts b/packages/odie-client/src/types.ts index 4a510af79a617..012cddc996eca 100644 --- a/packages/odie-client/src/types.ts +++ b/packages/odie-client/src/types.ts @@ -148,7 +148,7 @@ export type Message = { created_at?: string; }; -export type ChatStatus = 'loading' | 'loaded' | 'sending' | 'dislike' | 'transfer'; +export type ChatStatus = 'loading' | 'loaded' | 'sending' | 'dislike' | 'transfer' | 'closed'; export type ReturnedChat = { chat_id: number; messages: Message[]; wpcom_user_id: number }; diff --git a/packages/onboarding/src/utils/flows.ts b/packages/onboarding/src/utils/flows.ts index 48275c22f5031..a8972468615db 100644 --- a/packages/onboarding/src/utils/flows.ts +++ b/packages/onboarding/src/utils/flows.ts @@ -208,6 +208,10 @@ export const isDomainForGravatarFlow = ( flowName: string | null | undefined ) = return Boolean( flowName && [ DOMAIN_FOR_GRAVATAR_FLOW ].includes( flowName ) ); }; +export const isHundredYearPlanFlow = ( flowName: string | null | undefined ) => { + return Boolean( flowName && [ HUNDRED_YEAR_PLAN_FLOW ].includes( flowName ) ); +}; + export const isHundredYearDomainFlow = ( flowName: string | null | undefined ) => { return Boolean( flowName && [ HUNDRED_YEAR_DOMAIN_FLOW ].includes( flowName ) ); }; diff --git a/packages/plans-grid-next/src/types.ts b/packages/plans-grid-next/src/types.ts index 6cc5c4f0c5568..208b27d0e6c71 100644 --- a/packages/plans-grid-next/src/types.ts +++ b/packages/plans-grid-next/src/types.ts @@ -264,7 +264,6 @@ export type PlanTypeSelectorProps = { basePlansPath?: string | null; intervalType: UrlFriendlyTermType; customerType: string; - withDiscount?: string; enableStickyBehavior?: boolean; stickyPlanTypeSelectorOffset?: number; onPlanIntervalUpdate: ( interval: SupportedUrlFriendlyTermType ) => void;