From 06dbbfdfdbcd7b39240a591c4595f103e5cfdc55 Mon Sep 17 00:00:00 2001 From: harshit Date: Wed, 22 Jul 2026 17:56:38 +0530 Subject: [PATCH 1/8] Improve compact feature recommendation panel in onboarding --- onboarding/src/Components/FeaturesList.js | 23 +++--- onboarding/src/Components/SiteSettings.js | 3 +- onboarding/src/scss/_features_list.scss | 85 +++++++++++++++++------ onboarding/src/scss/_site-settings.scss | 30 +++++++- 4 files changed, 104 insertions(+), 37 deletions(-) diff --git a/onboarding/src/Components/FeaturesList.js b/onboarding/src/Components/FeaturesList.js index 411c41b4..68c3e29b 100644 --- a/onboarding/src/Components/FeaturesList.js +++ b/onboarding/src/Components/FeaturesList.js @@ -12,37 +12,31 @@ 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', pluginSlug: 'optimole-wp', label: __('Image Optimization', 'templates-patterns-collection'), - description: __('Make your site faster with automatic image optimization.', 'templates-patterns-collection') + description: __('Auto-compress images for faster pages.', 'templates-patterns-collection') }, { id: 'caching', pluginSlug: 'wp-cloudflare-page-cache', label: __('Caching', 'templates-patterns-collection'), - description: __('Supercharge your site’s speed with powerful caching.', 'templates-patterns-collection') + description: __('Speed up your site with smart caching.', 'templates-patterns-collection') }, { id: 'eCommerce', pluginSlug: 'wp-full-stripe-free', label: __('eCommerce', 'templates-patterns-collection'), - description: __('Set up your store and start accepting payments via Stripe.', 'templates-patterns-collection'), + description: __('Sell online and accept Stripe payments.', 'templates-patterns-collection'), }, { id: 'chatbot', pluginSlug: 'hyve-lite', label: __('AI ChatBot', 'templates-patterns-collection'), - description: __('Keep visitors engaged with an intelligent AI chatbot.', 'templates-patterns-collection'), + description: __('Engage visitors with an AI chatbot.', 'templates-patterns-collection'), } ]; @@ -54,7 +48,7 @@ const pluginCollection = [ id: 'visualizer', pluginSlug: 'visualizer', label: __('Tables and Chart', 'templates-patterns-collection'), - description: __('A powerful and easy to use plugin for responsive charts & tables.', 'templates-patterns-collection') + description: __('Responsive charts and tables.', 'templates-patterns-collection') }, { id: 'woocommerce', @@ -102,13 +96,13 @@ const pluginCollection = [ id: 'pods', pluginSlug: 'pods', label: __('Pods', 'templates-patterns-collection'), - description: __('A framework for creating, managing, and deploying customized content types and fields for any project.', 'templates-patterns-collection') + description: __('Custom content types and fields made easy.', 'templates-patterns-collection') }, { id: 'restrict-content', pluginSlug: 'restrict-content', label: __('Membership Plugin', 'templates-patterns-collection'), - description: __('Membership plugin that allows you to monetize content access.', 'templates-patterns-collection') + description: __('Monetize members-only content.', 'templates-patterns-collection') }, { id: 'learning-management-system', @@ -123,7 +117,6 @@ const FeaturesList = ({ requiredPlugins, onToggle }) => { const [selectedFeatures, setSelectedFeatures] = useState({ pageBuilder: false, - contactForm: false, eCommerce: false, donations: false, automation: false, 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..04fa8724 100644 --- a/onboarding/src/scss/_features_list.scss +++ b/onboarding/src/scss/_features_list.scss @@ -1,47 +1,78 @@ .ob-select-features { display: flex; flex-direction: column; + width: 100%; + overflow-x: hidden; .ob-features-grid { display: flex; flex-direction: column; - gap: 1rem; + gap: 10px; width: 100%; + min-width: 0; + overflow-x: hidden; } .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; + border: 1px solid #e3e8ef; + border-radius: 10px; cursor: pointer; 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 { 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-feature-description { + color: #5d6f87; + } } &.ob-disabled { cursor: not-allowed; - opacity: 0.6; + opacity: 1; + border-color: #dce3ee; + background-color: #f7f9fc; + + .ob-feature-title { + color: #7b8697; + } + + .ob-feature-description { + color: #95a0b1; + } + + input[type='checkbox'] { + cursor: not-allowed; + opacity: 0.7; + } &:hover { - border-color: #e0e0e0; + border-color: #dce3ee; box-shadow: none; } } @@ -50,28 +81,42 @@ display: flex; align-items: center; justify-content: space-between; + gap: 10px; width: 100%; .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; 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-description { - font-size: 0.875rem; - color: #666666; - margin-top: 0.5rem; + font-size: 14px; + line-height: 1.35; + color: #7d8895; + margin-top: 4px; text-align: left; + overflow-wrap: anywhere; + + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + line-clamp: 2; + overflow: hidden; } } } \ No newline at end of file diff --git a/onboarding/src/scss/_site-settings.scss b/onboarding/src/scss/_site-settings.scss index 30b4556c..ac2b42dd 100644 --- a/onboarding/src/scss/_site-settings.scss +++ b/onboarding/src/scss/_site-settings.scss @@ -96,4 +96,32 @@ 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; + } + + .ob-feature-card { + .ob-feature-description { + white-space: normal; + } + } +} From 614655fad648ddccc259cc7ae9157912c5c184bf Mon Sep 17 00:00:00 2001 From: harshit Date: Wed, 22 Jul 2026 18:53:59 +0530 Subject: [PATCH 2/8] Improve feature card accessibility contrast and focus visibility --- onboarding/src/scss/_features_list.scss | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/onboarding/src/scss/_features_list.scss b/onboarding/src/scss/_features_list.scss index 04fa8724..ba6876d3 100644 --- a/onboarding/src/scss/_features_list.scss +++ b/onboarding/src/scss/_features_list.scss @@ -2,7 +2,6 @@ display: flex; flex-direction: column; width: 100%; - overflow-x: hidden; .ob-features-grid { display: flex; @@ -10,7 +9,6 @@ gap: 10px; width: 100%; min-width: 0; - overflow-x: hidden; } .ob-feature-card { @@ -107,7 +105,7 @@ .ob-feature-description { font-size: 14px; line-height: 1.35; - color: #7d8895; + color: #666666; margin-top: 4px; text-align: left; overflow-wrap: anywhere; From 397e4885a53ea2428b62c21d6e0836361e5560d1 Mon Sep 17 00:00:00 2001 From: girishpanchal30 Date: Thu, 30 Jul 2026 18:16:01 +0530 Subject: [PATCH 3/8] fix: validate Neve license plans correctly --- assets/src/Components/CloudLibrary/common.js | 28 ++++++++ assets/src/Components/Header.js | 6 -- assets/src/Components/License.js | 10 +-- assets/src/store/selectors.js | 22 +----- e2e-tests/config/mocks.js | 29 +++++++- e2e-tests/specs/license.spec.js | 75 ++++++++++++++++++++ onboarding/src/store/selectors.js | 8 +-- shared/utils.js | 42 +++++++++++ 8 files changed, 184 insertions(+), 36 deletions(-) create mode 100644 e2e-tests/specs/license.spec.js diff --git a/assets/src/Components/CloudLibrary/common.js b/assets/src/Components/CloudLibrary/common.js index 139b861d..af4e87b4 100644 --- a/assets/src/Components/CloudLibrary/common.js +++ b/assets/src/Components/CloudLibrary/common.js @@ -5,6 +5,9 @@ import { stringifyUrl } from 'query-string'; import { v4 as uuidv4 } from 'uuid'; import { models, loadPromise } from '@wordpress/api'; import { cleanTemplateContent } from '../../../../shared/utils'; +import { + isLicenseValid, +} from '../../../../shared/utils'; export const changeOption = ( option, value ) => { const model = new models.Settings( { @@ -80,6 +83,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 || ! isLicenseValid( 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..1e633fff 100644 --- a/assets/src/Components/License.js +++ b/assets/src/Components/License.js @@ -11,7 +11,8 @@ import { Icon, } from '@wordpress/components'; import { models } from '@wordpress/api'; -import { fetchLibrary as licenseCheck } from './CloudLibrary/common'; +import { licenseCheck } from './CloudLibrary/common'; +import { isLicenseValid, isTemplatesCloudTier } from '../../../shared/utils'; const License = ( { setLicense, license } ) => { const keyValue = license?.key !== '' && license?.key !== 'free' ? license?.key : ''; @@ -19,8 +20,7 @@ const License = ( { setLicense, license } ) => { const [ loading, setLoading ] = useState( false ); const [ resultMsg, setResultMsg ] = useState( {} ); - - const isValid = 'valid' === license?.valid || 'valid' === license?.license; + const isValid = isLicenseValid( license ) && isTemplatesCloudTier( license?.tier ); const delay = (time) => new Promise(resolve => setTimeout(resolve, time)); @@ -61,10 +61,10 @@ const License = ( { setLicense, license } ) => { return; } - const { success, templates } = await licenseCheck( false, { license_id: data.key, license_check: 1 } ); + const { success, status, license: licenseData } = await licenseCheck( data.key ); if ( success ) { - setLicense( templates ); + setLicense( licenseData ); await updateKey( data.key ); } else { createNotice( 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/specs/license.spec.js b/e2e-tests/specs/license.spec.js new file mode 100644 index 00000000..07354b5b --- /dev/null +++ b/e2e-tests/specs/license.spec.js @@ -0,0 +1,75 @@ +/** + * 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 a 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('.components-notice__content').filter({ + hasText: 'Can not activate this license!' + }) + ).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(); + }); +}); 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..3cf69b8f 100644 --- a/shared/utils.js +++ b/shared/utils.js @@ -23,3 +23,45 @@ 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; From ba8230f2cb8e86098230007da734343358bd9c31 Mon Sep 17 00:00:00 2001 From: girishpanchal30 Date: Thu, 30 Jul 2026 18:38:46 +0530 Subject: [PATCH 4/8] fix: enhance license validation to check --- assets/src/Components/CloudLibrary/common.js | 3 ++- e2e-tests/specs/license.spec.js | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/assets/src/Components/CloudLibrary/common.js b/assets/src/Components/CloudLibrary/common.js index af4e87b4..1274a70f 100644 --- a/assets/src/Components/CloudLibrary/common.js +++ b/assets/src/Components/CloudLibrary/common.js @@ -7,6 +7,7 @@ import { models, loadPromise } from '@wordpress/api'; import { cleanTemplateContent } from '../../../../shared/utils'; import { isLicenseValid, + isTemplatesCloudTier, } from '../../../../shared/utils'; export const changeOption = ( option, value ) => { @@ -96,7 +97,7 @@ export const licenseCheck = async ( licenseKey ) => { license_check: 1, } ) ) || {}; - if ( ! success || ! isLicenseValid( license ) ) { + if ( ! success || ! isLicenseValid( license ) || ! isTemplatesCloudTier( license?.tier ) ) { return { success: false, status: 'invalid', diff --git a/e2e-tests/specs/license.spec.js b/e2e-tests/specs/license.spec.js index 07354b5b..6e86089e 100644 --- a/e2e-tests/specs/license.spec.js +++ b/e2e-tests/specs/license.spec.js @@ -43,7 +43,7 @@ test.describe('License activation', () => { await setLegacyTc(requestUtils, false); }); - test('not activating a personal plan', async ({ + test('not activating personal plan', async ({ page, admin, }) => { From d0ba013444e3fd40d957150118c3489c29e0196d Mon Sep 17 00:00:00 2001 From: girishpanchal30 Date: Thu, 30 Jul 2026 18:59:36 +0530 Subject: [PATCH 5/8] fix: simplify license check response --- assets/src/Components/License.js | 2 +- e2e-tests/specs/license.spec.js | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/assets/src/Components/License.js b/assets/src/Components/License.js index 1e633fff..d4fc3097 100644 --- a/assets/src/Components/License.js +++ b/assets/src/Components/License.js @@ -61,7 +61,7 @@ const License = ( { setLicense, license } ) => { return; } - const { success, status, license: licenseData } = await licenseCheck( data.key ); + const { success, license: licenseData } = await licenseCheck( data.key ); if ( success ) { setLicense( licenseData ); diff --git a/e2e-tests/specs/license.spec.js b/e2e-tests/specs/license.spec.js index 6e86089e..753068e8 100644 --- a/e2e-tests/specs/license.spec.js +++ b/e2e-tests/specs/license.spec.js @@ -54,9 +54,7 @@ test.describe('License activation', () => { await page.getByRole('button', { name: 'Activate' }).click(); await expect( - page.locator('.components-notice__content').filter({ - hasText: 'Can not activate this license!' - }) + page.locator('#tpc-app').getByText('Can not activate this license!', { exact: true }) ).toBeVisible(); }); From dc21128813f707286c58b3ddf6f79eb260b72ac3 Mon Sep 17 00:00:00 2001 From: girishpanchal30 Date: Fri, 31 Jul 2026 18:29:56 +0530 Subject: [PATCH 6/8] fix: update license validation --- assets/src/Components/CloudLibrary/common.js | 8 +--- assets/src/Components/License.js | 39 ++++++++++++++++---- e2e-tests/mu-plugins/tpc-e2e.php | 25 +++++++++++-- shared/utils.js | 9 +++++ 4 files changed, 63 insertions(+), 18 deletions(-) diff --git a/assets/src/Components/CloudLibrary/common.js b/assets/src/Components/CloudLibrary/common.js index 1274a70f..63bd0fbe 100644 --- a/assets/src/Components/CloudLibrary/common.js +++ b/assets/src/Components/CloudLibrary/common.js @@ -4,11 +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 { - isLicenseValid, - isTemplatesCloudTier, -} from '../../../../shared/utils'; +import { cleanTemplateContent, hasTemplatesCloudAccess } from '../../../../shared/utils'; export const changeOption = ( option, value ) => { const model = new models.Settings( { @@ -97,7 +93,7 @@ export const licenseCheck = async ( licenseKey ) => { license_check: 1, } ) ) || {}; - if ( ! success || ! isLicenseValid( license ) || ! isTemplatesCloudTier( license?.tier ) ) { + if ( ! success || ! hasTemplatesCloudAccess( license ) ) { return { success: false, status: 'invalid', diff --git a/assets/src/Components/License.js b/assets/src/Components/License.js index d4fc3097..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'; @@ -12,7 +13,10 @@ import { } from '@wordpress/components'; import { models } from '@wordpress/api'; import { licenseCheck } from './CloudLibrary/common'; -import { isLicenseValid, isTemplatesCloudTier } from '../../../shared/utils'; +import { + hasTemplatesCloudAccess, + isLicenseValid, +} from '../../../shared/utils'; const License = ( { setLicense, license } ) => { const keyValue = license?.key !== '' && license?.key !== 'free' ? license?.key : ''; @@ -20,7 +24,13 @@ const License = ( { setLicense, license } ) => { const [ loading, setLoading ] = useState( false ); const [ resultMsg, setResultMsg ] = useState( {} ); - const isValid = isLicenseValid( license ) && isTemplatesCloudTier( license?.tier ); + const isStored = isLicenseValid( license ); + const hasAccess = hasTemplatesCloudAccess( 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)); @@ -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/e2e-tests/mu-plugins/tpc-e2e.php b/e2e-tests/mu-plugins/tpc-e2e.php index 1fb24b51..decccd87 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 ) { @@ -110,9 +110,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 ) { @@ -130,6 +132,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/shared/utils.js b/shared/utils.js index 3cf69b8f..d14da652 100644 --- a/shared/utils.js +++ b/shared/utils.js @@ -65,3 +65,12 @@ export const isTemplatesCloudTier = ( tier ) => { */ 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 ); From e9c5b432e70866cf95ca27286d5ae801cc483c25 Mon Sep 17 00:00:00 2001 From: girishpanchal30 Date: Mon, 3 Aug 2026 14:25:56 +0530 Subject: [PATCH 7/8] fix: add tests for handling stored licenses on ineligible plans --- e2e-tests/specs/license.spec.js | 40 +++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/e2e-tests/specs/license.spec.js b/e2e-tests/specs/license.spec.js index 753068e8..1904cff0 100644 --- a/e2e-tests/specs/license.spec.js +++ b/e2e-tests/specs/license.spec.js @@ -71,3 +71,43 @@ test.describe('License activation', () => { 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(); + }); +}); From cc30a46bca216d4f0e47372f4ec656155b25471e Mon Sep 17 00:00:00 2001 From: harshit Date: Tue, 4 Aug 2026 18:08:52 +0530 Subject: [PATCH 8/8] Auto-fit feature descriptions to the available panel height Descriptions now render expanded and collapse only when the card list would run past the footer, so the panel no longer needs scrolling on short viewports. Each card gets a chevron toggle that reuses the title for its accessible name, and selected features give up their description first since unselected ones still need the pitch. Updates the e2e selectors to target the new markup via data-plugin and relaxes the card count assertion, as the collection is now five entries. --- e2e-tests/specs/onboarding.spec.js | 15 ++- onboarding/src/Components/FeaturesList.js | 131 +++++++++++++++++----- onboarding/src/scss/_features_list.scss | 72 ++++++++---- onboarding/src/scss/_site-settings.scss | 6 - 4 files changed, 168 insertions(+), 56 deletions(-) diff --git a/e2e-tests/specs/onboarding.spec.js b/e2e-tests/specs/onboarding.spec.js index 604409cf..c5a2fc82 100644 --- a/e2e-tests/specs/onboarding.spec.js +++ b/e2e-tests/specs/onboarding.spec.js @@ -24,6 +24,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('/'); @@ -110,13 +113,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'); @@ -133,7 +140,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 68c3e29b..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'; @@ -18,25 +18,25 @@ const featuredPluginCollection = [ id: 'imageOpt', pluginSlug: 'optimole-wp', label: __('Image Optimization', 'templates-patterns-collection'), - description: __('Auto-compress images for faster pages.', 'templates-patterns-collection') + description: __('Make your site faster with automatic image optimization.', 'templates-patterns-collection') }, { id: 'caching', pluginSlug: 'wp-cloudflare-page-cache', label: __('Caching', 'templates-patterns-collection'), - description: __('Speed up your site with smart caching.', 'templates-patterns-collection') + description: __('Supercharge your site’s speed with powerful caching.', 'templates-patterns-collection') }, { id: 'eCommerce', pluginSlug: 'wp-full-stripe-free', label: __('eCommerce', 'templates-patterns-collection'), - description: __('Sell online and accept Stripe payments.', 'templates-patterns-collection'), + description: __('Set up your store and start accepting payments via Stripe.', 'templates-patterns-collection'), }, { id: 'chatbot', pluginSlug: 'hyve-lite', label: __('AI ChatBot', 'templates-patterns-collection'), - description: __('Engage visitors with an AI chatbot.', 'templates-patterns-collection'), + description: __('Keep visitors engaged with an intelligent AI chatbot.', 'templates-patterns-collection'), } ]; @@ -48,7 +48,7 @@ const pluginCollection = [ id: 'visualizer', pluginSlug: 'visualizer', label: __('Tables and Chart', 'templates-patterns-collection'), - description: __('Responsive charts and tables.', 'templates-patterns-collection') + description: __('A powerful and easy to use plugin for responsive charts & tables.', 'templates-patterns-collection') }, { id: 'woocommerce', @@ -96,13 +96,13 @@ const pluginCollection = [ id: 'pods', pluginSlug: 'pods', label: __('Pods', 'templates-patterns-collection'), - description: __('Custom content types and fields made easy.', 'templates-patterns-collection') + description: __('A framework for creating, managing, and deploying customized content types and fields for any project.', 'templates-patterns-collection') }, { id: 'restrict-content', pluginSlug: 'restrict-content', label: __('Membership Plugin', 'templates-patterns-collection'), - description: __('Monetize members-only content.', 'templates-patterns-collection') + description: __('Membership plugin that allows you to monetize content access.', 'templates-patterns-collection') }, { id: 'learning-management-system', @@ -126,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)) { @@ -191,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/scss/_features_list.scss b/onboarding/src/scss/_features_list.scss index ba6876d3..231c0733 100644 --- a/onboarding/src/scss/_features_list.scss +++ b/onboarding/src/scss/_features_list.scss @@ -22,7 +22,6 @@ background-color: #ffffff; border: 1px solid #e3e8ef; border-radius: 10px; - cursor: pointer; transition: box-shadow 0.3s ease, border 0.3s ease; text-align: left; @@ -31,7 +30,7 @@ box-shadow: 0 2px 8px rgba(3, 102, 213, 0.08); } - &:focus { + &:focus-within { outline: none; border-color: #0366d5; box-shadow: 0 0 0 3px rgba(3, 102, 213, 0.18); @@ -44,10 +43,6 @@ .ob-feature-title { color: #0f4fbf; } - - .ob-feature-description { - color: #5d6f87; - } } &.ob-disabled { @@ -60,10 +55,6 @@ color: #7b8697; } - .ob-feature-description { - color: #95a0b1; - } - input[type='checkbox'] { cursor: not-allowed; opacity: 0.7; @@ -78,16 +69,33 @@ .ob-feature-header { display: flex; align-items: center; - justify-content: space-between; - gap: 10px; + 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: 16px; line-height: 1.2; font-weight: 700; color: #1f2937; margin: 0; + flex: 1; text-align: left; min-width: 0; } @@ -100,21 +108,47 @@ 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: 14px; line-height: 1.35; color: #666666; - margin-top: 4px; + margin-top: 6px; text-align: left; overflow-wrap: anywhere; - display: -webkit-box; - -webkit-box-orient: vertical; - -webkit-line-clamp: 2; - line-clamp: 2; - overflow: hidden; + // 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 ac2b42dd..c7fdbbc8 100644 --- a/onboarding/src/scss/_site-settings.scss +++ b/onboarding/src/scss/_site-settings.scss @@ -118,10 +118,4 @@ .ob-settings-bottom { padding: 14px 20px 16px; } - - .ob-feature-card { - .ob-feature-description { - white-space: normal; - } - } }