diff --git a/assets/src/Components/CloudLibrary/common.js b/assets/src/Components/CloudLibrary/common.js index 139b861d..63bd0fbe 100644 --- a/assets/src/Components/CloudLibrary/common.js +++ b/assets/src/Components/CloudLibrary/common.js @@ -4,7 +4,7 @@ import apiFetch from '@wordpress/api-fetch'; import { stringifyUrl } from 'query-string'; import { v4 as uuidv4 } from 'uuid'; import { models, loadPromise } from '@wordpress/api'; -import { cleanTemplateContent } from '../../../../shared/utils'; +import { cleanTemplateContent, hasTemplatesCloudAccess } from '../../../../shared/utils'; export const changeOption = ( option, value ) => { const model = new models.Settings( { @@ -80,6 +80,31 @@ export const fetchLibrary = async ( } }; +/** + * Validate a license key against the licensing API. + * + * @param {string} licenseKey The license key to check. + * @return {Promise} `{ success, status, license, message }`. + */ +export const licenseCheck = async ( licenseKey ) => { + const { success, templates: license, message } = + ( await fetchLibrary( false, { + license_id: licenseKey, + license_check: 1, + } ) ) || {}; + + if ( ! success || ! hasTemplatesCloudAccess( license ) ) { + return { + success: false, + status: 'invalid', + license, + message, + }; + } + + return { success: true, status: 'valid', license }; +}; + export const updateTemplate = async ( id, name ) => { const url = stringifyUrl( { url: tiobDash.endpoint + 'templates/' + id, diff --git a/assets/src/Components/Header.js b/assets/src/Components/Header.js index 0abfa209..54cc8cff 100644 --- a/assets/src/Components/Header.js +++ b/assets/src/Components/Header.js @@ -17,7 +17,6 @@ const TabNavigation = ( { setCurrentTab, currentTab, isFetching, - license, } ) => { const buttons = {}; @@ -33,7 +32,6 @@ const TabNavigation = ( { const [ isSyncing, setSyncing ] = useState( false ); const { isLicenseOpen, setLicenseOpen } = useContext( LicensePanelContext ); - const isValid = 'valid' === license?.valid || 'valid' === license?.license; const sync = () => { setSyncing( true ); @@ -158,7 +156,6 @@ const Header = ( { cancelOnboarding, setCurrentTab, currentTab, - license, } ) => { return (
@@ -169,7 +166,6 @@ const Header = ( {
@@ -204,13 +200,11 @@ export default compose( getOnboardingStatus, getCurrentTab, getFetching, - getLicense, } = select( 'neve-onboarding' ); return { isOnboarding: getOnboardingStatus(), currentTab: getCurrentTab(), isFetching: getFetching(), - license: getLicense(), }; } ) )( Header ); diff --git a/assets/src/Components/License.js b/assets/src/Components/License.js index c8b2acb9..3c147100 100644 --- a/assets/src/Components/License.js +++ b/assets/src/Components/License.js @@ -1,3 +1,4 @@ +/* global tiobDash */ import { useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { compose } from '@wordpress/compose'; @@ -11,7 +12,11 @@ import { Icon, } from '@wordpress/components'; import { models } from '@wordpress/api'; -import { fetchLibrary as licenseCheck } from './CloudLibrary/common'; +import { licenseCheck } from './CloudLibrary/common'; +import { + hasTemplatesCloudAccess, + isLicenseValid, +} from '../../../shared/utils'; const License = ( { setLicense, license } ) => { const keyValue = license?.key !== '' && license?.key !== 'free' ? license?.key : ''; @@ -19,8 +24,13 @@ const License = ( { setLicense, license } ) => { const [ loading, setLoading ] = useState( false ); const [ resultMsg, setResultMsg ] = useState( {} ); + const isStored = isLicenseValid( license ); + const hasAccess = hasTemplatesCloudAccess( license ); - const isValid = 'valid' === license?.valid || 'valid' === license?.license; + const notEntitledMsg = __( + 'Your license is valid, but Templates Cloud is not included in your plan.', + 'templates-patterns-collection' + ); const delay = (time) => new Promise(resolve => setTimeout(resolve, time)); @@ -61,10 +71,10 @@ const License = ( { setLicense, license } ) => { return; } - const { success, templates } = await licenseCheck( false, { license_id: data.key, license_check: 1 } ); + const { success, license: licenseData } = await licenseCheck( data.key ); if ( success ) { - setLicense( templates ); + setLicense( licenseData ); await updateKey( data.key ); } else { createNotice( @@ -77,7 +87,7 @@ const License = ( { setLicense, license } ) => { const toggleLicense = ( event ) => { onSaveLicense( { - action: isValid ? 'deactivate' : 'activate', + action: isStored ? 'deactivate' : 'activate', key: licenseKey, } ); @@ -85,9 +95,9 @@ const License = ( { setLicense, license } ) => { }; const futureDate = new Date( new Date().setFullYear( new Date().getFullYear() + 10 ) ); - const expiration = isValid && license?.expires === 'lifetime' ? futureDate.toDateString() : new Date( license.expires ).toDateString(); + const expiration = isStored && license?.expires === 'lifetime' ? futureDate.toDateString() : new Date( license.expires ).toDateString(); - const licenseStatusMsg = isValid ? ( + const licenseStatusMsg = hasAccess ? ( <> { 'Verified - Expires at'} { expiration } @@ -97,6 +107,18 @@ const License = ( { setLicense, license } ) => { '' ); + const renderEntitlementMsg = + isStored && ! hasAccess ? ( + + { notEntitledMsg }{ ' ' } + + { __( 'Upgrade to PRO', 'templates-patterns-collection' ) } + + + ) : ( + '' + ); + const renderResultMsg = Object.keys( resultMsg ).length > 0 ? ( @@ -110,11 +132,11 @@ const License = ( { setLicense, license } ) => { <>
{ type="submit" variant="primary" > - { isValid + { isStored ? __( 'Deactivate', 'templates-patterns-collection' ) : __( 'Activate', 'templates-patterns-collection' ) }
{ licenseStatusMsg }
+ { renderEntitlementMsg } { renderResultMsg } ); diff --git a/assets/src/store/selectors.js b/assets/src/store/selectors.js index bfaf3a92..afc12120 100644 --- a/assets/src/store/selectors.js +++ b/assets/src/store/selectors.js @@ -1,3 +1,5 @@ +import { isTemplatesCloudTier } from '../../../shared/utils'; + export default { getSites: ( state ) => state.sites, getMigrationData: ( state ) => state.migrationData, @@ -15,28 +17,10 @@ export default { getTemplateModal: ( state ) => state.templateModal, getSearchQuery: ( state ) => state.searchQuery, getUserStatus: ( state ) => { - const acceptedTiers = [ - 6, - 17, - 23, - 5, - 9, - 14, - 20, - 1, - 7, - 12, - 18, - 3, - 8, - 13, - 19, - ]; - return ( state.license && state.license.tier && - acceptedTiers.includes( state.license.tier ) + isTemplatesCloudTier( state.license.tier ) ); }, getLicense: ( state ) => { diff --git a/e2e-tests/config/mocks.js b/e2e-tests/config/mocks.js index 77000f62..97e59640 100644 --- a/e2e-tests/config/mocks.js +++ b/e2e-tests/config/mocks.js @@ -108,6 +108,23 @@ export const MOCK_TEMPLATES = [ }, ]; +// License payloads returned for a browser-side license check (license_check=1). +// AGENCY_LICENSE matches the license the mu-plugin seeds; PERSONAL_LICENSE is a +// valid license on a tier that does not include Templates Cloud. +export const AGENCY_LICENSE = { + license: 'valid', + key: 'tpc-e2e-key', + tier: 3, + expires: 'lifetime', +}; + +export const PERSONAL_LICENSE = { + license: 'valid', + key: 'tpc-e2e-personal-key', + tier: 2, + expires: 'lifetime', +}; + // apiFetch sends credentialed requests, so the mocked cross-origin responses // must echo the exact origin (a wildcard is rejected by the browser). const corsHeaders = (route) => ({ @@ -142,10 +159,20 @@ export async function mockOnboardingRoutes(page) { export const TEMPLATE_CONTENT_TEXT = 'TPC E2E imported content'; export const TEMPLATE_CONTENT = `

${TEMPLATE_CONTENT_TEXT}

`; -export async function mockTemplatesCloudRoutes(page, templates = MOCK_TEMPLATES) { +export async function mockTemplatesCloudRoutes( + page, + templates = MOCK_TEMPLATES, + license = AGENCY_LICENSE, +) { await page.route('**/api.themeisle.com/templates-cloud/**', (route) => { const url = new URL(route.request().url()); + // license_check=1 returns the license data instead of templates - this + // is what the license panel activates against. + if (url.searchParams.get('license_check')) { + return fulfillJson(route, license); + } + // GET templates/{id}/import returns the template's block content. if (url.pathname.endsWith('/import')) { return fulfillJson(route, { diff --git a/e2e-tests/mu-plugins/tpc-e2e.php b/e2e-tests/mu-plugins/tpc-e2e.php index dbb055d9..821a4d02 100644 --- a/e2e-tests/mu-plugins/tpc-e2e.php +++ b/e2e-tests/mu-plugins/tpc-e2e.php @@ -62,7 +62,7 @@ function () { 'args' => array( 'mode' => array( 'type' => 'string', - 'enum' => array( '', 'down', 'invalid' ), + 'enum' => array( '', 'down', 'invalid', 'personal' ), ), ), 'callback' => function ( $request ) { @@ -112,9 +112,11 @@ function ( $preempt, $args, $url ) { $is_themeisle_api = false !== strpos( $url, 'api.themeisle.com' ) || false !== strpos( $url, 'ai.themeisle.com' ); // Scenario modes (Otter pattern), set per spec via tpc-e2e/v1/api-mode: - // 'down' => ThemeIsle APIs unreachable. - // 'invalid' => license check rejects the key (a code/message body is - // what License::check_license treats as invalid). + // 'down' => ThemeIsle APIs unreachable. + // 'invalid' => license check rejects the key (a code/message body is + // what License::check_license treats as invalid). + // 'personal' => the key is accepted, on a tier that does not include + // Templates Cloud. $mode = get_option( 'tpc_e2e_api_mode', '' ); if ( 'down' === $mode && $is_themeisle_api ) { @@ -132,6 +134,21 @@ function ( $preempt, $args, $url ) { ); } + // A valid license on a tier that does not include Templates Cloud. + // Must match PERSONAL_LICENSE in config/mocks.js. + if ( 'personal' === $mode && false !== strpos( $url, 'api.themeisle.com/templates-cloud/' ) ) { + return tpc_e2e_response( + wp_json_encode( + array( + 'license' => 'valid', + 'key' => 'tpc-e2e-personal-key', + 'tier' => 2, + 'expires' => 'lifetime', + ) + ) + ); + } + // Starter sites feed (Sites_Listing::API). if ( false !== strpos( $url, 'api.themeisle.com/sites/wp-json/demosites-api/sites' ) ) { return tpc_e2e_response( file_get_contents( TPC_E2E_FIXTURES_DIR . '/sites.json' ) ); diff --git a/e2e-tests/specs/license.spec.js b/e2e-tests/specs/license.spec.js new file mode 100644 index 00000000..1904cff0 --- /dev/null +++ b/e2e-tests/specs/license.spec.js @@ -0,0 +1,113 @@ +/** + * WordPress dependencies + */ +import { test, expect } from '@wordpress/e2e-test-utils-playwright'; + +/** + * Internal dependencies + */ +import { + AGENCY_LICENSE, + MOCK_TEMPLATES, + PERSONAL_LICENSE, + mockTemplatesCloudRoutes, +} from '../config/mocks'; + +const setApiMode = (requestUtils, mode) => + requestUtils.rest({ + path: '/tpc-e2e/v1/api-mode', + method: 'POST', + data: { mode }, + }); + +const setLegacyTc = (requestUtils, enabled) => + requestUtils.rest({ + path: '/tpc-e2e/v1/legacy-tc', + method: 'POST', + data: { enabled }, + }); + +// The license panel lives in the tiob-plugin dashboard settings, which only +// exists for "legacy Templates Cloud" installs. The 'invalid' API mode drops the +// license the mu-plugin seeds, so the activation form is the starting state. +test.describe('License activation', () => { + const SETTINGS_URL = 'admin.php?page=tiob-plugin#settings'; + + test.beforeEach(async ({ requestUtils }) => { + await setLegacyTc(requestUtils, true); + await setApiMode(requestUtils, 'invalid'); + }); + + test.afterEach(async ({ requestUtils }) => { + await setApiMode(requestUtils, ''); + await setLegacyTc(requestUtils, false); + }); + + test('not activating personal plan', async ({ + page, + admin, + }) => { + await mockTemplatesCloudRoutes(page, MOCK_TEMPLATES, PERSONAL_LICENSE); + await admin.visitAdminPage(SETTINGS_URL); + + await page.getByLabel('License Key').fill(PERSONAL_LICENSE.key); + await page.getByRole('button', { name: 'Activate' }).click(); + + await expect( + page.locator('#tpc-app').getByText('Can not activate this license!', { exact: true }) + ).toBeVisible(); + }); + + test('activating an agency plan', async ({ + page, + admin, + }) => { + await mockTemplatesCloudRoutes(page, MOCK_TEMPLATES, AGENCY_LICENSE); + await admin.visitAdminPage(SETTINGS_URL); + + await page.getByLabel('License Key').fill(AGENCY_LICENSE.key); + await page.getByRole('button', { name: 'Activate' }).click(); + + await expect(page.getByRole('button', { name: 'Deactivate' })).toBeVisible(); + }); +}); + +// The 'personal' API mode is the state inherit_license_from_neve() stores for a +// Neve Personal key: the license is valid, its tier is not eligible. The panel +// has to treat it as a stored license and still say why Templates Cloud is locked. +test.describe('A stored license on an ineligible plan', () => { + const SETTINGS_URL = 'admin.php?page=tiob-plugin#settings'; + + test.beforeEach(async ({ requestUtils, admin }) => { + await setLegacyTc(requestUtils, true); + await setApiMode(requestUtils, 'personal'); + await admin.visitAdminPage(SETTINGS_URL); + }); + + test.afterEach(async ({ requestUtils }) => { + await setApiMode(requestUtils, ''); + await setLegacyTc(requestUtils, false); + }); + + test('can be deactivated', async ({ page }) => { + await expect(page.getByRole('button', { name: 'Deactivate' })).toBeVisible(); + }); + + test('is masked in the license field', async ({ page }) => { + await expect(page.getByLabel('License Key')).toHaveValue( + '******************************l-key', + ); + }); + + test('is not reported as verified', async ({ page }) => { + await expect(page.getByText('Verified - Expires at')).toBeHidden(); + }); + + test('explains that the plan does not include Templates Cloud', async ({ page }) => { + await expect( + page.getByText( + 'Your license is valid, but Templates Cloud is not included in your plan.', + ), + ).toBeVisible(); + }); +}); diff --git a/e2e-tests/specs/onboarding.spec.js b/e2e-tests/specs/onboarding.spec.js index 79457404..540e45c8 100644 --- a/e2e-tests/specs/onboarding.spec.js +++ b/e2e-tests/specs/onboarding.spec.js @@ -35,6 +35,9 @@ test.describe('Onboarding', () => { await expect(page.locator('.ob-error-wrap')).toHaveCount(0); }; + const featureCard = ( page, pluginSlug ) => + page.locator(`.ob-feature-header[data-plugin="${ pluginSlug }"] .ob-feature-select`); + test('Sub-menu in Admin page', async ({ page, admin }) => { await admin.visitAdminPage('/'); @@ -128,13 +131,17 @@ test.describe('Onboarding', () => { await openFirstSiteAndWaitForData(page); await page.getByRole('button', { name: 'Continue' }).click(); - expect(await page.locator('.ob-feature-card').count()).toBe(6); + // FeaturesList caps the list at MAX_FEATURE_LIST_LENGTH (6). + const featureCardCount = await page.locator('.ob-feature-card').count(); + expect(featureCardCount).toBeGreaterThanOrEqual(5); + expect(featureCardCount).toBeLessThanOrEqual(6); expect( - await page.locator('.ob-feature-card.ob-disabled[aria-checked="true"]').count(), + await page.locator('.ob-feature-card.ob-disabled .ob-feature-select[aria-checked="true"]').count(), ).toBeGreaterThan(0); // We have some required plugin that are active by default. // Check if we can select a plugin to install. - const cachePlugin = page.getByRole('checkbox', { name: 'Caching Supercharge your site' }); + const cachePlugin = featureCard( page, 'wp-cloudflare-page-cache' ); + await expect(cachePlugin).toHaveAttribute('aria-checked', 'false'); await cachePlugin.click(); await expect(cachePlugin).toHaveAttribute('aria-checked', 'true'); @@ -151,7 +158,7 @@ test.describe('Onboarding', () => { await admin.visitAdminPage(ONBOARDING_URL); await openFirstSiteAndWaitForData(page); await page.getByRole('button', { name: 'Continue' }).click(); - const cachePlugin = page.getByRole('checkbox', { name: 'Caching Supercharge your site' }); + const cachePlugin = featureCard( page, 'wp-cloudflare-page-cache' ); await cachePlugin.click(); await page.getByRole('button', { name: 'Import Website' }).click(); diff --git a/onboarding/src/Components/FeaturesList.js b/onboarding/src/Components/FeaturesList.js index 411c41b4..679f49f1 100644 --- a/onboarding/src/Components/FeaturesList.js +++ b/onboarding/src/Components/FeaturesList.js @@ -1,4 +1,4 @@ -import { useState, useEffect } from '@wordpress/element'; +import { useState, useEffect, useLayoutEffect, useRef } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { decodeHtmlEntities } from '../utils/common'; @@ -12,13 +12,7 @@ const featuredPluginCollection = [ id: 'pageBuilder', pluginSlug: 'otter-blocks', label: __('Site Builder', 'templates-patterns-collection'), - description: __('Build beautiful pages with a simple drag-and-drop page builder.', 'templates-patterns-collection') - }, - { - id: 'contactForm', - pluginSlug: 'otter-blocks', - label: __('Contact Form', 'templates-patterns-collection'), - description: __('Create forms to capture leads and feedback.', 'templates-patterns-collection') + description: __('Build pages and forms with Otter.', 'templates-patterns-collection') }, { id: 'imageOpt', @@ -123,7 +117,6 @@ const FeaturesList = ({ requiredPlugins, onToggle }) => { const [selectedFeatures, setSelectedFeatures] = useState({ pageBuilder: false, - contactForm: false, eCommerce: false, donations: false, automation: false, @@ -133,6 +126,19 @@ const FeaturesList = ({ requiredPlugins, onToggle }) => { }); const [lockedPluginSlugs, setLockedPluginSlugs] = useState([]); + const [expandedFeatures, setExpandedFeatures] = useState({}); + + const gridRef = useRef(null); + const autoFitApplied = useRef(false); + + const toggleExpanded = (feature) => { + // Any manual toggle takes over from the automatic fit. + autoFitApplied.current = true; + setExpandedFeatures((prev) => ({ + ...prev, + [feature]: !prev[feature], + })); + }; const toggleFeature = (feature, pluginSlug) => { if (lockedPluginSlugs.includes(pluginSlug)) { @@ -198,41 +204,105 @@ const FeaturesList = ({ requiredPlugins, onToggle }) => { setFeatureList(orderedFeatures); setLockedPluginSlugs(requiredPluginSlugs); + setExpandedFeatures( + Object.fromEntries(orderedFeatures.map(({ id }) => [id, true])) + ); + autoFitApplied.current = false; }, [requiredPlugins]); + // Descriptions start open and collapse only if the list would run past the footer. + // Selected features give up their description first, since unselected ones still need the pitch. + useLayoutEffect(() => { + if (autoFitApplied.current || !gridRef.current || 0 === featureList.length) { + return; + } + autoFitApplied.current = true; + + const footer = document.querySelector('.ob-settings-bottom'); + const limit = window.innerHeight - (footer ? footer.offsetHeight : 0); + const overflow = gridRef.current.getBoundingClientRect().bottom - limit; + + if (overflow <= 0) { + return; + } + + const heightOf = (id) => { + const description = gridRef.current.querySelector(`#ob-feature-desc-${id}`); + if (!description || description.hidden) { + return 0; + } + return description.offsetHeight + parseFloat(window.getComputedStyle(description).marginTop || 0); + }; + + const isSelected = ({ id, pluginSlug }) => selectedFeatures[id] || lockedPluginSlugs.includes(pluginSlug); + const reclaimed = featureList.filter(isSelected).reduce((total, { id }) => total + heightOf(id), 0); + + setExpandedFeatures( + reclaimed >= overflow + ? Object.fromEntries(featureList.filter((feature) => !isSelected(feature)).map(({ id }) => [id, true])) + : {} + ); + }, [featureList]); + return (
-
+
{ featureList.map((feature) => { const checked = selectedFeatures[feature.id] || lockedPluginSlugs.includes(feature.pluginSlug); const isLocked = lockedPluginSlugs.includes(feature.pluginSlug); + const isExpanded = Boolean(expandedFeatures[feature.id]); + const titleId = `ob-feature-title-${feature.id}`; + const descriptionId = `ob-feature-desc-${feature.id}`; return ( - + {feature.description && ( + + )}
-
{feature.description}
- + {feature.description && ( + + )} +
); }) } diff --git a/onboarding/src/Components/SiteSettings.js b/onboarding/src/Components/SiteSettings.js index bd2d5527..b75be1a2 100644 --- a/onboarding/src/Components/SiteSettings.js +++ b/onboarding/src/Components/SiteSettings.js @@ -146,7 +146,8 @@ export const SiteSettings = ( {
{ ! fetching ? ( diff --git a/onboarding/src/scss/_features_list.scss b/onboarding/src/scss/_features_list.scss index a2a35a81..231c0733 100644 --- a/onboarding/src/scss/_features_list.scss +++ b/onboarding/src/scss/_features_list.scss @@ -1,47 +1,67 @@ .ob-select-features { display: flex; flex-direction: column; + width: 100%; .ob-features-grid { display: flex; flex-direction: column; - gap: 1rem; + gap: 10px; width: 100%; + min-width: 0; } .ob-feature-card { display: flex; flex-direction: column; align-items: flex-start; - padding: 1rem; + width: 100%; + min-width: 0; + box-sizing: border-box; + padding: 12px 14px; background-color: #ffffff; - border: 1px solid #e0e0e0; - border-radius: 6px; - cursor: pointer; + border: 1px solid #e3e8ef; + border-radius: 10px; transition: box-shadow 0.3s ease, border 0.3s ease; + text-align: left; &:hover:not(.ob-disabled) { - border-color: #007cba; - box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + border-color: #9ec3ef; + box-shadow: 0 2px 8px rgba(3, 102, 213, 0.08); } - &:focus { + &:focus-within { outline: none; - border-color: #007cba; - box-shadow: 0 0 0 4px rgba(0, 124, 186, 0.2); + border-color: #0366d5; + box-shadow: 0 0 0 3px rgba(3, 102, 213, 0.18); } &.selected { - border-color: #007cba; - background-color: #e8f4fa; + border-color: #8bb6f3; + background-color: #eaf3ff; + + .ob-feature-title { + color: #0f4fbf; + } } &.ob-disabled { cursor: not-allowed; - opacity: 0.6; + opacity: 1; + border-color: #dce3ee; + background-color: #f7f9fc; + + .ob-feature-title { + color: #7b8697; + } + + input[type='checkbox'] { + cursor: not-allowed; + opacity: 0.7; + } &:hover { - border-color: #e0e0e0; + border-color: #dce3ee; box-shadow: none; } } @@ -49,29 +69,86 @@ .ob-feature-header { display: flex; align-items: center; - justify-content: space-between; + gap: 8px; width: 100%; + .ob-feature-select { + display: flex; + align-items: center; + gap: 10px; + flex: 1; + min-width: 0; + padding: 0; + background: none; + border: 0; + cursor: pointer; + text-align: left; + + &:focus { + outline: none; + } + } + .ob-feature-title { - font-size: 1rem; - font-weight: bold; - color: #333333; + font-size: 16px; + line-height: 1.2; + font-weight: 700; + color: #1f2937; margin: 0; + flex: 1; text-align: left; + min-width: 0; } input[type='checkbox'] { cursor: pointer; - accent-color: #007cba; - transform: scale(1.2); + accent-color: #2b62de; + margin: 0; + width: 18px; + height: 18px; + flex-shrink: 0; + } + + .ob-feature-expand { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 28px; + height: 28px; + margin-right: -6px; + padding: 0; + background: none; + border: 0; + border-radius: 4px; + color: #9aa3b2; + cursor: pointer; + + &:hover { + color: #0f4fbf; + background-color: rgba(3, 102, 213, 0.08); + } + + svg { + transition: transform 0.2s ease; + } + + &[aria-expanded='true'] svg { + transform: rotate(180deg); + } } } .ob-feature-description { - font-size: 0.875rem; + font-size: 14px; + line-height: 1.35; color: #666666; - margin-top: 0.5rem; + margin-top: 6px; text-align: left; + overflow-wrap: anywhere; + + // Align with the title rather than the leading checkbox. + padding-left: 28px; } } -} \ No newline at end of file +} diff --git a/onboarding/src/scss/_site-settings.scss b/onboarding/src/scss/_site-settings.scss index 30b4556c..c7fdbbc8 100644 --- a/onboarding/src/scss/_site-settings.scss +++ b/onboarding/src/scss/_site-settings.scss @@ -96,4 +96,26 @@ padding: 20px; background: #fff; box-shadow: 4px 4px 40px 0 rgba(0, 0, 0, 0.10); -} \ No newline at end of file +} + +.ob-site-settings.is-step-4-features { + .ob-settings-description { + padding-top: 16px; + gap: 8px; + margin-bottom: 20px; + + p { + margin: 0; + font-size: 15px; + line-height: 1.45; + } + } + + .ob-settings-top { + gap: 16px; + } + + .ob-settings-bottom { + padding: 14px 20px 16px; + } +} diff --git a/onboarding/src/store/selectors.js b/onboarding/src/store/selectors.js index 0808d2da..9bce46e1 100644 --- a/onboarding/src/store/selectors.js +++ b/onboarding/src/store/selectors.js @@ -1,3 +1,5 @@ +import { isTemplatesCloudTier } from '../../../shared/utils'; + export default { getThemeAction: ( state ) => state.themeAction, getCurrentStep: ( state ) => state.onboardingStep, @@ -11,14 +13,10 @@ export default { getError: ( state ) => state.error, getPluginOptions: ( state ) => state.pluginOptions, getUserStatus: ( state ) => { - const acceptedTiers = [ - 6, 17, 23, 5, 9, 14, 20, 1, 7, 12, 18, 3, 8, 13, 19, - ]; - return ( state.license && state.license.tier && - acceptedTiers.includes( state.license.tier ) + isTemplatesCloudTier( state.license.tier ) ); }, getUserCustomSettings: ( state ) => state.userCustomSettings, diff --git a/shared/utils.js b/shared/utils.js index e044fe41..d14da652 100644 --- a/shared/utils.js +++ b/shared/utils.js @@ -23,3 +23,54 @@ export const cleanTemplateContent = ( templateContent, cleanFunc ) => { loopElementorElement( item, cleanFunc ); } ); }; + +/** + * License tiers entitled to Templates Cloud. + */ +export const TEMPLATES_CLOUD_TIERS = [ + 6, + 17, + 23, + 5, + 9, + 14, + 20, + 1, + 7, + 12, + 18, + 3, + 8, + 13, + 19, +]; + +/** + * Check if a license tier is entitled to Templates Cloud. + * + * @param {number|string} tier The tier key returned by the licensing API. + * @return {boolean} Whether the tier includes Templates Cloud. + */ +export const isTemplatesCloudTier = ( tier ) => { + const parsed = Number( tier ); + + return Number.isInteger( parsed ) && TEMPLATES_CLOUD_TIERS.includes( parsed ); +}; + +/** + * Check if the license itself is valid, regardless of its tier. + * + * @param {Object} license The license data. + * @return {boolean} Whether the license is valid. + */ +export const isLicenseValid = ( license ) => + 'valid' === license?.valid || 'valid' === license?.license; + +/** + * Check if the license grants access to Templates Cloud. + * + * @param {Object} license The license data. + * @return {boolean} Whether the license is valid and includes Templates Cloud. + */ +export const hasTemplatesCloudAccess = ( license ) => + isLicenseValid( license ) && isTemplatesCloudTier( license?.tier );