diff --git a/e2e-tests/specs/onboarding.spec.js b/e2e-tests/specs/onboarding.spec.js index d737ce93..604409cf 100644 --- a/e2e-tests/specs/onboarding.spec.js +++ b/e2e-tests/specs/onboarding.spec.js @@ -48,9 +48,12 @@ test.describe('Onboarding', () => { expect(await page.locator('.ss-card .ss-badge').count()).toBeGreaterThan(0); // 'All' and 'Free' should show after you select a category. + // Match exactly: card page-shot buttons (e.g. "Gallery", "Ballet Blog", + // "All Courses") also contain "all" as a substring, so a non-exact name + // match resolves to multiple elements once the grid renders a full page. await page.locator('.ob-cat-wrap').first().click(); - await expect(page.getByRole('button', { name: 'All' })).toBeVisible(); - await expect(page.getByRole('button', { name: 'Free' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'All', exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Free', exact: true })).toBeVisible(); // Check card structure. const firstListedSiteCard = page.locator('.ss-card-wrap').first(); @@ -62,41 +65,6 @@ test.describe('Onboarding', () => { await expect(firstListedSiteCard.locator('.ss-title')).not.toBeEmpty(); }); - test('Onboarding promo notice can be dismissed and stays hidden after reload', async ({ page, admin }) => { - await admin.visitAdminPage(ONBOARDING_URL); - - const promoNotice = page.locator('.ob-onboarding-promo'); - await expect(promoNotice).toBeVisible(); - - // The client sends the dismiss action via FormData, which fetch encodes as - // multipart/form-data. Inspect the raw body bytes so the predicate works - // regardless of whether the encoding is multipart or url-encoded. - const isDismissCall = (request) => - request.url().includes('admin-ajax.php') && - request.method() === 'POST' && - (request.postDataBuffer()?.toString('utf8') ?? '').includes( - 'dismiss_onboarding_promo_notice' - ); - - const dismissRequest = page.waitForRequest(isDismissCall); - const dismissResponse = page.waitForResponse((response) => - isDismissCall(response.request()) - ); - - await promoNotice.getByRole('button', { name: 'Dismiss notice' }).click(); - - const request = await dismissRequest; - const response = await dismissResponse; - expect(request.postDataBuffer()?.toString('utf8')).toContain( - 'dismiss_onboarding_promo_notice' - ); - expect(response.ok()).toBeTruthy(); - - await expect(promoNotice).toBeHidden(); - await page.reload(); - await expect(promoNotice).toBeHidden(); - }); - test('Site Import Customization Rendering', async ({ page, admin }) => { await admin.visitAdminPage(ONBOARDING_URL); await openFirstSiteAndWaitForData( page ); diff --git a/includes/Admin.php b/includes/Admin.php index 625c1a2d..e8cd0ac9 100755 --- a/includes/Admin.php +++ b/includes/Admin.php @@ -24,10 +24,9 @@ class Admin { const IMPORTED_TEMPLATES_COUNT_OPT = 'tiob_premade_imported'; const FEEDBACK_DISMISSED_OPT = 'tiob_feedback_dismiss'; - const TC_REMOVED_KEY = 'tiob_tc_removed'; - const TC_NEW_NOTICE_DISMISSED = 'tiob_new_tc_notice_dismissed'; - const ONBOARDING_PROMO_NOTICE_DISMISSED = 'tiob_onboarding_promo_notice_dismissed'; - const VISITED_LIBRARY_OPT = 'tiob_library_visited'; + const TC_REMOVED_KEY = 'tiob_tc_removed'; + const TC_NEW_NOTICE_DISMISSED = 'tiob_new_tc_notice_dismissed'; + const VISITED_LIBRARY_OPT = 'tiob_library_visited'; /** * Admin page slug @@ -89,7 +88,6 @@ public function init() { add_action( 'wp_ajax_tpc_get_logs', array( $this, 'external_get_logs' ) ); add_action( 'wp_ajax_dismiss_new_tc_notice', array( $this, 'dismiss_new_tc_notice' ) ); - add_action( 'wp_ajax_dismiss_onboarding_promo_notice', array( $this, 'dismiss_onboarding_promo_notice' ) ); $this->register_feedback_settings(); @@ -163,52 +161,6 @@ public function dismiss_new_tc_notice() { $this->ensure_ajax_response( $response ); } - /** - * Dismiss onboarding promo notice. - * - * @return void - */ - public function dismiss_onboarding_promo_notice() { - $response = array( - 'success' => false, - 'code' => 'ti__ob_not_allowed', - 'message' => 'Not allowed!', - ); - - if ( ! isset( $_REQUEST['nonce'] ) ) { - $this->ensure_ajax_response( $response ); - return; - } - - $nonce = sanitize_text_field( wp_unslash( $_REQUEST['nonce'] ) ); - - if ( ! wp_verify_nonce( $nonce, 'dismiss_onboarding_promo_notice' ) ) { - $this->ensure_ajax_response( $response ); - return; - } - - if ( ! current_user_can( 'install_plugins' ) ) { - $this->ensure_ajax_response( $response ); - return; - } - - $response['success'] = true; - unset( $response['code'] ); - unset( $response['message'] ); - - update_option( self::ONBOARDING_PROMO_NOTICE_DISMISSED, 'yes' ); - $this->ensure_ajax_response( $response ); - } - - /** - * Decide if the onboarding promo notice should be shown. - * - * @return bool - */ - private function should_show_onboarding_promo_notice() { - return get_option( self::ONBOARDING_PROMO_NOTICE_DISMISSED, 'no' ) !== 'yes'; - } - /** * Decide if the business/agency variant of the onboarding promo text should be shown. * @@ -937,11 +889,6 @@ private function get_localization() { 'ajaxURL' => esc_url( admin_url( 'admin-ajax.php' ) ), 'nonce' => wp_create_nonce( 'dismiss_new_tc_notice' ), ), - 'onboardingPromoNotice' => array( - 'show' => $this->should_show_onboarding_promo_notice(), - 'ajaxURL' => esc_url( admin_url( 'admin-ajax.php' ) ), - 'nonce' => wp_create_nonce( 'dismiss_onboarding_promo_notice' ), - ), 'onboardingPluginCompatibility' => array( 'hyve-lite' => is_php_version_compatible( '8.1' ), ), diff --git a/includes/Starter_Ranking.php b/includes/Starter_Ranking.php index 45032577..19bc8a6c 100644 --- a/includes/Starter_Ranking.php +++ b/includes/Starter_Ranking.php @@ -89,8 +89,9 @@ private static function fetch_order( $builder, $query = null, $budget = null ) { $deadline = time() + ( null !== $budget ? (int) $budget : self::REQUEST_BUDGET ); $start = self::base_url() . '/api/workflows/' . self::SLUG . '/start'; $body = array( - 'site_url' => home_url(), - 'builder' => $builder, + 'site_url' => home_url(), + 'site_title' => sanitize_text_field( get_bloginfo( 'name' ) ), + 'builder' => $builder, ); if ( null !== $query && '' !== $query ) { diff --git a/onboarding/src/Components/OnboardingPromoNotice.js b/onboarding/src/Components/OnboardingPromoNotice.js deleted file mode 100644 index 9ddfee31..00000000 --- a/onboarding/src/Components/OnboardingPromoNotice.js +++ /dev/null @@ -1,96 +0,0 @@ -/* global tiobDash */ -import { __, sprintf } from '@wordpress/i18n'; -import { createInterpolateElement, useState } from '@wordpress/element'; -import { ajaxAction } from '../utils/rest'; - -const OnboardingPromoNotice = () => { - const shouldShowNotice = Boolean( tiobDash.onboardingPromoNotice?.show ); - const showProMessage = Boolean( tiobDash.onboardingShowProNoticeText ); - - const emailBody = sprintf( - /* translators: %s: double line break in the starter site request email template */ - __( - 'Hi Neve team,%1$sI\'m looking for a starter site for the following project:%1$sProject type: (e.g. Restaurant, Law Firm, SaaS)%1$sKey pages needed: (e.g. Home, About, Services, Contact)%1$sStyle preference: (e.g. Minimal, Bold, Corporate)%1$sAny references: (optional)%1$sThanks', - 'templates-patterns-collection' - ), - '\n\n' - ); - - const requestSiteLink = - 'mailto:contact@themeisle.com?subject=' + - encodeURIComponent( - __( 'Starter Site Request', 'templates-patterns-collection' ) - ) + - '&body=' + - encodeURIComponent( emailBody ); - - const noticeMessage = showProMessage - ? createInterpolateElement( - __( - 'Fresh designs built for every niche. Can\'t find what you\'re looking for? As a Pro user, request a site and we\'ll build it for you.', - 'templates-patterns-collection' - ), - { - requestSiteLink: ( - /* eslint-disable-next-line jsx-a11y/anchor-has-content */ - - ), - } - ) - : __( - 'From free to pro, fresh designs built for every niche. More coming soon.', - 'templates-patterns-collection' - ); - - const [ isVisible, setIsVisible ] = useState( shouldShowNotice ); - - if ( ! isVisible ) { - return null; - } - - const dismissNotice = () => { - setIsVisible( false ); - ajaxAction( - tiobDash.onboardingPromoNotice.ajaxURL, - 'dismiss_onboarding_promo_notice', - tiobDash.onboardingPromoNotice.nonce - ).catch( () => null ); - }; - - return ( -
-
- { __( 'New', 'templates-patterns-collection' ) } -
-
-

- { sprintf( - /* translators: %s: number of new starter sites */ - __( - '%s new starter sites, just landed.', - 'templates-patterns-collection' - ), - '80+' - ) } -

-

{ noticeMessage }

-
- -
- ); -}; - -export default OnboardingPromoNotice; diff --git a/onboarding/src/Components/Sites.js b/onboarding/src/Components/Sites.js index a0ef9747..efee2918 100644 --- a/onboarding/src/Components/Sites.js +++ b/onboarding/src/Components/Sites.js @@ -27,11 +27,11 @@ const Sites = ( { sortBy, selectedColors, } ) => { - const [ maxShown, setMaxShown ] = useState( 9 ); + const [ maxShown, setMaxShown ] = useState( 12 ); const { sites = {} } = getSites; useEffect( () => { - setMaxShown( 9 ); + setMaxShown( 12 ); }, [ editor, category, searchQuery, sortBy, selectedColors ] ); const getBuilders = () => Object.keys( sites ); @@ -218,9 +218,9 @@ const Sites = ( { const rest = filterByColors( filterByCategory( ranked, category ) ).filter( ( site ) => site && site.slug && ! inMatches[ site.slug ] ); - const remainder = matches.length % 3; + const remainder = matches.length % 4; const pad = - remainder === 0 ? 0 : Math.min( 3 - remainder, rest.length ); + remainder === 0 ? 0 : Math.min( 4 - remainder, rest.length ); return { list: [ ...matches, ...rest ], @@ -280,7 +280,7 @@ const Sites = ( { return false; } - setMaxShown( ( shown ) => shown + 9 ); + setMaxShown( ( shown ) => shown + 12 ); } } > { const { upsell, @@ -93,6 +94,32 @@ const StarterSiteCard = ( { screenshot: screenshotMap[ color ] || screenshot, } ) ); }, [ data?.colors, data?.screenshots_by_color, screenshot ] ); + // First globally-selected color this card actually has, returned as the + // canonical RAW colorOptions slug so option.slug === activeColor still hits. + // selectedColors are normalized lowercase (Filters.js) while colorOptions + // slugs / screenshots_by_color keys are raw, so match case-insensitively. + const filterColor = useMemo( () => { + if ( + ! Array.isArray( selectedColors ) || + ! selectedColors.length || + ! colorOptions.length + ) { + return ''; + } + + const normalizedToSlug = new Map( + colorOptions.map( ( option ) => [ + String( option.slug || '' ).trim().toLowerCase(), + option.slug, + ] ) + ); + + const matched = selectedColors.find( ( color ) => + normalizedToSlug.has( color ) + ); + + return matched ? normalizedToSlug.get( matched ) : ''; + }, [ selectedColors, colorOptions ] ); const previewColors = colorOptions.slice( 0, 2 ); const [ activePage, setActivePage ] = useState( pageShots[0]?.key || 'home' ); const [ activeColor, setActiveColor ] = useState( colorOptions[0]?.slug || '' ); @@ -102,9 +129,12 @@ const StarterSiteCard = ( { setActivePage( pageShots[0]?.key || 'home' ); }, [ pageShots ] ); + // Seed the displayed color from the global filter when this card matches a + // selected color; otherwise fall back to the first palette. Re-runs only on + // filter/colorOptions changes, so manual swatch clicks survive until then. useEffect( () => { - setActiveColor( colorOptions[0]?.slug || '' ); - }, [ colorOptions ] ); + setActiveColor( filterColor || colorOptions[0]?.slug || '' ); + }, [ colorOptions, filterColor ] ); const activePageShot = pageShots.find( ( shot ) => shot.key === activePage ) || pageShots[0] || null; @@ -194,7 +224,7 @@ const StarterSiteCard = ( { ) ) } ) } - { colorOptions.length > 0 && ( + { colorOptions.length > 1 && (
{ diff --git a/onboarding/src/Components/Steps/SiteList.js b/onboarding/src/Components/Steps/SiteList.js index 563b4827..f59700b6 100644 --- a/onboarding/src/Components/Steps/SiteList.js +++ b/onboarding/src/Components/Steps/SiteList.js @@ -11,7 +11,6 @@ import Toast from '../Toast'; import Filters from '../Filters'; import Sites from '../Sites'; import EditorSelector from '../EditorSelector'; -import OnboardingPromoNotice from '../OnboardingPromoNotice'; import SVG from '../../utils/svg'; import { get, track } from '../../utils/rest'; @@ -41,7 +40,7 @@ const SiteList = ( { const toastMessage = createInterpolateElement( __( - 'Unlock Access to all premium templates with Neve Business plan. .', + 'Included with Neve Business. See plans', 'templates-patterns-collection' ), { @@ -49,10 +48,8 @@ const SiteList = ( { - { __( 'Get Started', 'templates-patterns-collection' ) } - + rel="noopener noreferrer" + /> ), } ); @@ -211,13 +208,30 @@ const SiteList = ( {
-

- { __( 'Choose a design', 'templates-patterns-collection' ) } -

+
+

+ { __( + 'Choose a design', + 'templates-patterns-collection' + ) } +

+

+ { createInterpolateElement( + __( + 'Nearly 200 starter sites across every niche, with dozens added recently.', + 'templates-patterns-collection' + ), + { + count: ( + + ), + } + ) } +

+
- { ( personalizing || searching ) && (
) } diff --git a/onboarding/src/Components/Toast.js b/onboarding/src/Components/Toast.js index e65be8f6..8e619675 100644 --- a/onboarding/src/Components/Toast.js +++ b/onboarding/src/Components/Toast.js @@ -1,18 +1,33 @@ import classnames from 'classnames'; +import { __ } from '@wordpress/i18n'; -const Toast = ( { svgIcon, message, className, setShowToast } ) => { +const Toast = ( { svgIcon, heading, message, className, setShowToast } ) => { const handleClose = () => { setShowToast( 'dismissed' ); }; return ( -
+
+ { svgIcon && ( + + ) }
- { svgIcon &&
{ svgIcon }
} - { message &&

{ message }

} + { heading &&

{ heading }

} + { message &&

{ message }

}
-
); diff --git a/onboarding/src/scss/_general.scss b/onboarding/src/scss/_general.scss index f3b1268a..bcdd074c 100644 --- a/onboarding/src/scss/_general.scss +++ b/onboarding/src/scss/_general.scss @@ -135,6 +135,41 @@ iframe { display: flex; align-items: center; justify-content: space-between; + gap: 16px; +} + +.ob-title-text { + display: flex; + flex-direction: column; + gap: 4px; + + h1 { + margin: 0; + } +} + +.ob-subtitle { + margin: 6px 0 0; + max-width: 56ch; + // ~#6E6D6D on white ≈ 4.6:1 → passes WCAG AA while staying clearly secondary to the near-black h1. + color: mix($main-text, $inverted-text, 56%); + font-size: 14px; + line-height: 20px; + font-weight: 400; + letter-spacing: 0.1px; + + // Subtle emphasis on the catalog claim — weight only, no brand color (would read promotional). + .ob-subtitle__count { + font-weight: 600; + color: $main-text; + } +} + +@media (max-width: #{$tablet}) { + .ob-subtitle { + font-size: 13px; + line-height: 19px; + } } /** @@ -231,7 +266,7 @@ input.components-text-control__input[type="email"], @mixin ob-general--laptop() { .ob-sites.is-grid { - grid-template-columns: 1fr 1fr 1fr; + grid-template-columns: repeat(4, minmax(0, 1fr)); } } diff --git a/onboarding/src/scss/_onboarding-promo-notice.scss b/onboarding/src/scss/_onboarding-promo-notice.scss deleted file mode 100644 index ddc1ef2c..00000000 --- a/onboarding/src/scss/_onboarding-promo-notice.scss +++ /dev/null @@ -1,105 +0,0 @@ -.ob-onboarding-promo { - display: flex; - align-items: center; - gap: 24px; - background: $primary; - border-radius: 16px; - padding: 18px 24px; - margin: 32px 0; -} - -.ob-onboarding-promo-badge { - background: rgba(255, 255, 255, 0.2); - color: $inverted-text; - border-radius: 10px; - padding: 8px 14px; - text-transform: uppercase; - font-size: 14px; - line-height: 20px; - font-weight: 700; - letter-spacing: 0.06em; - flex-shrink: 0; -} - -.ob-onboarding-promo-content { - flex: 1; - - h3 { - margin: 0 0 6px; - color: $inverted-text; - font-size: 20px; - line-height: 30px; - font-weight: 700; - } - - p { - margin: 0; - color: rgba(255, 255, 255, 0.92); - font-size: 15px; - line-height: 24px; - font-weight: 400; - } - - .ob-onboarding-promo-link { - color: $inverted-text; - font-weight: 700; - text-decoration: underline; - } -} - -.ob-onboarding-promo-close { - border: 0; - background: rgba(255, 255, 255, 0.2); - color: $inverted-text; - width: 40px; - height: 40px; - border-radius: 12px; - font-size: 24px; - line-height: 1; - cursor: pointer; - display: inline-flex; - align-items: center; - justify-content: center; - flex-shrink: 0; -} - -@media (max-width: #{$laptop}) { - .ob-onboarding-promo { - padding: 16px; - gap: 16px; - } - - .ob-onboarding-promo-badge { - font-size: 12px; - line-height: 18px; - padding: 6px 10px; - } - - .ob-onboarding-promo-content { - h3 { - font-size: 18px; - line-height: 28px; - } - - p { - font-size: 14px; - line-height: 22px; - } - } - - .ob-onboarding-promo-close { - width: 36px; - height: 36px; - font-size: 20px; - } -} - -@media (max-width: #{$tablet}) { - .ob-onboarding-promo { - flex-wrap: wrap; - } - - .ob-onboarding-promo-close { - margin-left: auto; - } -} diff --git a/onboarding/src/scss/_starter-site-card.scss b/onboarding/src/scss/_starter-site-card.scss index a5a68540..a5ba2ea6 100644 --- a/onboarding/src/scss/_starter-site-card.scss +++ b/onboarding/src/scss/_starter-site-card.scss @@ -46,6 +46,12 @@ transform: translateY(0); pointer-events: auto; } + + .ss-color-summary { + opacity: 1; + transform: translateY(0); + pointer-events: auto; + } } } @@ -122,12 +128,17 @@ background: rgba(255, 255, 255, 0.96); backdrop-filter: blur(8px); box-shadow: 0 6px 18px rgba(12, 19, 31, 0.12); - transition: transform 0.18s ease, box-shadow 0.18s ease, background 0.18s ease; + // Hidden at rest — revealed only when the card is hovered/focused (like the page-shots), + // so it adds no visual noise to the resting grid. + opacity: 0; + transform: translateY(4px); + pointer-events: none; + transition: opacity 0.2s ease, transform 0.2s ease, box-shadow 0.18s ease, + background 0.18s ease; z-index: 2; gap: 6px; &:hover { - transform: translateY(-1px); background: rgba(255, 255, 255, 0.99); box-shadow: 0 10px 22px rgba(12, 19, 31, 0.16); } diff --git a/onboarding/src/scss/_toast.scss b/onboarding/src/scss/_toast.scss index 4e6c3418..4b481153 100644 --- a/onboarding/src/scss/_toast.scss +++ b/onboarding/src/scss/_toast.scss @@ -1,50 +1,118 @@ .ob-toast { position: fixed; - bottom: 49px; - right: 18px; - opacity: 0; - transform: translateY(20px); /* Start 20px below the final position */ - transition: opacity 0.5s ease, transform 0.5s ease; /* Add transition */ - display: inline-flex; - padding: 0 16px; - align-items: center; - gap: 16px; - flex-shrink: 0; + bottom: 24px; + right: 24px; + z-index: 100; + display: flex; + align-items: flex-start; + gap: 10px; + max-width: 340px; + padding: 12px 14px; border-radius: $button-radius; border: 1px solid $border; - background: $light-bg; - box-shadow: 8px 8px 40px 0px rgba(0, 0, 0, 0.30); - height: 70px; - font-size: 16px; - font-style: normal; - font-weight: 400; + background: #fff; + box-shadow: 0 6px 24px rgba(0, 0, 0, 0.12); + opacity: 0; + transform: translateY(12px); + transition: opacity 0.35s ease, transform 0.35s ease; + pointer-events: none; } -.ob-toast-content { - display: flex; - flex: 1; - align-items: center; - gap: 16px; - p { - color: $main-text; - font-size: 16px; +.ob-toast.show { + opacity: 1; + transform: translateY(0); + pointer-events: auto; +} + +.ob-toast-icon { + flex: 0 0 auto; + line-height: 0; + margin-top: 1px; + + svg { + width: 24px; + height: 24px; + display: block; } +} + +.ob-toast-content { + flex: 1 1 auto; + min-width: 0; +} + +.ob-toast-heading { + margin: 0; + color: $main-text; + font-size: 13px; + line-height: 18px; + font-weight: 600; +} + +.ob-toast-message { + margin: 2px 0 0; + color: $secondary-text; + font-size: 13px; + line-height: 18px; + font-weight: 400; + a { - text-decoration: none; color: $primary; - font-weight: 700; + font-weight: 600; + text-decoration: none; + white-space: nowrap; + + &:hover, + &:focus-visible { + text-decoration: underline; + } + + &:focus-visible { + outline: 2px solid $primary; + outline-offset: 2px; + border-radius: 2px; + } } } .ob-toast-close { + flex: 0 0 auto; + margin: -4px -4px 0 2px; + padding: 4px; background: none; border: none; - font-size: 20px; + line-height: 1; + font-size: 16px; + color: $secondary-text; cursor: pointer; + border-radius: 4px; + + &:hover { + color: $main-text; + } + + &:focus-visible { + outline: 2px solid $primary; + outline-offset: 1px; + } } -/* Add a class to control the appearance */ -.ob-toast.show { - opacity: 1; /* Show with full opacity */ - transform: translateY(0); /* Move to the final position */ +@media (prefers-reduced-motion: reduce) { + .ob-toast { + transition: opacity 0.2s ease; + transform: none; + } + + .ob-toast.show { + transform: none; + } +} + +@media (max-width: #{$tablet}) { + .ob-toast { + left: 16px; + right: 16px; + bottom: 16px; + max-width: none; + } } diff --git a/onboarding/src/style.scss b/onboarding/src/style.scss index 9938a679..c5a758d8 100644 --- a/onboarding/src/style.scss +++ b/onboarding/src/style.scss @@ -4,7 +4,6 @@ @import "scss/category-buttons"; @import "scss/search"; @import "scss/filters"; -@import "scss/onboarding-promo-notice"; @import "scss/starter-site-card"; @import "scss/editor-selector"; @import "scss/site-settings"; diff --git a/onboarding/src/utils/svg.js b/onboarding/src/utils/svg.js index 3985f31e..0fa1308b 100644 --- a/onboarding/src/utils/svg.js +++ b/onboarding/src/utils/svg.js @@ -4,6 +4,7 @@ const SVG = { xmlns="http://www.w3.org/2000/svg" width="40" height="40" + viewBox="0 0 40 40" fill="none" >