Merge branch 'trunk' into feature/marketplace-subscriptions

This commit is contained in:
Dan Q 2023-09-21 12:47:21 +01:00
commit 69929ba050
271 changed files with 6448 additions and 2128 deletions

View File

@ -0,0 +1,175 @@
module.exports = async ( { context, core, github } ) => {
const {
API_RESULT,
E2E_RESULT,
k6_RESULT,
PLUGINS_BLOCKS_PATH,
PLUGIN_TESTS_RESULT,
GITHUB_REF_NAME,
GITHUB_RUN_ID,
} = process.env;
const {
selectEmoji,
readContextBlocksFromJsonFiles,
} = require( './utils' );
const URL_GITHUB_RUN_LOG = `https://github.com/woocommerce/woocommerce/actions/runs/${ GITHUB_RUN_ID }`;
const create_blockGroup_header = async () => {
const getRunStartDate = async () => {
const response = await github.rest.actions.getWorkflowRun( {
owner: context.repo.owner,
repo: context.repo.repo,
run_id: GITHUB_RUN_ID,
} );
const runStartedAt = new Date( response.data.run_started_at );
const intlDateTimeFormatOptions = {
dateStyle: 'full',
timeStyle: 'long',
};
const date = new Intl.DateTimeFormat(
'en-US',
intlDateTimeFormatOptions
).format( runStartedAt );
return date;
};
const readableDate = await getRunStartDate();
const blocks = [
{
type: 'header',
text: {
type: 'plain_text',
text: 'Daily test results',
emoji: true,
},
},
{
type: 'divider',
},
{
type: 'context',
elements: [
{
type: 'mrkdwn',
text: `*Run started:* ${ readableDate }`,
},
],
},
{
type: 'context',
elements: [
{
type: 'mrkdwn',
text: `*Branch:* \`${ GITHUB_REF_NAME }\``,
},
],
},
{
type: 'context',
elements: [
{
type: 'mrkdwn',
text: `*GitHub run logs:* <${ URL_GITHUB_RUN_LOG }|${ GITHUB_RUN_ID }>`,
},
],
},
{
type: 'context',
elements: [
{
type: 'mrkdwn',
text: '*Test reports dashboard:* <https://woocommerce.github.io/woocommerce-test-reports/daily/|Daily smoke tests>',
},
],
},
{
type: 'divider',
},
];
return blocks;
};
const create_blockGroup_nightlySite = () => {
const emoji_API = selectEmoji( API_RESULT );
const emoji_E2E = selectEmoji( E2E_RESULT );
const emoji_k6 = selectEmoji( k6_RESULT );
const url_API =
'https://woocommerce.github.io/woocommerce-test-reports/daily/nightly-site/api';
const url_E2E =
'https://woocommerce.github.io/woocommerce-test-reports/daily/nightly-site/e2e';
const url_k6 = URL_GITHUB_RUN_LOG;
const blocks = [
{
type: 'section',
text: {
type: 'mrkdwn',
text: `<${ URL_GITHUB_RUN_LOG }|*Smoke tests on daily build*>`,
},
},
{
type: 'context',
elements: [
{
type: 'mrkdwn',
text: `<${ url_API }|API> ${ emoji_API }\t<${ url_E2E }|E2E> ${ emoji_E2E }\t<${ url_k6 }|k6> ${ emoji_k6 }`,
},
],
},
{
type: 'divider',
},
];
return blocks;
};
const create_blockGroups_plugins = () => {
const pluginTestsSkipped = PLUGIN_TESTS_RESULT === 'skipped';
const blocks_pluginTestsSkipped = [
{
type: 'section',
text: {
type: 'mrkdwn',
text: ':warning: *Plugin tests were not run!*',
},
},
{
type: 'context',
elements: [
{
type: 'mrkdwn',
text: `Head over to the <${ URL_GITHUB_RUN_LOG }|GitHub workflow run log> to see what went wrong.`,
},
],
},
{
type: 'divider',
},
];
return pluginTestsSkipped
? blocks_pluginTestsSkipped
: readContextBlocksFromJsonFiles( PLUGINS_BLOCKS_PATH );
};
const blockGroup_header = await create_blockGroup_header();
const blockGroup_nightlySite = create_blockGroup_nightlySite();
const blockGroups_plugins = create_blockGroups_plugins();
const blocks_all = [
...blockGroup_header,
...blockGroup_nightlySite,
...blockGroups_plugins.flat(),
];
const payload = {
text: 'Daily test results',
blocks: blocks_all,
};
const payload_stringified = JSON.stringify( payload );
core.setOutput( 'payload', payload_stringified );
};

View File

@ -0,0 +1,37 @@
module.exports = ( { core } ) => {
const { UPLOAD_RESULT, E2E_RESULT, PLUGIN_NAME, PLUGIN_SLUG } = process.env;
const { selectEmoji } = require( './utils' );
const fs = require( 'fs' );
const emoji_UPLOAD = selectEmoji( UPLOAD_RESULT );
const emoji_E2E = selectEmoji( E2E_RESULT );
const reportURL = `https://woocommerce.github.io/woocommerce-test-reports/daily/${ PLUGIN_SLUG }/e2e`;
const blockGroup = [
{
type: 'section',
text: {
type: 'mrkdwn',
text: `<${ reportURL }|*${ PLUGIN_NAME }*>`,
},
},
{
type: 'context',
elements: [
{
type: 'mrkdwn',
text: `"Upload plugin" test ${ emoji_UPLOAD }\tOther E2E tests ${ emoji_E2E }`,
},
],
},
{
type: 'divider',
},
];
const blockGroup_stringified = JSON.stringify( blockGroup );
const path = `/tmp/${ PLUGIN_SLUG }.json`;
fs.writeFileSync( path, blockGroup_stringified );
core.setOutput( 'path', path );
};

View File

@ -0,0 +1,26 @@
const fs = require( 'fs' );
const path = require( 'path' );
/**
* @param {string} blocksDir
* @returns {any[][]}
*/
const readContextBlocksFromJsonFiles = ( blocksDir ) => {
const jsonsDir = path.resolve( blocksDir );
const jsons = fs.readdirSync( jsonsDir );
let contextBlocks = [];
for ( const json of jsons ) {
const jsonPath = path.resolve( jsonsDir, json );
const contextBlock = require( jsonPath );
contextBlocks.push( contextBlock );
}
return contextBlocks;
};
module.exports = {
readContextBlocksFromJsonFiles,
};

View File

@ -0,0 +1,7 @@
const { readContextBlocksFromJsonFiles } = require( './get-context-blocks' );
const { selectEmoji } = require( './select-emoji' );
module.exports = {
readContextBlocksFromJsonFiles,
selectEmoji,
};

View File

@ -0,0 +1,26 @@
const emojis = {
PASSED: ':workflow-passed:',
FAILED: ':workflow-failed:',
SKIPPED: ':workflow-skipped:',
CANCELLED: ':workflow-cancelled:',
UNKNOWN: ':grey_question:',
};
const selectEmoji = ( result ) => {
switch ( result ) {
case 'success':
return emojis.PASSED;
case 'failure':
return emojis.FAILED;
case 'skipped':
return emojis.SKIPPED;
case 'cancelled':
return emojis.CANCELLED;
default:
return emojis.UNKNOWN;
}
};
module.exports = {
selectEmoji,
};

View File

@ -8,6 +8,7 @@ env:
API_ARTIFACT: api-daily--run-${{ github.run_number }}
E2E_ARTIFACT: e2e-daily--run-${{ github.run_number }}
FORCE_COLOR: 1
PLUGIN_SLACK_BLOCKS_ARTIFACT: plugin-blocks
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@ -21,6 +22,8 @@ jobs:
runs-on: ubuntu-20.04
permissions:
contents: read
outputs:
test-result: ${{ steps.run-api-composite-action.outputs.result }}
env:
ALLURE_RESULTS_DIR: ${{ github.workspace }}/plugins/woocommerce/tests/api-core-tests/test-results/allure-results
ALLURE_REPORT_DIR: ${{ github.workspace }}/plugins/woocommerce/tests/api-core-tests/test-results/allure-report
@ -42,38 +45,31 @@ jobs:
install-filters: woocommerce
build: false
- name: Download and install Chromium browser.
working-directory: plugins/woocommerce
run: pnpm exec playwright install chromium
- name: Update site to nightly version
uses: ./.github/actions/tests/run-e2e-tests
with:
report-name: ${{ env.API_ARTIFACT }}
tests: update-woocommerce.spec.js
env:
GITHUB_TOKEN: ${{ secrets.E2E_GH_TOKEN }}
UPDATE_WC: nightly
- name: Run API tests.
working-directory: plugins/woocommerce
- name: Run API tests
id: run-api-composite-action
uses: ./.github/actions/tests/run-api-tests
with:
report-name: ${{ env.API_ARTIFACT }}
env:
USER_KEY: ${{ secrets.SMOKE_TEST_ADMIN_USER }}
USER_SECRET: ${{ secrets.SMOKE_TEST_ADMIN_PASSWORD }}
run: pnpm exec playwright test --config=tests/api-core-tests/playwright.config.js
- name: Generate API Test report.
if: success() || failure()
working-directory: plugins/woocommerce
run: pnpm exec allure generate --clean ${{ env.ALLURE_RESULTS_DIR }} --output ${{ env.ALLURE_REPORT_DIR }}
- name: Archive API test report
if: success() || failure()
uses: actions/upload-artifact@v3
with:
name: ${{ env.API_ARTIFACT }}
path: |
${{ env.ALLURE_RESULTS_DIR }}
${{ env.ALLURE_REPORT_DIR }}
if-no-files-found: ignore
retention-days: 5
e2e-tests:
name: E2E tests on nightly build
runs-on: ubuntu-20.04
permissions:
contents: read
outputs:
test-result: ${{ steps.run-e2e-composite-action.outputs.result }}
# needs: [api-tests]
env:
ADMIN_PASSWORD: ${{ secrets.SMOKE_TEST_ADMIN_PASSWORD }}
@ -85,7 +81,6 @@ jobs:
CUSTOMER_PASSWORD: ${{ secrets.SMOKE_TEST_CUSTOMER_PASSWORD }}
CUSTOMER_USER: ${{ secrets.SMOKE_TEST_CUSTOMER_USER }}
DEFAULT_TIMEOUT_OVERRIDE: 120000
steps:
- uses: actions/checkout@v3
@ -95,33 +90,24 @@ jobs:
install-filters: woocommerce
build: false
- name: Download and install Chromium browser.
working-directory: plugins/woocommerce
run: pnpm exec playwright install chromium
- name: Run E2E tests.
- name: Run E2E tests
id: run-e2e-composite-action
timeout-minutes: 60
working-directory: plugins/woocommerce
uses: ./.github/actions/tests/run-e2e-tests
with:
report-name: ${{ env.E2E_ARTIFACT }}
env:
ADMIN_PASSWORD: ${{ secrets.SMOKE_TEST_ADMIN_PASSWORD }}
ADMIN_USER: ${{ secrets.SMOKE_TEST_ADMIN_USER }}
ADMIN_USER_EMAIL: ${{ secrets.SMOKE_TEST_ADMIN_USER_EMAIL }}
ALLURE_REPORT_DIR: ${{ github.workspace }}/plugins/woocommerce/tests/e2e-pw/test-results/allure-report
ALLURE_RESULTS_DIR: ${{ github.workspace }}/plugins/woocommerce/tests/e2e-pw/test-results/allure-results
BASE_URL: ${{ secrets.SMOKE_TEST_URL }}
CUSTOMER_PASSWORD: ${{ secrets.SMOKE_TEST_CUSTOMER_PASSWORD }}
CUSTOMER_USER: ${{ secrets.SMOKE_TEST_CUSTOMER_USER }}
DEFAULT_TIMEOUT_OVERRIDE: 120000
E2E_MAX_FAILURES: 25
RESET_SITE: true
run: pnpm exec playwright test --config=tests/e2e-pw/playwright.config.js
- name: Generate Playwright E2E Test report.
if: success() || failure()
working-directory: plugins/woocommerce
run: pnpm exec allure generate --clean ${{ env.ALLURE_RESULTS_DIR }} --output ${{ env.ALLURE_REPORT_DIR }}
- name: Archive E2E test report
if: success() || failure()
uses: actions/upload-artifact@v3
with:
name: ${{ env.E2E_ARTIFACT }}
path: |
${{ env.ALLURE_RESULTS_DIR }}
${{ env.ALLURE_REPORT_DIR }}
if-no-files-found: ignore
retention-days: 5
k6-tests:
name: k6 tests on nightly build
@ -130,6 +116,8 @@ jobs:
contents: read
needs: [api-tests]
if: success() || failure()
outputs:
test-result: ${{ steps.run-k6-tests.conclusion }}
steps:
- uses: actions/checkout@v3
@ -139,29 +127,31 @@ jobs:
install-filters: woocommerce
build: false
- name: Download and install Chromium browser.
working-directory: plugins/woocommerce
run: pnpm exec playwright install chromium
- name: Update performance test site with E2E test
working-directory: plugins/woocommerce
id: update-perf-site
continue-on-error: true
uses: ./.github/actions/tests/run-e2e-tests
with:
report-name: k6-daily-update-site--run-${{ github.run_number }}
tests: update-woocommerce.spec.js
env:
ALLURE_RESULTS_DIR: ${{ github.workspace }}/plugins/woocommerce/tests/e2e-pw/allure-results
ALLURE_REPORT_DIR: ${{ github.workspace }}/plugins/woocommerce/tests/e2e-pw/allure-report
BASE_URL: ${{ secrets.SMOKE_TEST_PERF_URL }}/
ADMIN_USER: ${{ secrets.SMOKE_TEST_PERF_ADMIN_USER }}
ADMIN_PASSWORD: ${{ secrets.SMOKE_TEST_PERF_ADMIN_PASSWORD }}
CUSTOMER_USER: ${{ secrets.SMOKE_TEST_PERF_ADMIN_USER }}
CUSTOMER_PASSWORD: ${{ secrets.SMOKE_TEST_PERF_ADMIN_PASSWORD }}
UPDATE_WC: nightly
DEFAULT_TIMEOUT_OVERRIDE: 120000
run: |
pnpm exec playwright test --config=tests/e2e-pw/playwright.config.js update-woocommerce.spec.js
continue-on-error: true
GITHUB_TOKEN: ${{ secrets.E2E_GH_TOKEN }}
UPDATE_WC: nightly
- name: Install k6
run: |
curl https://github.com/grafana/k6/releases/download/v0.33.0/k6-v0.33.0-linux-amd64.tar.gz -L | tar xvz --strip-components 1
- name: Run k6 smoke tests
id: run-k6-tests
env:
URL: ${{ secrets.SMOKE_TEST_PERF_URL }}
HOST: ${{ secrets.SMOKE_TEST_PERF_HOST }}
@ -180,7 +170,6 @@ jobs:
contents: read
needs: [api-tests]
env:
USE_WP_ENV: 1
ALLURE_RESULTS_DIR: ${{ github.workspace }}/plugins/woocommerce/tests/e2e-pw/allure-results
ALLURE_REPORT_DIR: ${{ github.workspace }}/plugins/woocommerce/tests/e2e-pw/allure-report
strategy:
@ -189,17 +178,23 @@ jobs:
include:
- plugin: 'WooCommerce Payments'
repo: 'automattic/woocommerce-payments'
slug: woocommerce-payments
- plugin: 'WooCommerce PayPal Payments'
repo: 'woocommerce/woocommerce-paypal-payments'
slug: woocommerce-paypal-payments
- plugin: 'WooCommerce Shipping & Tax'
repo: 'automattic/woocommerce-services'
slug: woocommerce-services
- plugin: 'WooCommerce Subscriptions'
repo: WC_SUBSCRIPTIONS_REPO
private: true
slug: woocommerce-subscriptions
- plugin: 'Gutenberg'
repo: 'WordPress/gutenberg'
slug: gutenberg
- plugin: 'Gutenberg - Nightly'
repo: 'bph/gutenberg'
slug: gutenberg-nightly
steps:
- uses: actions/checkout@v3
@ -208,43 +203,52 @@ jobs:
with:
build-filters: woocommerce
- name: Launch wp-env e2e environment
working-directory: plugins/woocommerce
run: pnpm env:test --filter=woocommerce
- name: Download and install Chromium browser.
working-directory: plugins/woocommerce
run: pnpm exec playwright install chromium
- name: Setup local test environment
uses: ./.github/actions/tests/setup-local-test-environment
with:
test-type: e2e
- name: Run 'Upload plugin' test
working-directory: plugins/woocommerce
id: run-upload-plugin-test
uses: ./.github/actions/tests/run-e2e-tests
with:
report-name: Smoke tests on trunk with ${{ matrix.plugin }} plugin installed (run ${{ github.run_number }})
tests: upload-plugin.spec.js
env:
PLUGIN_REPOSITORY: ${{ matrix.private && secrets[matrix.repo] || matrix.repo }}
PLUGIN_NAME: ${{ matrix.plugin }}
GITHUB_TOKEN: ${{ secrets.E2E_GH_TOKEN }}
run: pnpm test:e2e-pw upload-plugin.spec.js
- name: Run the rest of E2E tests
working-directory: plugins/woocommerce
id: run-e2e-composite-action
timeout-minutes: 60
uses: ./.github/actions/tests/run-e2e-tests
with:
playwright-config: ignore-plugin-tests.playwright.config.js
report-name: Smoke tests on trunk with ${{ matrix.plugin }} plugin installed (run ${{ github.run_number }})
env:
E2E_MAX_FAILURES: 15
run: pnpm test:e2e-pw
- name: Generate E2E Test report.
- name: Create context block and save as JSON file
if: success() || failure()
working-directory: plugins/woocommerce
run: pnpm exec allure generate --clean ${{ env.ALLURE_RESULTS_DIR }} --output ${{ env.ALLURE_REPORT_DIR }}
id: create-block-json
uses: actions/github-script@v6
with:
script: |
const script = require( './.github/actions/tests/slack-summary-daily/scripts/create-blocks-plugin-tests.js' )
script( { core } );
env:
UPLOAD_RESULT: ${{ steps.run-upload-plugin-test.outputs.result }}
E2E_RESULT: ${{ steps.run-e2e-composite-action.outputs.result }}
PLUGIN_NAME: ${{ matrix.plugin }}
PLUGIN_SLUG: ${{ matrix.slug }}
- name: Archive E2E test report
- name: Upload JSON file as artifact
if: success() || failure()
uses: actions/upload-artifact@v3
with:
name: Smoke tests on trunk with ${{ matrix.plugin }} plugin installed (run ${{ github.run_number }})
path: |
${{ env.ALLURE_RESULTS_DIR }}
${{ env.ALLURE_REPORT_DIR }}
if-no-files-found: ignore
retention-days: 5
name: ${{ env.PLUGIN_SLACK_BLOCKS_ARTIFACT }}
path: ${{ steps.create-block-json.outputs.path }}
trunk-results:
name: Publish report on smoke tests on nightly build
@ -306,7 +310,8 @@ jobs:
plugins-results:
name: Publish report on Smoke tests on trunk with plugins
if: |
( success() || failure() ) &&
( success() || failure() ) &&
( needs.test-plugins.result != 'skipped' ) &&
! github.event.pull_request.head.repo.fork
runs-on: ubuntu-20.04
needs: [e2e-tests, test-plugins, k6-tests]
@ -345,3 +350,51 @@ jobs:
-f slug="${{ matrix.slug }}" \
-f s3_root=public \
--repo woocommerce/woocommerce-test-reports
post-slack-summary:
name: Post Slack summary
runs-on: ubuntu-20.04
permissions:
contents: read
if: |
success() || (
failure() && contains( needs.*.result, 'failure' )
)
needs:
- api-tests
- e2e-tests
- k6-tests
- test-plugins
steps:
- uses: actions/checkout@v3
- name: Download Slack blocks from plugin tests
if: needs.test-plugins.result != 'skipped'
id: download-plugin-blocks
uses: actions/download-artifact@v3
with:
name: ${{ env.PLUGIN_SLACK_BLOCKS_ARTIFACT }}
path: /tmp/plugin-blocks
- name: Construct Slack payload
id: construct-slack-payload
uses: actions/github-script@v6
with:
script: |
const script = require('./.github/actions/tests/slack-summary-daily/scripts/construct-slack-payload.js');
await script( { context, core, github } );
env:
API_RESULT: ${{ needs.api-tests.outputs.test-result }}
E2E_RESULT: ${{ needs.e2e-tests.outputs.test-result || needs.e2e-tests.result }}
k6_RESULT: ${{ needs.k6-tests.outputs.test-result || needs.k6-tests.result }}
PLUGINS_BLOCKS_PATH: ${{ steps.download-plugin-blocks.outputs.download-path }}
PLUGIN_TESTS_RESULT: ${{ needs.test-plugins.result }}
- name: Send Slack message
id: send-slack-message
uses: slackapi/slack-github-action@v1.23.0
with:
channel-id: ${{ secrets.DAILY_TEST_SLACK_CHANNEL }}
payload: ${{ steps.construct-slack-payload.outputs.payload }}
env:
SLACK_BOT_TOKEN: ${{ secrets.E2E_SLACK_TOKEN }}

View File

@ -1,5 +1,12 @@
== Changelog ==
= 8.1.1 2023-09-18 =
**WooCommerce**
* Fix - Do not send user meta data back in `woocommerce_get_customer_details` response. [#40221](https://github.com/woocommerce/woocommerce/pull/40221)
* Fix - Fix possible metadata duplication when HPOS is enabled. [#40148](https://github.com/woocommerce/woocommerce/pull/40148)
= 8.1.0 2023-09-12 =
**WooCommerce**

View File

@ -0,0 +1,5 @@
Significance: patch
Type: fix
Comment: Decode html characters in SelectTree

View File

@ -6,6 +6,7 @@ import classNames from 'classnames';
import { createElement, useEffect, useState } from '@wordpress/element';
import { useInstanceId } from '@wordpress/compose';
import { BaseControl, TextControl } from '@wordpress/components';
import { decodeEntities } from '@wordpress/html-entities';
/**
* Internal dependencies
@ -16,6 +17,7 @@ import { SelectedItems } from '../experimental-select-control/selected-items';
import { ComboBox } from '../experimental-select-control/combo-box';
import { SuffixIcon } from '../experimental-select-control/suffix-icon';
import { SelectTreeMenu } from './select-tree-menu';
import { escapeHTML } from '../utils';
interface SelectTreeProps extends TreeControlProps {
id: string;
@ -185,11 +187,11 @@ export const SelectTree = function SelectTree( {
) : (
<TextControl
{ ...inputProps }
value={ props.createValue || '' }
value={ decodeEntities( props.createValue || '' ) }
onChange={ ( value ) => {
if ( onInputChange ) onInputChange( value );
const item = items.find(
( i ) => i.label === value
( i ) => i.label === escapeHTML( value )
);
if ( props.onSelect && item ) {
props.onSelect( item );

View File

@ -84,7 +84,7 @@ export { DynamicForm } from './dynamic-form';
export { default as TourKit } from './tour-kit';
export * as TourKitTypes from './tour-kit/types';
export { CollapsibleContent } from './collapsible-content';
export { createOrderedChildren, sortFillsByOrder } from './utils';
export { createOrderedChildren, sortFillsByOrder, escapeHTML } from './utils';
export { WooProductFieldItem as __experimentalWooProductFieldItem } from './woo-product-field-item';
export { WooProductSectionItem as __experimentalWooProductSectionItem } from './woo-product-section-item';
export { WooProductTabItem as __experimentalWooProductTabItem } from './woo-product-tab-item';

View File

@ -85,3 +85,10 @@ export const sortFillsByOrder: Slot.Props[ 'children' ] = ( fills ) => {
return <Fragment>{ sortedFills }</Fragment>;
};
export const escapeHTML = ( string: string ) => {
return string
.replace( /&/g, '&amp;' )
.replace( />/g, '&gt;' )
.replace( /</g, '&lt;' );
};

View File

@ -0,0 +1,4 @@
Significance: patch
Type: add
Add additional type to user preferences config.

View File

@ -29,10 +29,12 @@ export type UserPreferences = {
variable_product_block_tour_shown?: string;
variations_report_columns?: string;
product_block_variable_options_notice_dismissed?: string;
variable_items_without_price_notice_dismissed?: Record< number, string >;
};
export type WoocommerceMeta = UserPreferences & {
task_list_tracked_started_tasks?: string;
variable_items_without_price_notice_dismissed?: string;
};
export type WCUser<

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add notice to variations when variations do not have prices set.

View File

@ -0,0 +1,4 @@
Significance: minor
Type: dev
Update copy in the add variation options modal

View File

@ -0,0 +1,4 @@
Significance: minor
Type: fix
Fix error displaying block after removing variation #40255

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add redirection to product edit page if variations are added before it being saved.

View File

@ -0,0 +1,4 @@
Significance: minor
Type: fix
Fix blocks product editor variation actions styles

View File

@ -0,0 +1,5 @@
Significance: patch
Type: fix
Comment: Escape special characters when searching for taxonomies

View File

@ -3,6 +3,7 @@
*/
import { useState } from '@wordpress/element';
import { resolveSelect } from '@wordpress/data';
import { escapeHTML } from '@woocommerce/components';
/**
* Internal dependencies
*/
@ -61,7 +62,7 @@ const useTaxonomySearch = (
Taxonomy[]
>( 'taxonomy', taxonomyName, {
per_page: PAGINATION_SIZE,
search,
search: escapeHTML( search ),
} );
if ( options?.fetchParents ) {
taxonomies = await getTaxonomiesMissingParents(

View File

@ -1,16 +1,32 @@
/**
* External dependencies
*/
import { sprintf, __ } from '@wordpress/i18n';
import {
EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME,
Product,
ProductVariation,
useUserPreferences,
} from '@woocommerce/data';
import { useBlockProps } from '@wordpress/block-editor';
import { recordEvent } from '@woocommerce/tracks';
import { BlockEditProps } from '@wordpress/blocks';
import { createElement } from '@wordpress/element';
import { createElement, useMemo, useRef } from '@wordpress/element';
import { resolveSelect, useDispatch, useSelect } from '@wordpress/data';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore No types for this exist yet.
// eslint-disable-next-line @woocommerce/dependency-group
import { useEntityId, useEntityProp } from '@wordpress/core-data';
/**
* Internal dependencies
*/
import { VariationsTable } from '../../components/variations-table';
import { useValidation } from '../../contexts/validation-context';
import { VariationOptionsBlockAttributes } from './types';
import { VariableProductTour } from './variable-product-tour';
import { TRACKS_SOURCE } from '../../constants';
import { handlePrompt } from '../../utils/handle-prompt';
export function Edit( {
context,
@ -19,11 +35,168 @@ export function Edit( {
isInSelectedTab?: boolean;
};
} ) {
const noticeDimissed = useRef( false );
const { invalidateResolution } = useDispatch(
EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME
);
const productId = useEntityId( 'postType', 'product' );
const blockProps = useBlockProps();
const [ productStatus ] = useEntityProp< string >(
'postType',
'product',
'status'
);
const totalCountWithoutPriceRequestParams = useMemo(
() => ( {
product_id: productId,
order: 'asc',
orderby: 'menu_order',
has_price: false,
} ),
[ productId ]
);
const { totalCountWithoutPrice } = useSelect(
( select ) => {
const { getProductVariationsTotalCount } = select(
EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME
);
return {
totalCountWithoutPrice:
getProductVariationsTotalCount< number >(
totalCountWithoutPriceRequestParams
),
};
},
[ productId ]
);
const {
updateUserPreferences,
variable_items_without_price_notice_dismissed:
itemsWithoutPriceNoticeDismissed,
} = useUserPreferences();
const { ref: variationTableRef } = useValidation< Product >(
`variations`,
async function regularPriceValidator( defaultValue, newData ) {
/**
* We cause a validation error if there is:
* - more then one variation without a price.
* - the notice hasn't been dismissed.
* - The product hasn't already been published.
* - We are publishing the product.
*/
if (
totalCountWithoutPrice > 0 &&
! noticeDimissed.current &&
productStatus !== 'publish' &&
// New status.
newData?.status === 'publish'
) {
if ( itemsWithoutPriceNoticeDismissed !== 'yes' ) {
updateUserPreferences( {
variable_items_without_price_notice_dismissed: {
...( itemsWithoutPriceNoticeDismissed || {} ),
[ productId ]: 'no',
},
} );
}
return __(
'Set variation prices before adding this product.',
'woocommerce'
);
}
},
[ totalCountWithoutPrice ]
);
function onSetPrices(
handleUpdateAll: ( update: Partial< ProductVariation >[] ) => void
) {
recordEvent( 'product_variations_set_prices_select', {
source: TRACKS_SOURCE,
} );
const productVariationsListPromise = resolveSelect(
EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME
).getProductVariations< Pick< ProductVariation, 'id' >[] >( {
product_id: productId,
has_price: false,
_fields: [ 'id' ],
} );
handlePrompt( {
onOk( value ) {
recordEvent( 'product_variations_set_prices_update', {
source: TRACKS_SOURCE,
} );
productVariationsListPromise.then( ( variations ) => {
handleUpdateAll(
variations.map( ( { id } ) => ( {
id,
regular_price: value,
} ) )
);
} );
},
} );
}
const hasNotDismissedNotice =
! itemsWithoutPriceNoticeDismissed ||
itemsWithoutPriceNoticeDismissed[ productId ] !== 'yes';
const noticeText =
totalCountWithoutPrice > 0 && hasNotDismissedNotice
? sprintf(
/** Translators: Number of variations without price */
__(
'%d variations do not have prices. Variations that do not have prices will not be visible to customers.',
'woocommerce'
),
totalCountWithoutPrice
)
: '';
return (
<div { ...blockProps }>
<VariationsTable />
<VariationsTable
ref={ variationTableRef as React.Ref< HTMLDivElement > }
noticeText={ noticeText }
onNoticeDismiss={ () => {
noticeDimissed.current = true;
updateUserPreferences( {
variable_items_without_price_notice_dismissed: {
...( itemsWithoutPriceNoticeDismissed || {} ),
[ productId ]: 'yes',
},
} );
} }
noticeActions={ [
{
label: __( 'Set prices', 'woocommerce' ),
onClick: onSetPrices,
className: 'is-destructive',
},
] }
onVariationTableChange={ ( type, update ) => {
if (
type === 'delete' ||
( type === 'update' &&
update &&
update.find(
( variation ) =>
variation.regular_price ||
variation.sale_price
) )
) {
invalidateResolution(
'getProductVariationsTotalCount',
[ totalCountWithoutPriceRequestParams ]
);
}
} }
/>
{ context?.isInSelectedTab && <VariableProductTour /> }
</div>
);

View File

@ -130,20 +130,9 @@ export function Edit() {
'Add variation options',
'woocommerce'
),
newAttributeModalDescription: createInterpolateElement(
__(
'Select from existing <globalAttributeLink>global attributes</globalAttributeLink> or create options for buyers to choose on the product page. You can change the order later.',
'woocommerce'
),
{
globalAttributeLink: (
<Link
href="https://woocommerce.com/document/variable-product/#add-attributes-to-use-for-variations"
type="external"
target="_blank"
/>
),
}
newAttributeModalDescription: __(
'Select from existing attributes or create new ones to add new variations for your product. You can change the order later.',
'woocommerce'
),
attributeRemoveLabel: __(
'Remove variation option',

View File

@ -4,14 +4,9 @@
import classNames from 'classnames';
import type { BlockEditProps } from '@wordpress/blocks';
import { Button } from '@wordpress/components';
import { Link } from '@woocommerce/components';
import { Product, ProductAttribute } from '@woocommerce/data';
import { recordEvent } from '@woocommerce/tracks';
import {
createElement,
useState,
createInterpolateElement,
} from '@wordpress/element';
import { createElement, useState } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import {
useBlockProps,
@ -131,20 +126,9 @@ export function Edit( {
{ isNewModalVisible && (
<NewAttributeModal
title={ __( 'Add variation options', 'woocommerce' ) }
description={ createInterpolateElement(
__(
'Select from existing <globalAttributeLink>global attributes</globalAttributeLink> or create options for buyers to choose on the product page. You can change the order later.',
'woocommerce'
),
{
globalAttributeLink: (
<Link
href="https://woocommerce.com/document/variable-product/#add-attributes-to-use-for-variations"
type="external"
target="_blank"
/>
),
}
description={ __(
'Select from existing attributes or create new ones to add new variations for your product. You can change the order later.',
'woocommerce'
) }
createNewAttributesAsGlobal={ true }
notice={ '' }

View File

@ -403,7 +403,9 @@ export const NewAttributeModal: React.FC< NewAttributeModalProps > = ( {
}
value={
attribute ===
null
null ||
attribute ===
undefined
? []
: attribute.terms
}
@ -506,7 +508,9 @@ export const NewAttributeModal: React.FC< NewAttributeModalProps > = ( {
label={ addAccessibleLabel }
disabled={
values.attributes.length === 1 &&
values.attributes[ 0 ] === null
( values.attributes[ 0 ] === null ||
values.attributes[ 0 ] ===
undefined )
}
onClick={ () =>
onAddingAttributes( values )

View File

@ -89,7 +89,9 @@ export const AttributeListItem: React.FC< AttributeListItemProps > = ( {
position="top center"
text={ NOT_VISIBLE_TEXT }
>
<HiddenWithHelpIcon />
<div>
<HiddenWithHelpIcon />
</div>
</Tooltip>
) }
{ typeof onEditClick === 'function' && (

View File

@ -26,7 +26,7 @@ export function usePublish( {
onPublishSuccess?( product: Product ): void;
onPublishError?( error: WPError ): void;
} ): Button.ButtonProps {
const { isValidating, validate } = useValidations();
const { isValidating, validate } = useValidations< Product >();
const [ productId ] = useEntityProp< number >(
'postType',
@ -61,7 +61,9 @@ export function usePublish( {
}
try {
await validate();
await validate( {
status: 'publish',
} );
// The publish button click not only change the status of the product
// but also save all the pending changes. So even if the status is
@ -93,6 +95,12 @@ export function usePublish( {
? 'product_publish_error'
: 'product_create_error',
} as WPError;
if ( ( error as Record< string, string > ).variations ) {
wpError.code = 'variable_product_no_variation_prices';
wpError.message = (
error as Record< string, string >
).variations;
}
}
onPublishError( wpError );
}

View File

@ -56,7 +56,7 @@ export function useSaveDraft( {
[ productId ]
);
const { isValidating, validate } = useValidations();
const { isValidating, validate } = useValidations< Product >();
const ariaDisabled =
disabled ||
@ -76,7 +76,7 @@ export function useSaveDraft( {
}
try {
await validate();
await validate( { status: 'draft' } );
await editEntityRecord( 'postType', 'product', productId, {
status: 'draft',

View File

@ -15,6 +15,21 @@ $table-row-height: calc($grid-unit * 9);
border-bottom: 1px solid $gray-200;
}
&__notice {
border-left: 0px;
margin: 0 0 $gap-large 0;
&.is-error {
background-color: #fcf0f1;
}
.components-notice__actions {
margin-top: $gap-small;
.components-button:first-child {
margin-left: 0px;
}
}
}
&__table {
height: $table-row-height * 5;
overflow: auto;
@ -26,6 +41,7 @@ $table-row-height: calc($grid-unit * 9);
border-color: $gray-600;
}
}
margin-left: $gap-smallest;
}
&__filters {
@ -84,6 +100,7 @@ $table-row-height: calc($grid-unit * 9);
align-items: center;
justify-content: flex-end;
gap: $gap-smaller;
margin-right: $gap-smallest;
&--delete {
&.components-button.components-menu-item__button.is-link {

View File

@ -5,6 +5,7 @@ import { __, sprintf } from '@wordpress/i18n';
import {
Button,
CheckboxControl,
Notice,
Spinner,
Tooltip,
} from '@wordpress/components';
@ -27,6 +28,8 @@ import {
createElement,
useRef,
useMemo,
Fragment,
forwardRef,
} from '@wordpress/element';
import { useSelect, useDispatch } from '@wordpress/data';
import classnames from 'classnames';
@ -53,7 +56,38 @@ import HiddenWithHelpIcon from '../../icons/hidden-with-help-icon';
const NOT_VISIBLE_TEXT = __( 'Not visible to customers', 'woocommerce' );
export function VariationsTable() {
type VariationsTableProps = {
noticeText?: string;
noticeStatus?: 'error' | 'warning' | 'success' | 'info';
onNoticeDismiss?: () => void;
noticeActions?: {
label: string;
onClick: (
handleUpdateAll: ( update: Partial< ProductVariation >[] ) => void,
handleDeleteAll: ( update: Partial< ProductVariation >[] ) => void
) => void;
className?: string;
variant?: string;
}[];
onVariationTableChange?: (
type: 'update' | 'delete',
updates?: Partial< ProductVariation >[]
) => void;
};
export const VariationsTable = forwardRef<
HTMLDivElement,
VariationsTableProps
>( function Table(
{
noticeText,
noticeActions = [],
noticeStatus = 'error',
onNoticeDismiss = () => {},
onVariationTableChange = () => {},
}: VariationsTableProps,
ref
) {
const [ currentPage, setCurrentPage ] = useState( 1 );
const lastVariations = useRef< ProductVariation[] | null >( null );
const [ perPage, setPerPage ] = useState(
@ -90,22 +124,9 @@ export function VariationsTable() {
} ),
[ productId ]
);
const context = useContext( CurrencyContext );
const { formatAmount } = context;
const { totalCount } = useSelect(
( select ) => {
const { getProductVariationsTotalCount } = select(
EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME
);
return {
totalCount: getProductVariationsTotalCount< number >(
totalCountRequestParams
),
};
},
[ productId ]
);
const { isLoading, latestVariations, isGeneratingVariations } = useSelect(
( select ) => {
const {
@ -127,6 +148,21 @@ export function VariationsTable() {
[ currentPage, perPage, productId ]
);
const { totalCount } = useSelect(
( select ) => {
const { getProductVariationsTotalCount } = select(
EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME
);
return {
totalCount: getProductVariationsTotalCount< number >(
totalCountRequestParams
),
};
},
[ productId ]
);
const paginationProps = usePagination( {
totalCount,
defaultPerPage: DEFAULT_VARIATION_PER_PAGE_OPTION,
@ -179,13 +215,17 @@ export function VariationsTable() {
recordEvent( 'product_variations_delete', {
source: TRACKS_SOURCE,
} );
invalidateResolution( 'getProductVariations', [
requestParams,
] );
} )
.finally( () =>
.finally( () => {
setIsUpdating( ( prevState ) => ( {
...prevState,
[ variationId ]: false,
} ) )
);
} ) );
onVariationTableChange( 'delete' );
} );
}
function handleVariationChange(
@ -212,12 +252,13 @@ export function VariationsTable() {
__( 'Failed to save variation.', 'woocommerce' )
);
} )
.finally( () =>
.finally( () => {
setIsUpdating( ( prevState ) => ( {
...prevState,
[ variationId ]: false,
} ) )
);
} ) );
onVariationTableChange( 'update', [ variation ] );
} );
}
function handleUpdateAll( update: Partial< ProductVariation >[] ) {
@ -238,6 +279,7 @@ export function VariationsTable() {
response.update.length
)
);
onVariationTableChange( 'update', update );
} )
.catch( () => {
createErrorNotice(
@ -266,6 +308,7 @@ export function VariationsTable() {
response.delete.length
)
);
onVariationTableChange( 'delete' );
} )
.catch( () => {
createErrorNotice(
@ -275,7 +318,7 @@ export function VariationsTable() {
}
return (
<div className="woocommerce-product-variations">
<div className="woocommerce-product-variations" ref={ ref }>
{ ( isLoading || isGeneratingVariations ) && (
<div className="woocommerce-product-variations__loading">
<Spinner />
@ -286,6 +329,21 @@ export function VariationsTable() {
) }
</div>
) }
{ noticeText && (
<Notice
status={ noticeStatus }
className="woocommerce-product-variations__notice"
onRemove={ onNoticeDismiss }
actions={ noticeActions.map( ( action ) => ( {
...action,
onClick: () => {
action?.onClick( handleUpdateAll, handleDeleteAll );
},
} ) ) }
>
{ noticeText }
</Notice>
) }
<div className="woocommerce-product-variations__header">
<div className="woocommerce-product-variations__selection">
<CheckboxControl
@ -401,18 +459,25 @@ export function VariationsTable() {
}
) }
>
<span
className={ classnames(
'woocommerce-product-variations__status-dot',
getProductStockStatusClass( variation )
) }
>
</span>
{ getProductStockStatus( variation ) }
{ variation.regular_price && (
<>
<span
className={ classnames(
'woocommerce-product-variations__status-dot',
getProductStockStatusClass(
variation
)
) }
>
</span>
{ getProductStockStatus( variation ) }
</>
) }
</div>
<div className="woocommerce-product-variations__actions">
{ variation.status === 'private' && (
{ ( variation.status === 'private' ||
! variation.regular_price ) && (
<Tooltip
// @ts-expect-error className is missing in TS, should remove this when it is included.
className="woocommerce-attribute-list-item__actions-tooltip"
@ -459,4 +524,4 @@ export function VariationsTable() {
) }
</div>
);
}
} );

View File

@ -1,6 +1,9 @@
export type ValidatorResponse = Promise< ValidationError >;
export type Validator< T > = ( initialValue?: T ) => ValidatorResponse;
export type Validator< T > = (
initialValue?: T,
newData?: Partial< T >
) => ValidatorResponse;
export type ValidationContextProps< T > = {
errors: ValidationErrors;
@ -9,7 +12,7 @@ export type ValidationContextProps< T > = {
validator: Validator< T >
): React.Ref< HTMLElement >;
validateField( name: string ): ValidatorResponse;
validateAll(): Promise< ValidationErrors >;
validateAll( newData?: Partial< T > ): Promise< ValidationErrors >;
};
export type ValidationProviderProps< T > = {
@ -19,9 +22,9 @@ export type ValidationProviderProps< T > = {
export type ValidationError = string | undefined;
export type ValidationErrors = Record< string, ValidationError >;
export type ValidatorRegistration = {
export type ValidatorRegistration< T > = {
name: string;
ref: React.Ref< HTMLElement >;
error?: ValidationError;
validate(): ValidatorResponse;
validate( newData?: Partial< T > ): ValidatorResponse;
};

View File

@ -13,17 +13,17 @@ function isInvalid( errors: ValidationErrors ) {
return Object.values( errors ).some( Boolean );
}
export function useValidations() {
export function useValidations< T = unknown >() {
const context = useContext( ValidationContext );
const [ isValidating, setIsValidating ] = useState( false );
return {
isValidating,
async validate() {
async validate( newData?: Partial< T > ) {
setIsValidating( true );
return new Promise< void >( ( resolve, reject ) => {
context
.validateAll()
.validateAll( newData )
.then( ( errors ) => {
if ( isInvalid( errors ) ) {
reject( errors );

View File

@ -38,11 +38,14 @@ export function ValidationProvider< T >( {
};
}
async function validateField( validatorId: string ): ValidatorResponse {
async function validateField(
validatorId: string,
newData?: Partial< T >
): ValidatorResponse {
const validators = validatorsRef.current;
if ( validatorId in validators ) {
const validator = validators[ validatorId ];
const result = validator( initialValue );
const result = validator( initialValue, newData );
return result.then( ( error ) => {
setErrors( ( currentErrors ) => ( {
@ -56,12 +59,17 @@ export function ValidationProvider< T >( {
return Promise.resolve( undefined );
}
async function validateAll(): Promise< ValidationErrors > {
async function validateAll(
newData: Partial< T >
): Promise< ValidationErrors > {
const newErrors: ValidationErrors = {};
const validators = validatorsRef.current;
for ( const validatorId in validators ) {
newErrors[ validatorId ] = await validateField( validatorId );
newErrors[ validatorId ] = await validateField(
validatorId,
newData
);
}
setErrors( newErrors );

View File

@ -33,5 +33,8 @@ export function useConfirmUnsavedProductChanges() {
[ productId ]
);
useConfirmUnsavedChanges( hasEdits || isSaving, preventLeavingProductForm );
useConfirmUnsavedChanges(
hasEdits || isSaving,
preventLeavingProductForm( productId )
);
}

View File

@ -1,11 +1,13 @@
/**
* External dependencies
*/
import { useDispatch } from '@wordpress/data';
import { resolveSelect, useDispatch } from '@wordpress/data';
import { useEntityProp } from '@wordpress/core-data';
import { useCallback, useState } from '@wordpress/element';
import { getNewPath, getPath, navigateTo } from '@woocommerce/navigation';
import {
EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME,
Product,
ProductDefaultAttribute,
} from '@woocommerce/data';
@ -34,6 +36,13 @@ export function useProductVariationsHelper() {
) => {
setIsGenerating( true );
const lastStatus = (
( await resolveSelect( 'core' ).getEditedEntityRecord(
'postType',
'product',
productId
) ) as Product
).status;
const hasVariableAttribute = attributes.some(
( attr ) => attr.variation
);
@ -64,6 +73,13 @@ export function useProductVariationsHelper() {
} )
.finally( () => {
setIsGenerating( false );
if (
lastStatus === 'auto-draft' &&
getPath().endsWith( 'add-product' )
) {
const url = getNewPath( {}, `/product/${ productId }` );
navigateTo( { url } );
}
} );
},
[]

View File

@ -4,6 +4,7 @@
import { __ } from '@wordpress/i18n';
export type WPErrorCode =
| 'variable_product_no_variation_prices'
| 'product_invalid_sku'
| 'product_create_error'
| 'product_publish_error'
@ -19,6 +20,8 @@ export type WPError = {
export function getProductErrorMessage( error: WPError ) {
switch ( error.code ) {
case 'variable_product_no_variation_prices':
return error.message;
case 'product_invalid_sku':
return __( 'Invalid or duplicated SKU.', 'woocommerce' );
case 'product_create_error':

View File

@ -6,10 +6,19 @@ import { Location } from 'react-router-dom';
/**
* Allow switching between tabs without prompting for unsaved changes.
*/
export const preventLeavingProductForm = ( toUrl: URL, fromUrl: Location ) => {
const toParams = new URLSearchParams( toUrl.search );
const fromParams = new URLSearchParams( fromUrl.search );
toParams.delete( 'tab' );
fromParams.delete( 'tab' );
return toParams.toString() !== fromParams.toString();
};
export const preventLeavingProductForm =
( productId?: number ) => ( toUrl: URL, fromUrl: Location ) => {
const toParams = new URLSearchParams( toUrl.search );
const fromParams = new URLSearchParams( fromUrl.search );
toParams.delete( 'tab' );
fromParams.delete( 'tab' );
// Prevent dialog from happening if moving from add new to edit page of same product.
if (
productId !== undefined &&
fromParams.get( 'path' ) === '/add-product' &&
toParams.get( 'path' ) === '/product/' + productId
) {
return false;
}
return toParams.toString() !== fromParams.toString();
};

View File

@ -16,7 +16,7 @@ describe( 'preventLeavingProductForm', () => {
const fromUrl = {
search: 'admin.php?page=wc-admin&path=/product/123&tab=general',
} as Location;
const shouldPrevent = preventLeavingProductForm( toUrl, fromUrl );
const shouldPrevent = preventLeavingProductForm()( toUrl, fromUrl );
expect( shouldPrevent ).toBe( true );
} );
@ -27,7 +27,7 @@ describe( 'preventLeavingProductForm', () => {
const fromUrl = {
search: 'admin.php?page=wc-admin&path=/product/123&tab=general',
} as Location;
const shouldPrevent = preventLeavingProductForm( toUrl, fromUrl );
const shouldPrevent = preventLeavingProductForm()( toUrl, fromUrl );
expect( shouldPrevent ).toBe( true );
} );
@ -38,7 +38,35 @@ describe( 'preventLeavingProductForm', () => {
const fromUrl = {
search: 'admin.php?page=wc-admin&path=/product/123&tab=shipping',
} as Location;
const shouldPrevent = preventLeavingProductForm( toUrl, fromUrl );
const shouldPrevent = preventLeavingProductForm()( toUrl, fromUrl );
expect( shouldPrevent ).toBe( true );
} );
it( 'should allow leaving when moving from the add-product to the edit page with same product id', () => {
const toUrl = new URL(
'http://mysite.com/admin.php?page=wc-admin&path=/product/123&tab=general'
);
const fromUrl = {
search: 'admin.php?page=wc-admin&path=/add-product',
} as Location;
const shouldPrevent = preventLeavingProductForm( 123 )(
toUrl,
fromUrl
);
expect( shouldPrevent ).toBe( false );
} );
it( 'should not allow leaving when moving from the add-product to the edit page with different product id', () => {
const toUrl = new URL(
'http://mysite.com/admin.php?page=wc-admin&path=/product/123&tab=general'
);
const fromUrl = {
search: 'admin.php?page=wc-admin&path=/add-product',
} as Location;
const shouldPrevent = preventLeavingProductForm( 333 )(
toUrl,
fromUrl
);
expect( shouldPrevent ).toBe( true );
} );
@ -49,7 +77,7 @@ describe( 'preventLeavingProductForm', () => {
const fromUrl = {
search: 'admin.php?page=wc-admin&path=/product/123&tab=shipping&other_param=b',
} as Location;
const shouldPrevent = preventLeavingProductForm( toUrl, fromUrl );
const shouldPrevent = preventLeavingProductForm()( toUrl, fromUrl );
expect( shouldPrevent ).toBe( true );
} );
} );

View File

@ -1492,11 +1492,15 @@ export const CoreProfilerController = ( {
hasJetpackSelected: ( context ) => {
return (
context.pluginsSelected.find(
( plugin ) => plugin === 'jetpack'
( plugin ) =>
plugin === 'jetpack' ||
plugin === 'jetpack-boost'
) !== undefined ||
context.pluginsAvailable.find(
( plugin: Extension ) =>
plugin.key === 'jetpack' && plugin.is_activated
( plugin.key === 'jetpack' ||
plugin.key === 'jetpack-boost' ) &&
plugin.is_activated
) !== undefined
);
},

View File

@ -6,7 +6,7 @@
* External dependencies
*/
import { useResizeObserver, pure, useRefEffect } from '@wordpress/compose';
import { useMemo, useContext } from '@wordpress/element';
import { useContext } from '@wordpress/element';
import { Disabled } from '@wordpress/components';
import {
__unstableEditorStyles as EditorStyles,
@ -52,7 +52,6 @@ export type ScaledBlockPreviewProps = {
function ScaledBlockPreview( {
viewportWidth,
containerWidth,
minHeight,
settings,
additionalStyles,
onClickNavigationItem,
@ -70,28 +69,14 @@ function ScaledBlockPreview( {
viewportWidth = containerWidth;
}
// @ts-ignore No types for this exist yet.
const [ contentResizeListener, { height: contentHeight } ] =
useResizeObserver();
// Avoid scrollbars for pattern previews.
const editorStyles = useMemo( () => {
return [
{
css: 'body{height:auto;overflow:hidden;border:none;padding:0;}',
__unstableType: 'presets',
},
...settings.styles,
];
}, [ settings.styles ] );
// Initialize on render instead of module top level, to avoid circular dependency issues.
MemoizedBlockList = MemoizedBlockList || pure( BlockList );
const scale = containerWidth / viewportWidth;
return (
<DisabledProvider value={ true }>
<Iframe
aria-hidden
tabIndex={ -1 }
contentRef={ useRefEffect( ( bodyElement: HTMLBodyElement ) => {
const {
ownerDocument: { documentElement },
@ -225,18 +210,8 @@ function ScaledBlockPreview( {
} );
};
}, [] ) }
aria-hidden
tabIndex={ -1 }
style={ {
width: viewportWidth,
height: contentHeight,
minHeight:
scale !== 0 && scale < 1 && minHeight
? minHeight / scale
: minHeight,
} }
>
<EditorStyles styles={ editorStyles } />
<EditorStyles styles={ settings.styles } />
<style>
{ `
.block-editor-block-list__block::before,
@ -267,7 +242,6 @@ function ScaledBlockPreview( {
${ additionalStyles }
` }
</style>
{ contentResizeListener }
<MemoizedBlockList renderAppender={ false } />
{ /* Only load font families when there are two font families (font-paring selection). Otherwise, it is not needed. */ }
{ externalFontFamilies.length === 2 && (

View File

@ -3,7 +3,6 @@
/**
* External dependencies
*/
import classNames from 'classnames';
// @ts-ignore No types for this exist yet.
import { useEntityRecords } from '@wordpress/core-data';
// @ts-ignore No types for this exist yet.
@ -46,7 +45,7 @@ export const BlockEditor = ( {} ) => {
: 'topDown';
const previewOpacity = useScrollOpacity(
'.interface-navigable-region.interface-interface-skeleton__content',
'.woocommerce-customize-store__block-editor iframe',
scrollDirection
);
@ -92,59 +91,6 @@ export const BlockEditor = ( {} ) => {
[ history, urlParams, pages ]
);
if ( urlParams.path === '/customize-store/assembler-hub/homepage' ) {
// When assembling the homepage preview, we need to render the blocks in a different way than the rest of the pages.
// Because we want to show a action bar when hovering over a pattern. This is not needed for the rest of the pages and will cause an issue with logo editing.
return (
<div className="woocommerce-customize-store__block-editor">
{ blocks.map( ( block, index ) => {
// Add padding to the top and bottom of the block preview.
let additionalStyles = '';
let hasActionBar = false;
switch ( true ) {
case index === 0:
// header
additionalStyles = `
.editor-styles-wrapper{ padding-top: var(--wp--style--root--padding-top) };'
`;
break;
case index === blocks.length - 1:
// footer
additionalStyles = `
.editor-styles-wrapper{ padding-bottom: var(--wp--style--root--padding-bottom) };
`;
break;
default:
hasActionBar = true;
}
return (
<div
key={ block.clientId }
className={ classNames(
'woocommerce-block-preview-container',
{
'has-action-menu': hasActionBar,
}
) }
>
<BlockPreview
blocks={ block }
settings={ settings }
additionalStyles={ additionalStyles }
onClickNavigationItem={ onClickNavigationItem }
// Use sub registry because we have multiple previews
useSubRegistry={ true }
previewOpacity={ previewOpacity }
/>
</div>
);
} ) }
</div>
);
}
return (
<div className="woocommerce-customize-store__block-editor">
<div className={ 'woocommerce-block-preview-container' }>

View File

@ -24,14 +24,14 @@ export const useEditorScroll = ( {
}
const previewContainer =
document.querySelector< HTMLDivElement >( editorSelector );
document.querySelector< HTMLIFrameElement >( editorSelector );
if ( previewContainer ) {
previewContainer?.scrollTo(
previewContainer.contentWindow?.scrollTo(
0,
scrollDirection === 'bottom'
? previewContainer?.scrollHeight
? previewContainer.contentDocument?.body.scrollHeight || 0
: 0
);
}
}, [ isEditorLoading, scrollDirection ] );
}, [ isEditorLoading, editorSelector, scrollDirection ] );
};

View File

@ -0,0 +1,142 @@
/* eslint-disable @woocommerce/dependency-group */
/* eslint-disable @typescript-eslint/ban-ts-comment */
/**
* External dependencies
*/
import { useMemo } from '@wordpress/element';
import { parse } from '@wordpress/blocks';
/**
* Internal dependencies
*/
import { usePatterns, Pattern, PatternWithBlocks } from './use-patterns';
// TODO: It might be better to create an API endpoint to get the templates.
export const LARGE_BUSINESS_TEMPLATES = {
template1: [
'a8c/cover-image-with-left-aligned-call-to-action',
'woocommerce-blocks/featured-products-5-item-grid',
'woocommerce-blocks/featured-products-fresh-and-tasty',
'woocommerce-blocks/featured-category-triple',
'a8c/3-column-testimonials',
'a8c/quotes-2',
'woocommerce-blocks/social-follow-us-in-social-media',
],
template2: [
'woocommerce-blocks/hero-product-split',
'woocommerce-blocks/featured-products-fresh-and-tasty',
'woocommerce-blocks/featured-category-triple',
'woocommerce-blocks/featured-products-fresh-and-tasty',
'a8c/three-columns-with-images-and-text',
'woocommerce-blocks/testimonials-3-columns',
'a8c/subscription',
],
template3: [
'a8c/call-to-action-7',
'a8c/3-column-testimonials',
'woocommerce-blocks/featured-products-fresh-and-tasty',
'woocommerce-blocks/featured-category-cover-image',
'woocommerce-blocks/featured-products-5-item-grid',
'woocommerce-blocks/featured-products-5-item-grid',
'woocommerce-blocks/social-follow-us-in-social-media',
],
};
export const SMALL_MEDIUM_BUSINESS_TEMPLATES = {
template1: [
'woocommerce-blocks/featured-products-fresh-and-tasty',
'woocommerce-blocks/testimonials-single',
'woocommerce-blocks/hero-product-3-split',
'a8c/contact-8',
],
template2: [
'a8c/about-me-4',
'a8c/product-feature-with-buy-button',
'woocommerce-blocks/featured-products-fresh-and-tasty',
'a8c/subscription',
'woocommerce-blocks/testimonials-3-columns',
'a8c/contact-with-map-on-the-left',
],
template3: [
'a8c/heading-and-video',
'a8c/3-column-testimonials',
'woocommerce-blocks/product-hero',
'a8c/quotes-2',
'a8c/product-feature-with-buy-button',
'a8c/simple-two-column-layout',
'woocommerce-blocks/social-follow-us-in-social-media',
],
};
const TEMPLATES = {
template1: LARGE_BUSINESS_TEMPLATES.template1,
template2: LARGE_BUSINESS_TEMPLATES.template2,
template3: LARGE_BUSINESS_TEMPLATES.template3,
template4: SMALL_MEDIUM_BUSINESS_TEMPLATES.template1,
template5: SMALL_MEDIUM_BUSINESS_TEMPLATES.template2,
template6: SMALL_MEDIUM_BUSINESS_TEMPLATES.template3,
};
export const getTemplatePatterns = (
template: string[],
patternsByName: Record< string, Pattern >
) =>
template
.map( ( patternName: string ) => {
const pattern = patternsByName[ patternName ];
if ( pattern && pattern.content ) {
return {
...pattern,
// @ts-ignore - Passing options is valid, but not in the type.
blocks: parse( pattern.content, {
__unstableSkipMigrationLogs: true,
} ),
};
}
return null;
} )
.filter( ( pattern ) => pattern !== null ) as PatternWithBlocks[];
export const patternsToNameMap = ( blockPatterns: Pattern[] ) =>
blockPatterns.reduce(
( acc: Record< string, Pattern >, pattern: Pattern ) => {
acc[ pattern.name ] = pattern;
return acc;
},
{}
);
export const useHomeTemplates = () => {
const { blockPatterns, isLoading } = usePatterns();
const patternsByName = useMemo(
() => patternsToNameMap( blockPatterns ),
[ blockPatterns ]
);
const homeTemplates = useMemo( () => {
if ( isLoading ) return {};
const recommendedTemplates = TEMPLATES;
return Object.entries( recommendedTemplates ).reduce(
(
acc: Record< string, PatternWithBlocks[] >,
[ templateName, template ]
) => {
if ( templateName in recommendedTemplates ) {
acc[ templateName ] = getTemplatePatterns(
template,
patternsByName
);
}
return acc;
},
{}
);
}, [ isLoading, patternsByName ] );
return {
homeTemplates,
isLoading,
};
};

View File

@ -9,7 +9,7 @@ import { store as coreStore } from '@wordpress/core-data';
import { useMemo } from '@wordpress/element';
import { BlockInstance, parse } from '@wordpress/blocks';
type Pattern = {
export type Pattern = {
blockTypes: string[];
categories: string[];
content: string;
@ -18,15 +18,17 @@ type Pattern = {
title: string;
};
type PatternWithBlocks = Pattern & {
export type PatternWithBlocks = Pattern & {
blocks: BlockInstance[];
};
export const usePatternsByCategory = ( category: string ) => {
export const usePatterns = () => {
const { blockPatterns, isLoading } = useSelect(
( select ) => ( {
// @ts-ignore - This is valid.
blockPatterns: select( coreStore ).getBlockPatterns(),
blockPatterns: select(
coreStore
// @ts-ignore - This is valid.
).getBlockPatterns() as Pattern[],
isLoading:
// @ts-ignore - This is valid.
! select( coreStore ).hasFinishedResolution(
@ -36,6 +38,15 @@ export const usePatternsByCategory = ( category: string ) => {
[]
);
return {
blockPatterns,
isLoading,
};
};
export const usePatternsByCategory = ( category: string ) => {
const { blockPatterns, isLoading } = usePatterns();
const patternsByCategory: PatternWithBlocks[] = useMemo( () => {
return ( blockPatterns || [] )
.filter( ( pattern: Pattern ) =>

View File

@ -1,7 +1,11 @@
/* eslint-disable @woocommerce/dependency-group */
/* eslint-disable @typescript-eslint/ban-ts-comment */
/**
* External dependencies
*/
import { useEffect, useState } from '@wordpress/element';
// @ts-ignore No types for this exist yet.
import { useIsSiteEditorLoading } from '@wordpress/edit-site/build-module/components/layout/hooks';
type ScrollDirection = 'topDown' | 'bottomUp';
@ -11,35 +15,50 @@ export const useScrollOpacity = (
sensitivity = 0.2
) => {
const [ opacity, setOpacity ] = useState( 0.05 );
const isEditorLoading = useIsSiteEditorLoading();
useEffect( () => {
const targetElement = document.querySelector( selector );
let targetElement: Document | Element | null =
document.querySelector( selector );
const isIFrame = targetElement?.tagName === 'IFRAME';
if ( isIFrame ) {
targetElement = ( targetElement as HTMLIFrameElement )
.contentDocument;
}
const handleScroll = () => {
if ( targetElement ) {
const maxScrollHeight =
targetElement.scrollHeight - targetElement.clientHeight;
const currentScrollPosition = targetElement.scrollTop;
const maxEffectScroll = maxScrollHeight * sensitivity;
let calculatedOpacity;
if ( direction === 'bottomUp' ) {
calculatedOpacity =
1 - currentScrollPosition / maxEffectScroll;
} else {
calculatedOpacity = currentScrollPosition / maxEffectScroll;
}
calculatedOpacity = 0.1 + 0.9 * calculatedOpacity;
// Clamp opacity between 0.1 and 1
calculatedOpacity = Math.max(
0.1,
Math.min( calculatedOpacity, 1 )
);
setOpacity( calculatedOpacity );
if ( ! targetElement ) {
return;
}
const contentElement = isIFrame
? ( targetElement as Document ).documentElement
: ( targetElement as Element );
const maxScrollHeight =
contentElement.scrollHeight - contentElement.clientHeight;
const currentScrollPosition = contentElement.scrollTop;
const maxEffectScroll = maxScrollHeight * sensitivity;
let calculatedOpacity;
if ( direction === 'bottomUp' ) {
calculatedOpacity =
maxScrollHeight / maxEffectScroll -
currentScrollPosition / maxEffectScroll;
} else {
calculatedOpacity = currentScrollPosition / maxEffectScroll;
}
calculatedOpacity = 0.1 + 0.9 * calculatedOpacity;
// Clamp opacity between 0.1 and 1
calculatedOpacity = Math.max(
0.1,
Math.min( calculatedOpacity, 1 )
);
setOpacity( calculatedOpacity );
};
if ( targetElement ) {
@ -51,7 +70,7 @@ export const useScrollOpacity = (
targetElement.removeEventListener( 'scroll', handleScroll );
}
};
}, [ selector, direction, sensitivity ] );
}, [ selector, direction, sensitivity, isEditorLoading ] );
return opacity;
};

View File

@ -4,6 +4,7 @@
import { __ } from '@wordpress/i18n';
import { useState } from '@wordpress/element';
import { TourKit, TourKitTypes } from '@woocommerce/components';
import { recordEvent } from '@woocommerce/tracks';
export * from './use-onboarding-tour';
type OnboardingTourProps = {
@ -83,8 +84,15 @@ export const OnboardingTour = ( {
],
closeHandler: ( _steps, _currentStepIndex, source ) => {
if ( source === 'done-btn' ) {
// Click on "Take a tour" button
recordEvent(
'customize_your_store_assembler_hub_tour_start'
);
setShowWelcomeTour( false );
} else {
recordEvent(
'customize_your_store_assembler_hub_tour_skip'
);
onClose();
}
},
@ -196,7 +204,19 @@ export const OnboardingTour = ( {
},
},
],
closeHandler: onClose,
closeHandler: ( _steps, _currentStepIndex, source ) => {
if ( source === 'done-btn' ) {
recordEvent(
'customize_your_store_assembler_hub_tour_complete'
);
} else {
recordEvent(
'customize_your_store_assembler_hub_tour_close'
);
}
onClose();
},
} }
></TourKit>
);

View File

@ -0,0 +1,106 @@
/**
* External dependencies
*/
import { render, screen } from '@testing-library/react';
import { recordEvent } from '@woocommerce/tracks';
/**
* Internal dependencies
*/
import { OnboardingTour } from '../index';
jest.mock( '@woocommerce/tracks', () => ( { recordEvent: jest.fn() } ) );
describe( 'OnboardingTour', () => {
let props: {
onClose: jest.Mock;
setShowWelcomeTour: jest.Mock;
showWelcomeTour: boolean;
};
beforeEach( () => {
props = {
onClose: jest.fn(),
setShowWelcomeTour: jest.fn(),
showWelcomeTour: true,
};
} );
it( 'should render welcome tour', () => {
render( <OnboardingTour { ...props } /> );
expect(
screen.getByText( /Welcome to your AI-generated store!/i )
).toBeInTheDocument();
} );
it( 'should render step 1', () => {
render( <OnboardingTour { ...props } showWelcomeTour={ false } /> );
expect(
screen.getByText( /View your changes in real time/i )
).toBeInTheDocument();
} );
it( 'should record an event when clicking on "Take a tour" button', () => {
render( <OnboardingTour { ...props } /> );
screen
.getByRole( 'button', {
name: /Take a tour/i,
} )
.click();
expect( recordEvent ).toHaveBeenCalledWith(
'customize_your_store_assembler_hub_tour_start'
);
} );
it( 'should record an event when clicking on "Skip" button', () => {
render( <OnboardingTour { ...props } /> );
screen
.getByRole( 'button', {
name: /Skip/i,
} )
.click();
expect( recordEvent ).toHaveBeenCalledWith(
'customize_your_store_assembler_hub_tour_skip'
);
} );
it( 'should record an event when clicking on "Skip" button', () => {
render( <OnboardingTour { ...props } showWelcomeTour={ false } /> );
screen
.getByRole( 'button', {
name: 'Close Tour',
} )
.click();
expect( recordEvent ).toHaveBeenCalledWith(
'customize_your_store_assembler_hub_tour_close'
);
} );
it( 'should record an event when complete the tour', () => {
render( <OnboardingTour { ...props } showWelcomeTour={ false } /> );
screen
.getByRole( 'button', {
name: 'Next',
} )
.click();
screen
.getByRole( 'button', {
name: 'Done',
} )
.click();
expect( recordEvent ).toHaveBeenCalledWith(
'customize_your_store_assembler_hub_tour_complete'
);
} );
} );

View File

@ -180,13 +180,12 @@ function ResizableFrame( {
left: 0,
},
visible: {
opacity: 1,
opacity: 0.6,
left: -10,
},
active: {
opacity: 1,
left: -10,
scaleY: 1.3,
},
};
const currentResizeHandleVariant = ( () => {

View File

@ -21,7 +21,8 @@ export const ColorPalette = () => {
gap={ 4 }
className="woocommerce-customize-store_color-palette-container"
>
{ COLOR_PALETTES.map( ( variation, index ) => (
{ /* TODO: Show 9 colors based on the AI recommendation */ }
{ COLOR_PALETTES.slice( 0, 9 ).map( ( variation, index ) => (
<VariationContainer key={ index } variation={ variation }>
<ColorPaletteVariationPreview title={ variation?.title } />
</VariationContainer>

View File

@ -4,15 +4,19 @@
*/
import { privateApis as blockEditorPrivateApis } from '@wordpress/block-editor';
import { unlock } from '@wordpress/edit-site/build-module/lock-unlock';
import { useContext } from '@wordpress/element';
import { mergeBaseAndUserConfigs } from '@wordpress/edit-site/build-module/components/global-styles/global-styles-provider';
const {
useGlobalStyle,
useGlobalSetting,
useSettingsForBlockElement,
ColorPanel: StylesColorPanel,
GlobalStylesContext,
} = unlock( blockEditorPrivateApis );
export const ColorPanel = () => {
const { setUserConfig } = useContext( GlobalStylesContext );
const [ style ] = useGlobalStyle( '', undefined, 'user', {
shouldDecodeEncode: false,
} );
@ -22,11 +26,25 @@ export const ColorPanel = () => {
const [ rawSettings ] = useGlobalSetting( '' );
const settings = useSettingsForBlockElement( rawSettings );
const onChange = ( ...props ) => {
setStyle( ...props );
setUserConfig( ( currentConfig ) => ( {
...currentConfig,
settings: mergeBaseAndUserConfigs( currentConfig.settings, {
color: {
palette: {
hasCreatedOwnColors: true,
},
},
} ),
} ) );
};
return (
<StylesColorPanel
inheritedValue={ inheritedStyle }
value={ style }
onChange={ setStyle }
onChange={ onChange }
settings={ settings }
/>
);

View File

@ -28,6 +28,15 @@ export const VariationContainer = ( { variation, children } ) => {
}, [ variation, base ] );
const selectVariation = () => {
// Remove the hasCreatedOwnColors flag if the user is switching to a color palette
if (
variation.settings.color &&
user.settings.color &&
user.settings.color.hasCreatedOwnColors
) {
delete user.settings.color.palette.hasCreatedOwnColors;
}
setUserConfig( () => {
return {
settings: mergeBaseAndUserConfigs(
@ -48,7 +57,6 @@ export const VariationContainer = ( { variation, children } ) => {
selectVariation();
}
};
const isActive = useMemo( () => {
if ( variation.settings.color ) {
return isEqual( variation.settings.color, user.settings.color );

View File

@ -23,7 +23,7 @@ import { SidebarNavigationScreenTypography } from './sidebar-navigation-screen-t
import { SidebarNavigationScreenHeader } from './sidebar-navigation-screen-header';
import { SidebarNavigationScreenHomepage } from './sidebar-navigation-screen-homepage';
import { SidebarNavigationScreenFooter } from './sidebar-navigation-screen-footer';
import { SidebarNavigationScreenPages } from './sidebar-navigation-screen-pages';
// import { SidebarNavigationScreenPages } from './sidebar-navigation-screen-pages';
import { SidebarNavigationScreenLogo } from './sidebar-navigation-screen-logo';
import { SaveHub } from './save-hub';
@ -124,9 +124,10 @@ function SidebarScreens() {
<NavigatorScreen path="/customize-store/assembler-hub/footer">
<SidebarNavigationScreenFooter />
</NavigatorScreen>
<NavigatorScreen path="/customize-store/assembler-hub/pages">
{ /* TODO: Implement pages sidebar in Phrase 2 */ }
{ /* <NavigatorScreen path="/customize-store/assembler-hub/pages">
<SidebarNavigationScreenPages />
</NavigatorScreen>
</NavigatorScreen> */ }
<NavigatorScreen path="/customize-store/assembler-hub/logo">
<SidebarNavigationScreenLogo />
</NavigatorScreen>
@ -146,8 +147,8 @@ function Sidebar() {
initialPath={ initialPath.current }
>
<SidebarScreens />
<SaveHub />
</NavigatorProvider>
<SaveHub />
</>
);
}

View File

@ -10,6 +10,8 @@ import { useSelect, useDispatch } from '@wordpress/data';
import {
// @ts-ignore No types for this exist yet.
__experimentalHStack as HStack,
// @ts-ignore No types for this exist yet.
__experimentalUseNavigator as useNavigator,
Button,
Spinner,
} from '@wordpress/components';
@ -22,6 +24,7 @@ import { store as blockEditorStore } from '@wordpress/block-editor';
import { store as noticesStore } from '@wordpress/notices';
// @ts-ignore No types for this exist yet.
import { useEntitiesSavedStatesIsDirty as useIsDirty } from '@wordpress/editor';
import { recordEvent } from '@woocommerce/tracks';
/**
* Internal dependencies
@ -40,13 +43,13 @@ export const SaveHub = () => {
const urlParams = useQuery();
const { sendEvent } = useContext( CustomizeStoreContext );
const [ isResolving, setIsResolving ] = useState< boolean >( false );
const navigator = useNavigator();
// @ts-ignore No types for this exist yet.
const { __unstableMarkLastChangeAsPersistent } =
useDispatch( blockEditorStore );
const { createSuccessNotice, createErrorNotice, removeNotice } =
useDispatch( noticesStore );
const { createErrorNotice, removeNotice } = useDispatch( noticesStore );
const {
dirtyEntityRecords,
@ -142,6 +145,13 @@ export const SaveHub = () => {
}, [ urlParams.path ] );
const save = async () => {
const source = `${ urlParams.path.replace(
'/customize-store/assembler-hub/',
''
) }`;
recordEvent( 'customize_your_store_assembler_hub_save_click', {
source,
} );
removeNotice( saveNoticeId );
try {
@ -168,10 +178,7 @@ export const SaveHub = () => {
}
}
createSuccessNotice( __( 'Site updated.', 'woocommerce' ), {
type: 'snackbar',
id: saveNoticeId,
} );
navigator.goToParent();
} catch ( error ) {
createErrorNotice(
`${ __( 'Saving failed.', 'woocommerce' ) } ${ error }`
@ -185,6 +192,10 @@ export const SaveHub = () => {
<Button
variant="primary"
onClick={ () => {
recordEvent(
'customize_your_store_assembler_hub_done_click'
);
setIsResolving( true );
sendEvent( 'FINISH_CUSTOMIZATION' );
} }

View File

@ -15,6 +15,7 @@ import { unlock } from '@wordpress/edit-site/build-module/lock-unlock';
// @ts-ignore No types for this exist yet.
import { store as editSiteStore } from '@wordpress/edit-site/build-module/store';
import { PanelBody } from '@wordpress/components';
import { recordEvent } from '@woocommerce/tracks';
/**
* Internal dependencies
@ -70,14 +71,38 @@ export const SidebarNavigationScreenColorPalette = () => {
{
EditorLink: (
<Link
href={ `${ ADMIN_URL }site-editor.php` }
type="external"
onClick={ () => {
recordEvent(
'customize_your_store_assembler_hub_editor_link_click',
{
source: 'color-palette',
}
);
window.open(
`${ ADMIN_URL }site-editor.php`,
'_blank'
);
return false;
} }
href=""
/>
),
StyleLink: (
<Link
href={ `${ ADMIN_URL }site-editor.php?path=%2Fwp_global_styles&canvas=edit` }
type="external"
onClick={ () => {
recordEvent(
'customize_your_store_assembler_hub_style_link_click',
{
source: 'color-palette',
}
);
window.open(
`${ ADMIN_URL }site-editor.php?path=%2Fwp_global_styles&canvas=edit`,
'_blank'
);
return false;
} }
href=""
/>
),
}

View File

@ -12,6 +12,7 @@ import {
useMemo,
} from '@wordpress/element';
import { Link } from '@woocommerce/components';
import { recordEvent } from '@woocommerce/tracks';
import { Spinner } from '@wordpress/components';
// @ts-expect-error Missing type in core-data.
import { __experimentalBlockPatternsList as BlockPatternList } from '@wordpress/block-editor';
@ -34,8 +35,7 @@ const SUPPORTED_FOOTER_PATTERNS = [
export const SidebarNavigationScreenFooter = () => {
useEditorScroll( {
editorSelector:
'.interface-navigable-region.interface-interface-skeleton__content',
editorSelector: '.woocommerce-customize-store__block-editor iframe',
scrollDirection: 'bottom',
} );
@ -86,8 +86,20 @@ export const SidebarNavigationScreenFooter = () => {
{
EditorLink: (
<Link
href={ `${ ADMIN_URL }site-editor.php` }
type="external"
onClick={ () => {
recordEvent(
'customize_your_store_assembler_hub_editor_link_click',
{
source: 'footer',
}
);
window.open(
`${ ADMIN_URL }site-editor.php`,
'_blank'
);
return false;
} }
href=""
/>
),
}

View File

@ -12,6 +12,7 @@ import {
useMemo,
} from '@wordpress/element';
import { Link } from '@woocommerce/components';
import { recordEvent } from '@woocommerce/tracks';
import { Spinner } from '@wordpress/components';
// @ts-ignore No types for this exist yet.
import { __experimentalBlockPatternsList as BlockPatternList } from '@wordpress/block-editor';
@ -35,8 +36,7 @@ const SUPPORTED_HEADER_PATTERNS = [
export const SidebarNavigationScreenHeader = () => {
useEditorScroll( {
editorSelector:
'.interface-navigable-region.interface-interface-skeleton__content',
editorSelector: '.woocommerce-customize-store__block-editor iframe',
scrollDirection: 'top',
} );
@ -85,8 +85,20 @@ export const SidebarNavigationScreenHeader = () => {
{
EditorLink: (
<Link
href={ `${ ADMIN_URL }site-editor.php` }
type="external"
onClick={ () => {
recordEvent(
'customize_your_store_assembler_hub_editor_link_click',
{
source: 'header',
}
);
window.open(
`${ ADMIN_URL }site-editor.php`,
'_blank'
);
return false;
} }
href=""
/>
),
}

View File

@ -1,16 +1,61 @@
/* eslint-disable @woocommerce/dependency-group */
/* eslint-disable @typescript-eslint/ban-ts-comment */
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { createInterpolateElement } from '@wordpress/element';
import {
createInterpolateElement,
useCallback,
useMemo,
} from '@wordpress/element';
import { Link } from '@woocommerce/components';
import { Spinner } from '@wordpress/components';
// @ts-expect-error Missing type in core-data.
import { __experimentalBlockPatternsList as BlockPatternList } from '@wordpress/block-editor';
import { recordEvent } from '@woocommerce/tracks';
/**
* Internal dependencies
*/
import { SidebarNavigationScreen } from './sidebar-navigation-screen';
import { ADMIN_URL } from '~/utils/admin-settings';
import { useEditorBlocks } from '../hooks/use-editor-blocks';
import { useHomeTemplates } from '../hooks/use-home-templates';
import { BlockInstance } from '@wordpress/blocks';
export const SidebarNavigationScreenHomepage = () => {
const { isLoading, homeTemplates } = useHomeTemplates();
const [ blocks, , onChange ] = useEditorBlocks();
const onClickPattern = useCallback(
( _pattern, selectedBlocks ) => {
onChange(
[ blocks[ 0 ], ...selectedBlocks, blocks[ blocks.length - 1 ] ],
{ selection: {} }
);
},
[ blocks, onChange ]
);
const homePatterns = useMemo( () => {
return Object.entries( homeTemplates ).map(
( [ templateName, patterns ] ) => {
return {
name: templateName,
blocks: patterns.reduce(
( acc: BlockInstance[], pattern ) => [
...acc,
...pattern.blocks,
],
[]
),
};
}
);
}, [ homeTemplates ] );
return (
<SidebarNavigationScreen
title={ __( 'Change your homepage', 'woocommerce' ) }
@ -22,16 +67,45 @@ export const SidebarNavigationScreenHomepage = () => {
{
EditorLink: (
<Link
href={ `${ ADMIN_URL }site-editor.php` }
type="external"
onClick={ () => {
recordEvent(
'customize_your_store_assembler_hub_editor_link_click',
{
source: 'homepage',
}
);
window.open(
`${ ADMIN_URL }site-editor.php`,
'_blank'
);
return false;
} }
href=""
/>
),
}
) }
content={
<>
<div className="edit-site-sidebar-navigation-screen-patterns__group-header"></div>
</>
<div className="woocommerce-customize-store__sidebar-homepage-content">
<div className="edit-site-sidebar-navigation-screen-patterns__group-homepage">
{ isLoading ? (
<span className="components-placeholder__preview">
<Spinner />
</span>
) : (
<BlockPatternList
shownPatterns={ homePatterns }
blockPatterns={ homePatterns }
onClickPattern={ onClickPattern }
label={ 'Hompeage' }
orientation="vertical"
category={ 'homepage' }
isDraggable={ false }
showTitlesAsTooltip={ false }
/>
) }
</div>
</div>
}
/>
);

View File

@ -20,11 +20,11 @@ import {
header,
home,
footer,
pages,
} from '@wordpress/icons';
// @ts-ignore No types for this exist yet.
import SidebarNavigationItem from '@wordpress/edit-site/build-module/components/sidebar-navigation-item';
import { Link } from '@woocommerce/components';
import { recordEvent } from '@woocommerce/tracks';
/**
* Internal dependencies
@ -45,8 +45,20 @@ export const SidebarNavigationScreenMain = () => {
{
EditorLink: (
<Link
href={ `${ ADMIN_URL }site-editor.php` }
type="external"
onClick={ () => {
recordEvent(
'customize_your_store_assembler_hub_editor_link_click',
{
source: 'main',
}
);
window.open(
`${ ADMIN_URL }site-editor.php`,
'_blank'
);
return false;
} }
href=""
/>
),
}
@ -64,6 +76,14 @@ export const SidebarNavigationScreenMain = () => {
path="/customize-store/assembler-hub/logo"
withChevron
icon={ siteLogo }
onClick={ () => {
recordEvent(
'customize_your_store_assembler_hub_sidebar_item_click',
{
item: 'logo',
}
);
} }
>
{ __( 'Add your logo', 'woocommerce' ) }
</NavigatorButton>
@ -72,6 +92,14 @@ export const SidebarNavigationScreenMain = () => {
path="/customize-store/assembler-hub/color-palette"
withChevron
icon={ color }
onClick={ () => {
recordEvent(
'customize_your_store_assembler_hub_sidebar_item_click',
{
item: 'color-palette',
}
);
} }
>
{ __( 'Change the color palette', 'woocommerce' ) }
</NavigatorButton>
@ -80,6 +108,14 @@ export const SidebarNavigationScreenMain = () => {
path="/customize-store/assembler-hub/typography"
withChevron
icon={ typography }
onClick={ () => {
recordEvent(
'customize_your_store_assembler_hub_sidebar_item_click',
{
item: 'typography',
}
);
} }
>
{ __( 'Change fonts', 'woocommerce' ) }
</NavigatorButton>
@ -95,6 +131,14 @@ export const SidebarNavigationScreenMain = () => {
path="/customize-store/assembler-hub/header"
withChevron
icon={ header }
onClick={ () => {
recordEvent(
'customize_your_store_assembler_hub_sidebar_item_click',
{
item: 'header',
}
);
} }
>
{ __( 'Change your header', 'woocommerce' ) }
</NavigatorButton>
@ -103,6 +147,14 @@ export const SidebarNavigationScreenMain = () => {
path="/customize-store/assembler-hub/homepage"
withChevron
icon={ home }
onClick={ () => {
recordEvent(
'customize_your_store_assembler_hub_sidebar_item_click',
{
item: 'home',
}
);
} }
>
{ __( 'Change your homepage', 'woocommerce' ) }
</NavigatorButton>
@ -111,17 +163,26 @@ export const SidebarNavigationScreenMain = () => {
path="/customize-store/assembler-hub/footer"
withChevron
icon={ footer }
onClick={ () => {
recordEvent(
'customize_your_store_assembler_hub_sidebar_item_click',
{
item: 'footer',
}
);
} }
>
{ __( 'Change your footer', 'woocommerce' ) }
</NavigatorButton>
<NavigatorButton
{ /* TODO: Turn on this in Phrase 2 */ }
{ /* <NavigatorButton
as={ SidebarNavigationItem }
path="/customize-store/assembler-hub/pages"
withChevron
icon={ pages }
>
{ __( 'Add and edit other pages', 'woocommerce' ) }
</NavigatorButton>
</NavigatorButton> */ }
</ItemGroup>
</>
}

View File

@ -4,6 +4,7 @@
import { __ } from '@wordpress/i18n';
import { Link } from '@woocommerce/components';
import { createInterpolateElement } from '@wordpress/element';
import { recordEvent } from '@woocommerce/tracks';
/**
* Internal dependencies
@ -23,8 +24,20 @@ export const SidebarNavigationScreenPages = () => {
{
EditorLink: (
<Link
href={ `${ ADMIN_URL }site-editor.php` }
type="external"
onClick={ () => {
recordEvent(
'customize_your_store_assembler_hub_editor_link_click',
{
source: 'pages',
}
);
window.open(
`${ ADMIN_URL }site-editor.php`,
'_blank'
);
return false;
} }
href=""
/>
),
PageLink: (

View File

@ -14,6 +14,7 @@ import { noop } from 'lodash';
import { store as editSiteStore } from '@wordpress/edit-site/build-module/store';
// @ts-ignore No types for this exist yet.
import { unlock } from '@wordpress/edit-site/build-module/lock-unlock';
import { recordEvent } from '@woocommerce/tracks';
/**
* Internal dependencies
@ -42,14 +43,38 @@ export const SidebarNavigationScreenTypography = () => {
{
EditorLink: (
<Link
href={ `${ ADMIN_URL }site-editor.php` }
type="external"
onClick={ () => {
recordEvent(
'customize_your_store_assembler_hub_editor_link_click',
{
source: 'typography',
}
);
window.open(
`${ ADMIN_URL }site-editor.php`,
'_blank'
);
return false;
} }
href=""
/>
),
StyleLink: (
<Link
href={ `${ ADMIN_URL }site-editor.php?path=%2Fwp_global_styles&canvas=edit` }
type="external"
onClick={ () => {
recordEvent(
'customize_your_store_assembler_hub_style_link_click',
{
source: 'typography',
}
);
window.open(
`${ ADMIN_URL }site-editor.php?path=%2Fwp_global_styles&canvas=edit`,
'_blank'
);
return false;
} }
href=""
/>
),
}

View File

@ -73,6 +73,7 @@
padding-top: 80px;
padding-bottom: 0;
gap: 0;
width: 348px;
}
.edit-site-sidebar-navigation-screen__title-icon,
@ -118,10 +119,14 @@
.edit-site-layout__sidebar {
.edit-site-sidebar__content {
display: flex;
flex-direction: column;
.components-navigator-screen {
will-change: auto;
padding: 0 16px;
overflow-x: hidden;
flex: 1;
}
}
@ -243,9 +248,13 @@
.woocommerce-customize-store__sidebar-logo-container {
margin: 12px 0 32px;
width: 324px;
padding: 32px;
cursor: pointer;
width: 324px;
.woocommerce-customize-store_custom-logo {
width: 100%;
}
}
.woocommerce-customize-store__sidebar-logo-content {
@ -411,12 +420,23 @@
}
}
.edit-site-sidebar-navigation-screen__content .block-editor-block-patterns-list {
width: 324px;
}
.woocommerce-customize-store__sidebar-homepage-content {
.block-editor-block-preview__content {
background-color: #fff;
max-height: 280px !important;
}
}
/* Preview Canvas */
.edit-site-layout__canvas {
bottom: 16px;
top: 16px;
padding: 0 16px;
left: 12px; // the default styles for this undersizes the width by 24px so we want to center this
padding: 0 4px 0 16px;
}
.edit-site-resizable-frame__handle {
@ -427,13 +447,13 @@
padding: 0 10px;
align-items: flex-start;
gap: 10px;
background: #d7defb;
background: var(--wp-admin-theme-color-background-25);
left: 5px !important;
border-radius: 4px;
height: 20px;
.components-popover__content {
color: #1d35b4;
color: var(--wp-admin-theme-color-darker-20);
font-size: 0.75rem;
font-style: normal;
font-weight: 500;
@ -452,10 +472,18 @@
.woocommerce-customize-store__block-editor,
.edit-site-layout:not(.is-full-canvas) .edit-site-layout__canvas > div .interface-interface-skeleton__content {
border-radius: 20px;
}
.interface-interface-skeleton__content {
@include custom-scrollbars-on-hover(transparent, $gray-600);
.woocommerce-customize-store__block-editor,
.woocommerce-block-preview-container,
.auto-block-preview__container {
height: 100%;
overflow: hidden;
}
iframe {
width: 100%;
height: 100%;
}
}
.edit-site-resizable-frame__inner-content {

View File

@ -0,0 +1,26 @@
<svg width="399" height="351" viewBox="0 0 399 351" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M690.229 71.6773C732.419 29.4861 704.871 -66.489 628.68 -142.668C552.49 -218.86 456.531 -246.423 414.327 -204.232C387.767 -177.672 388.854 -129.818 412.279 -78.9995C293.927 -161.631 168.994 -182.204 107.389 -120.612C45.7841 -59.0053 66.3567 65.9162 149 184.286C98.1829 160.846 50.3307 159.773 23.7712 186.333C-18.4189 228.525 9.12891 324.5 85.3197 400.678C161.51 476.871 257.469 504.419 299.659 462.228C326.219 435.668 325.131 387.815 301.707 336.996C420.073 419.628 545.006 440.201 606.597 378.608C668.202 317.002 647.629 192.08 564.986 73.7106C615.803 97.1502 663.655 98.2233 690.215 71.6632L690.229 71.6773Z" fill="#E6DCFF" fill-opacity="0.5"/>
<path d="M376.07 217H319.352V229.555H376.07V217Z" fill="#271B3D"/>
<path d="M376.07 229.555H319.352V250.479H376.07V229.555Z" fill="#BEA0F2"/>
<path d="M367.337 221.185L371.521 225.37" stroke="white" stroke-width="0.71" stroke-miterlimit="10"/>
<path d="M371.521 221.185L367.337 225.37" stroke="white" stroke-width="0.71" stroke-miterlimit="10"/>
<path d="M334.217 324.551H376.066V303.627H334.217V324.551Z" fill="#BEA0F2"/>
<path d="M240.498 324.551H282.346V303.627H240.498V324.551Z" fill="#BEA0F2"/>
<path d="M287.36 324.551H329.208V303.627H287.36V324.551Z" fill="#BEA0F2"/>
<path d="M308.272 299.132H350.12V278.208H308.272V299.132Z" fill="#BEA0F2"/>
<path d="M355.142 299.132H376.066V278.208H355.142V299.132Z" fill="#BEA0F2"/>
<path d="M261.694 293.642C274.881 298.601 282.371 290.256 282.371 290.256L248.478 277.509L281.459 262.557C281.459 262.557 273.437 254.722 260.602 260.544C255.735 262.749 246.829 268.457 238.79 273.868L223.499 268.118C224.173 263.636 221.657 259.129 217.251 257.472C212.187 255.568 206.517 258.137 204.613 263.205C202.708 268.269 205.278 273.939 210.346 275.843C214.849 277.538 219.82 275.689 222.21 271.7C223.821 272.696 227.776 275.12 232.672 278.036C227.985 281.263 224.202 283.941 222.653 285.046C220.009 281.225 214.916 279.71 210.538 281.698C205.609 283.933 203.42 289.758 205.655 294.688C207.889 299.618 213.719 301.806 218.644 299.572C222.934 297.626 225.148 292.96 224.177 288.536L239.054 281.79C247.436 286.657 256.697 291.767 261.698 293.646L261.694 293.642ZM212.074 271.236C209.551 270.286 208.266 267.457 209.216 264.934C210.166 262.406 212.995 261.125 215.518 262.075C218.042 263.025 219.327 265.854 218.377 268.378C217.427 270.901 214.598 272.186 212.074 271.236ZM216.611 295.085C214.154 296.199 211.246 295.106 210.132 292.65C209.019 290.193 210.111 287.285 212.568 286.172C215.024 285.059 217.933 286.151 219.046 288.607C220.159 291.064 219.067 293.972 216.611 295.085Z" fill="#271B3D"/>
<path d="M314.552 265.804H323.88" stroke="#271B3D" stroke-miterlimit="10"/>
<path d="M327.869 265.804H337.192" stroke="#271B3D" stroke-miterlimit="10"/>
<path d="M341.181 265.804H350.509" stroke="#271B3D" stroke-miterlimit="10"/>
<path d="M354.497 265.804H363.821" stroke="#271B3D" stroke-miterlimit="10"/>
<path d="M366.817 265.804H376.145" stroke="#271B3D" stroke-miterlimit="10"/>
<path d="M301.24 265.804H310.564" stroke="#271B3D" stroke-miterlimit="10"/>
<path d="M287.924 265.804H297.248" stroke="#271B3D" stroke-miterlimit="10"/>
<path d="M236.378 227.437C236.378 226.429 235.892 225.501 235.072 224.736L228.891 227.462L235.038 230.171C235.875 229.402 236.378 228.457 236.378 227.437Z" fill="#271B3D"/>
<path d="M235.072 224.736C235.888 225.501 236.377 226.429 236.377 227.437C236.377 228.458 235.879 229.398 235.038 230.171L251.702 237.92H257.239V217H251.702L235.072 224.736Z" fill="#BEA0F2"/>
<path d="M251.798 217C250.053 217.949 248.706 218.693 248.706 220.847C248.706 222.288 249.442 223.502 250.748 224.148C249.451 224.804 248.714 226.03 248.706 227.462C248.714 228.89 249.446 230.117 250.748 230.776C249.442 231.423 248.706 232.637 248.706 234.077C248.706 236.232 250.053 236.975 251.798 237.924H307.704L311.328 232.107V222.817L307.704 217H251.798Z" fill="#271B3D"/>
<path d="M307.707 237.924L311.331 232.11V222.818L307.707 217H297.659L294.035 222.818V232.11L297.659 237.924H307.707Z" fill="#BEA0F2"/>
<path d="M302.681 231.245C304.08 231.245 305.213 229.535 305.213 227.425C305.213 225.315 304.08 223.604 302.681 223.604C301.283 223.604 300.15 225.315 300.15 227.425C300.15 229.535 301.283 231.245 302.681 231.245Z" fill="#271B3D"/>
<path d="M128 227.739H225.415" stroke="#271B3D" stroke-miterlimit="10"/>
</svg>

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

@ -9,8 +9,13 @@ import { recordEvent } from '@woocommerce/tracks';
* Internal dependencies
*/
import {
ColorPaletteResponse,
designWithAiStateMachineContext,
designWithAiStateMachineEvents,
FontPairing,
LookAndToneCompletionResponse,
Header,
Footer,
} from './types';
import { aiWizardClosedBeforeCompletionEvent } from './events';
import {
@ -18,7 +23,6 @@ import {
lookAndFeelCompleteEvent,
toneOfVoiceCompleteEvent,
} from './pages';
import { LookAndToneCompletionResponse } from './services';
const assignBusinessInfoDescription = assign<
designWithAiStateMachineContext,
@ -72,14 +76,82 @@ const assignLookAndTone = assign<
},
} );
const assignDefaultColorPalette = assign<
designWithAiStateMachineContext,
designWithAiStateMachineEvents
>( {
aiSuggestions: ( context, event: unknown ) => {
return {
...context.aiSuggestions,
defaultColorPalette: (
event as {
data: {
response: ColorPaletteResponse;
};
}
).data.response,
};
},
} );
const assignFontPairing = assign<
designWithAiStateMachineContext,
designWithAiStateMachineEvents
>( {
aiSuggestions: ( context, event: unknown ) => {
return {
...context.aiSuggestions,
fontPairing: (
event as {
data: {
response: FontPairing;
};
}
).data.response.pair_name,
};
},
} );
const assignHeader = assign<
designWithAiStateMachineContext,
designWithAiStateMachineEvents
>( {
aiSuggestions: ( context, event: unknown ) => {
return {
...context.aiSuggestions,
header: (
event as {
data: {
response: Header;
};
}
).data.response.slug,
};
},
} );
const assignFooter = assign<
designWithAiStateMachineContext,
designWithAiStateMachineEvents
>( {
aiSuggestions: ( context, event: unknown ) => {
return {
...context.aiSuggestions,
footer: (
event as {
data: {
response: Footer;
};
}
).data.response.slug,
};
},
} );
const logAIAPIRequestError = () => {
// log AI API request error
// eslint-disable-next-line no-console
console.log( 'API Request error' );
recordEvent(
'customize_your_store_look_and_tone_ai_completion_response_error',
{ error_type: 'http_network_error' }
);
};
const updateQueryStep = (
@ -142,6 +214,10 @@ export const actions = {
assignLookAndFeel,
assignToneOfVoice,
assignLookAndTone,
assignDefaultColorPalette,
assignFontPairing,
assignHeader,
assignFooter,
logAIAPIRequestError,
updateQueryStep,
recordTracksStepViewed,

View File

@ -35,6 +35,7 @@
font-weight: 500;
line-height: 20px; /* 125% */
letter-spacing: -0.24px;
margin-bottom: 8px;
}
p {

View File

@ -19,6 +19,8 @@ import {
} from './pages';
import { customizeStoreStateMachineEvents } from '..';
import './style.scss';
export type events = { type: 'THEME_SUGGESTED' };
export type DesignWithAiComponent =
| typeof BusinessInfoDescription

View File

@ -33,7 +33,7 @@ export const LookAndFeel = ( {
title: __( 'Contemporary', 'woocommerce' ),
key: 'Contemporary' as const,
subtitle: __(
'Clean lines, neutral colors, sleek and modern look',
'Clean lines, neutral colors, sleek and modern look.',
'woocommerce'
),
},

View File

@ -77,7 +77,10 @@ export const ToneOfVoice = ( {
<div className="woocommerce-cys-design-with-ai-tone-of-voice woocommerce-cys-layout">
<div className="woocommerce-cys-page">
<h1>
{ __( 'How would you like to sound?', 'woocommerce' ) }
{ __(
'Which writing style do you prefer?',
'woocommerce'
) }
</h1>
<div className="choices">
{ choices.map( ( { title, subtitle, key } ) => {

View File

@ -0,0 +1,248 @@
/**
* External dependencies
*/
import { z } from 'zod';
/**
* Internal dependencies
*/
import { ColorPalette } from '../types';
const colorChoices: ColorPalette[] = [
{
name: 'Ancient Bronze',
primary: '#11163d',
secondary: '#8C8369',
foreground: '#11163d',
background: '#ffffff',
},
{
name: 'Crimson Tide',
primary: '#A02040',
secondary: '#234B57',
foreground: '#871C37',
background: '#ffffff',
},
{
name: 'Purple Twilight',
primary: '#301834',
secondary: '#6a5eb7',
foreground: '#090909',
background: '#fefbff',
},
{
name: 'Midnight Citrus',
primary: '#1B1736',
secondary: '#7E76A3',
foreground: '#1B1736',
background: '#ffffff',
},
{
name: 'Lemon Myrtle',
primary: '#3E7172',
secondary: '#FC9B00',
foreground: '#325C5D',
background: '#ffffff',
},
{
name: 'Green Thumb',
primary: '#164A41',
secondary: '#4B7B4D',
foreground: '#164A41',
background: '#ffffff',
},
{
name: 'Golden Haze',
primary: '#232224',
secondary: '#EBB54F',
foreground: '#515151',
background: '#ffffff',
},
{
name: 'Golden Indigo',
primary: '#4866C0',
secondary: '#C09F50',
foreground: '#405AA7',
background: '#ffffff',
},
{
name: 'Arctic Dawn',
primary: '#243156',
secondary: '#DE5853',
foreground: '#243156',
background: '#ffffff',
},
{
name: 'Jungle Sunrise',
primary: '#1a4435',
secondary: '#ed774e',
foreground: '#0a271d',
background: '#fefbec',
},
{
name: 'Berry Grove',
primary: '#1F351A',
secondary: '#DE76DE',
foreground: '#1f351a',
background: '#fdfaf1',
},
{
name: 'Fuchsia',
primary: '#b7127f',
secondary: '#18020C',
foreground: '#b7127f',
background: '#f7edf6',
},
{
name: 'Raspberry Chocolate',
primary: '#42332e',
secondary: '#d64d68',
foreground: '#241d1a',
background: '#eeeae6',
},
{
name: 'Canary',
primary: '#0F0F05',
secondary: '#353535',
foreground: '#0F0F05',
background: '#FCFF9B',
},
{
name: 'Gumtree Sunset',
primary: '#476C77',
secondary: '#EFB071',
foreground: '#476C77',
background: '#edf4f4',
},
{
name: 'Ice',
primary: '#12123F',
secondary: '#3473FE',
foreground: '#12123F',
background: '#F1F4FA',
},
{
name: 'Cinder',
primary: '#c14420',
secondary: '#2F2D2D',
foreground: '#863119',
background: '#f1f2f2',
},
{
name: 'Blue Lagoon',
primary: '#004DE5',
secondary: '#0496FF',
foreground: '#0036A3',
background: '#FEFDF8',
},
{
name: 'Sandalwood Oasis',
primary: '#F0EBE3',
secondary: '#DF9785',
foreground: '#ffffff',
background: '#2a2a16',
},
{
name: 'Rustic Rosewood',
primary: '#F4F4F2',
secondary: '#EE797C',
foreground: '#ffffff',
background: '#1A1A1A',
},
{
name: 'Cinnamon Latte',
primary: '#D9CAB3',
secondary: '#BC8034',
foreground: '#FFFFFF',
background: '#3C3F4D',
},
{
name: 'Lilac Nightshade',
primary: '#f5d6ff',
secondary: '#C48DDA',
foreground: '#ffffff',
background: '#000000',
},
{
name: 'Lightning',
primary: '#ebffd2',
secondary: '#fefefe',
foreground: '#ebffd2',
background: '#0e1fb5',
},
{
name: 'Aquamarine Night',
primary: '#deffef',
secondary: '#56fbb9',
foreground: '#ffffff',
background: '#091C48',
},
{
name: 'Charcoal',
primary: '#dbdbdb',
secondary: '#efefef',
foreground: '#dbdbdb',
background: '#1e1e1e',
},
{
name: 'Evergreen Twilight',
primary: '#ffffff',
secondary: '#8EE978',
foreground: '#ffffff',
background: '#181818',
},
{
name: 'Slate',
primary: '#FFFFFF',
secondary: '#FFDF6D',
foreground: '#EFF2F9',
background: '#13161E',
},
];
const allowedNames: string[] = colorChoices.map( ( palette ) => palette.name );
const hexColorRegex = /^#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})$/;
const colorPaletteNameValidator = z
.string()
.refine( ( name ) => allowedNames.includes( name ), {
message: 'Color palette not part of allowed list',
} );
export const colorPaletteValidator = z.object( {
name: colorPaletteNameValidator,
primary: z
.string()
.regex( hexColorRegex, { message: 'Invalid primary color' } ),
secondary: z
.string()
.regex( hexColorRegex, { message: 'Invalid secondary color' } ),
foreground: z
.string()
.regex( hexColorRegex, { message: 'Invalid foreground color' } ),
background: z
.string()
.regex( hexColorRegex, { message: 'Invalid background color' } ),
} );
export const colorPaletteResponseValidator = z.object( {
default: colorPaletteNameValidator,
bestColors: z.array( colorPaletteNameValidator ).length( 8 ),
} );
export const defaultColorPalette = {
queryId: 'default_color_palette',
// make sure version is updated every time the prompt is changed
version: '2023-09-18',
prompt: ( businessDescription: string, look: string, tone: string ) => {
return `
You are a WordPress theme expert. Analyse the following store description, merchant's chosen look and tone, and determine the most appropriate color scheme, along with 8 best alternatives.
Respond in the form: "{ default: "palette name", bestColors: [ "palette name 1", "palette name 2", "palette name 3", "palette name 4", "palette name 5", "palette name 6", "palette name 7", "palette name 8" ] }"
Chosen look and tone: ${ look } look, ${ tone } tone.
Business description: ${ businessDescription }
Colors to choose from:
${ JSON.stringify( colorChoices ) }
`;
},
responseValidation: colorPaletteResponseValidator.parse,
};

View File

@ -0,0 +1,120 @@
/**
* External dependencies
*/
import { z } from 'zod';
/** This block below was generated by ChatGPT using GPT-4 on 2023-09-18 */
/** Original source: plugins/woocommerce-admin/client/customize-store/assembler-hub/sidebar/global-styles/font-pairing-variations/constants.ts */
const fontChoices = [
{
pair_name: 'Bodoni Moda + Overpass',
fonts: {
'Bodoni Moda':
'A modern serif font with high contrast between thick and thin lines, commonly used for headings.',
Overpass:
'A clean, modern sans-serif, originally inspired by Highway Gothic. Good for text and UI elements.',
},
settings:
'Overpass is used for buttons and general typography, while Bodoni Moda is specified for headings and some core blocks like site title and post navigation link.',
},
{
pair_name: 'Commissioner + Crimson Pro',
fonts: {
Commissioner:
'A low-contrast, geometric sans-serif, designed for legibility and readability in long texts.',
'Crimson Pro':
'A serif typeface designed for readability and long-form text.',
},
settings:
'Commissioner dominates elements like buttons, headings, and core blocks, while Crimson Pro is set for general typography.',
},
{
pair_name: 'Libre Baskerville + DM Sans',
fonts: {
'Libre Baskerville':
'A serif typeface with a classic feel, good for long reading and often used for body text in books.',
'DM Sans':
'A clean, geometric sans-serif, often used for UI and short text.',
},
settings:
'Libre Baskerville is used for headings and core blocks, whereas DM Sans is used for buttons and general typography.',
},
{
pair_name: 'Libre Franklin + EB Garamond',
fonts: {
'Libre Franklin':
'A sans-serif that offers readability, suitable for both text and display.',
'EB Garamond':
"A revival of the classical 'Garamond' typefaces, suitable for long-form text.",
},
settings:
'Libre Franklin is predominantly used for elements like buttons, headings, and core blocks. EB Garamond is set for general typography.',
},
{
pair_name: 'Montserrat + Arvo',
fonts: {
Montserrat:
'A geometric sans-serif, popular for its modern clean lines.',
Arvo: 'A slab-serif font with a more traditional feel, suitable for print and screen.',
},
settings:
'Montserrat is used for buttons, headings, and core blocks. Arvo is used for general typography.',
},
{
pair_name: 'Playfair Display + Fira Sans',
fonts: {
'Playfair Display':
'A high-contrast serif designed for headings and offers a modern take on older serif fonts.',
'Fira Sans':
'A sans-serif designed for readability at small sizes, making it suitable for both UI and text.',
},
settings:
'Playfair Display is used in italics for headings and core blocks, while Fira Sans is used for buttons and general typography.',
},
{
pair_name: 'Rubik + Inter',
fonts: {
Rubik: 'A sans-serif with slightly rounded corners, designed for a softer, more modern look.',
Inter: 'A highly legible sans-serif, optimized for UI design.',
},
settings:
'Rubik is applied for headings and core blocks. Inter is used for buttons and general typography.',
},
{
pair_name: 'Space Mono + Roboto',
fonts: {
'Space Mono': 'A monospace typeface with a futuristic vibe.',
Roboto: 'A neo-grotesque sans-serif, known for its flexibility and modern design.',
},
settings:
'Space Mono is used for headings, while Roboto takes care of buttons and general typography.',
},
];
const allowedFontChoices = fontChoices.map( ( config ) => config.pair_name );
export const fontChoiceValidator = z.object( {
pair_name: z
.string()
.refine( ( name ) => allowedFontChoices.includes( name ), {
message: 'Font choice not part of allowed list',
} ),
} );
export const fontPairings = {
queryId: 'font_pairings',
version: '2023-09-18',
prompt: ( businessDescription: string, look: string, tone: string ) => {
return `
You are a WordPress theme expert. Analyse the following store description, merchant's chosen look and tone, and determine the most appropriate font pairing.
Respond only with one font pairing and and in the format: '{"pair_name":"font 1 + font 2"}'.
Chosen look and tone: ${ look } look, ${ tone } tone.
Business description: ${ businessDescription }
Font pairings to choose from:
${ JSON.stringify( fontChoices ) }
`;
},
responseValidation: fontChoiceValidator.parse,
};

View File

@ -0,0 +1,47 @@
/**
* External dependencies
*/
import { z } from 'zod';
const footerChoices = [
{
slug: 'woocommerce-blocks/footer-simple-menu-and-cart',
label: 'Footer with Simple Menu and Cart',
},
{
slug: 'woocommerce-blocks/footer-with-3-menus',
label: 'Footer with 3 Menus',
},
{
slug: 'woocommerce-blocks/footer-large',
label: 'Large Footer',
},
];
const allowedFooter: string[] = footerChoices.map( ( footer ) => footer.slug );
export const footerValidator = z.object( {
slug: z.string().refine( ( slug ) => allowedFooter.includes( slug ), {
message: 'Footer not part of allowed list',
} ),
} );
export const defaultFooter = {
queryId: 'default_footer',
// make sure version is updated every time the prompt is changed
version: '2023-09-19',
prompt: ( businessDescription: string, look: string, tone: string ) => {
return `
You are a WordPress theme expert. Analyse the following store description, merchant's chosen look and tone, and determine the most appropriate footer.
Respond only with one footer and only its JSON.
Chosen look and tone: ${ look } look, ${ tone } tone.
Business description: ${ businessDescription }
Footer to choose from:
${ JSON.stringify( footerChoices ) }
`;
},
responseValidation: footerValidator.parse,
};

View File

@ -0,0 +1,51 @@
/**
* External dependencies
*/
import { z } from 'zod';
const headerChoices = [
{
slug: 'woocommerce-blocks/header-essential',
label: 'Essential Header',
},
{
slug: 'woocommerce-blocks/header-centered-menu-with-search',
label: 'Centered Menu with search Header',
},
{
slug: 'woocommerce-blocks/header-minimal',
label: 'Minimal Header',
},
{
slug: 'woocommerce-blocks/header-large',
label: 'Large Header',
},
];
const allowedHeaders: string[] = headerChoices.map( ( header ) => header.slug );
export const headerValidator = z.object( {
slug: z.string().refine( ( slug ) => allowedHeaders.includes( slug ), {
message: 'Header not part of allowed list',
} ),
} );
export const defaultHeader = {
queryId: 'default_header',
// make sure version is updated every time the prompt is changed
version: '2023-09-19',
prompt: ( businessDescription: string, look: string, tone: string ) => {
return `
You are a WordPress theme expert. Analyse the following store description, merchant's chosen look and tone, and determine the most appropriate header.
Respond only with one header and only its JSON.
Chosen look and tone: ${ look } look, ${ tone } tone.
Business description: ${ businessDescription }
Headers to choose from:
${ JSON.stringify( headerChoices ) }
`;
},
responseValidation: headerValidator.parse,
};

View File

@ -0,0 +1,5 @@
export * from './colorChoices';
export * from './lookAndTone';
export * from './fontPairings';
export * from './header';
export * from './footer';

View File

@ -0,0 +1,51 @@
/**
* Internal dependencies
*/
import {
Look,
Tone,
VALID_LOOKS,
VALID_TONES,
LookAndToneCompletionResponse,
} from '../types';
export const isLookAndToneCompletionResponse = (
obj: unknown
): obj is LookAndToneCompletionResponse => {
return (
obj !== undefined &&
obj !== null &&
typeof obj === 'object' &&
'look' in obj &&
VALID_LOOKS.includes( obj.look as Look ) &&
'tone' in obj &&
VALID_TONES.includes( obj.tone as Tone )
);
};
export const lookAndTone = {
queryId: 'look_and_tone',
// make sure version is updated every time the prompt is changed
version: '2023-09-18',
prompt: ( businessInfoDescription: string ) => {
return [
'You are a WordPress theme expert.',
'Analyze the following store description and determine the look and tone of the theme.',
`For look, you can choose between ${ VALID_LOOKS.join( ',' ) }.`,
`For tone of the description, you can choose between ${ VALID_TONES.join(
','
) }.`,
'Your response should be in json with look and tone values.',
'\n',
businessInfoDescription,
].join( '\n' );
},
responseValidation: ( response: unknown ) => {
if ( isLookAndToneCompletionResponse( response ) ) {
return response;
}
throw new Error(
'Invalid values in Look and Tone completion response'
);
},
};

View File

@ -0,0 +1,238 @@
/**
* Internal dependencies
*/
import { colorPaletteValidator, defaultColorPalette } from '..';
describe( 'colorPaletteValidator', () => {
it( 'should validate a correct color palette', () => {
const validPalette = {
name: 'Ancient Bronze',
primary: '#11163d',
secondary: '#8C8369',
foreground: '#11163d',
background: '#ffffff',
};
const parsedResult = colorPaletteValidator.parse( validPalette );
expect( parsedResult ).toEqual( validPalette );
} );
it( 'should fail for an incorrect name', () => {
const invalidPalette = {
name: 'Invalid Name',
primary: '#11163d',
secondary: '#8C8369',
foreground: '#11163d',
background: '#ffffff',
};
expect( () => colorPaletteValidator.parse( invalidPalette ) )
.toThrowErrorMatchingInlineSnapshot( `
"[
{
\\"code\\": \\"custom\\",
\\"message\\": \\"Color palette not part of allowed list\\",
\\"path\\": [
\\"name\\"
]
}
]"
` );
} );
it( 'should fail for an invalid primary color', () => {
const invalidPalette = {
name: 'Ancient Bronze',
primary: 'invalidColor',
secondary: '#8C8369',
foreground: '#11163d',
background: '#ffffff',
};
expect( () => colorPaletteValidator.parse( invalidPalette ) )
.toThrowErrorMatchingInlineSnapshot( `
"[
{
\\"validation\\": \\"regex\\",
\\"code\\": \\"invalid_string\\",
\\"message\\": \\"Invalid primary color\\",
\\"path\\": [
\\"primary\\"
]
}
]"
` );
} );
it( 'should fail for an invalid secondary color', () => {
const invalidPalette = {
name: 'Ancient Bronze',
primary: '#11163d',
secondary: 'invalidColor',
foreground: '#11163d',
background: '#ffffff',
};
expect( () => colorPaletteValidator.parse( invalidPalette ) )
.toThrowErrorMatchingInlineSnapshot( `
"[
{
\\"validation\\": \\"regex\\",
\\"code\\": \\"invalid_string\\",
\\"message\\": \\"Invalid secondary color\\",
\\"path\\": [
\\"secondary\\"
]
}
]"
` );
} );
it( 'should fail for an invalid foreground color', () => {
const invalidPalette = {
name: 'Ancient Bronze',
primary: '#11163d',
secondary: '11163d',
foreground: '#invalid_color',
background: '#ffffff',
};
expect( () => colorPaletteValidator.parse( invalidPalette ) )
.toThrowErrorMatchingInlineSnapshot( `
"[
{
\\"validation\\": \\"regex\\",
\\"code\\": \\"invalid_string\\",
\\"message\\": \\"Invalid secondary color\\",
\\"path\\": [
\\"secondary\\"
]
},
{
\\"validation\\": \\"regex\\",
\\"code\\": \\"invalid_string\\",
\\"message\\": \\"Invalid foreground color\\",
\\"path\\": [
\\"foreground\\"
]
}
]"
` );
} );
it( 'should fail for an invalid background color', () => {
const invalidPalette = {
name: 'Ancient Bronze',
primary: '#11163d',
secondary: '#11163d',
foreground: '#11163d',
background: '#fffff',
};
expect( () => colorPaletteValidator.parse( invalidPalette ) )
.toThrowErrorMatchingInlineSnapshot( `
"[
{
\\"validation\\": \\"regex\\",
\\"code\\": \\"invalid_string\\",
\\"message\\": \\"Invalid background color\\",
\\"path\\": [
\\"background\\"
]
}
]"
` );
} );
} );
describe( 'colorPaletteResponseValidator', () => {
it( 'should validate a correct color palette response', () => {
const validPalette = {
default: 'Ancient Bronze',
bestColors: Array( 8 ).fill( 'Ancient Bronze' ),
};
const parsedResult =
defaultColorPalette.responseValidation( validPalette );
expect( parsedResult ).toEqual( validPalette );
} );
it( 'should fail if array contains invalid color', () => {
const invalidPalette = {
default: 'Ancient Bronze',
bestColors: Array( 7 )
.fill( 'Ancient Bronze' )
.concat( [ 'Invalid Color' ] ),
};
expect( () => defaultColorPalette.responseValidation( invalidPalette ) )
.toThrowErrorMatchingInlineSnapshot( `
"[
{
\\"code\\": \\"custom\\",
\\"message\\": \\"Color palette not part of allowed list\\",
\\"path\\": [
\\"bestColors\\",
7
]
}
]"
` );
} );
it( 'should fail if bestColors property is missing', () => {
const invalidPalette = {
default: 'Ancient Bronze',
};
expect( () => defaultColorPalette.responseValidation( invalidPalette ) )
.toThrowErrorMatchingInlineSnapshot( `
"[
{
\\"code\\": \\"invalid_type\\",
\\"expected\\": \\"array\\",
\\"received\\": \\"undefined\\",
\\"path\\": [
\\"bestColors\\"
],
\\"message\\": \\"Required\\"
}
]"
` );
} );
it( 'should fail if default property is missing', () => {
const invalidPalette = {
bestColors: Array( 8 ).fill( 'Ancient Bronze' ),
};
expect( () => defaultColorPalette.responseValidation( invalidPalette ) )
.toThrowErrorMatchingInlineSnapshot( `
"[
{
\\"code\\": \\"invalid_type\\",
\\"expected\\": \\"string\\",
\\"received\\": \\"undefined\\",
\\"path\\": [
\\"default\\"
],
\\"message\\": \\"Required\\"
}
]"
` );
} );
it( 'should fail if bestColors array is not of length 8', () => {
const invalidPalette = {
default: 'Ancient Bronze',
bestColors: Array( 7 ).fill( 'Ancient Bronze' ),
};
expect( () => defaultColorPalette.responseValidation( invalidPalette ) )
.toThrowErrorMatchingInlineSnapshot( `
"[
{
\\"code\\": \\"too_small\\",
\\"minimum\\": 8,
\\"type\\": \\"array\\",
\\"inclusive\\": true,
\\"exact\\": true,
\\"message\\": \\"Array must contain exactly 8 element(s)\\",
\\"path\\": [
\\"bestColors\\"
]
}
]"
` );
} );
} );

View File

@ -0,0 +1,47 @@
/**
* Internal dependencies
*/
import { fontChoiceValidator } from '..';
describe( 'fontChoiceValidator', () => {
it( 'should validate when font choice is part of the allowed list', () => {
const validFontChoice = { pair_name: 'Montserrat + Arvo' };
expect( () =>
fontChoiceValidator.parse( validFontChoice )
).not.toThrow();
} );
it( 'should not validate when font choice is not part of the allowed list', () => {
const invalidFontChoice = { pair_name: 'Comic Sans' };
expect( () => fontChoiceValidator.parse( invalidFontChoice ) )
.toThrowErrorMatchingInlineSnapshot( `
"[
{
\\"code\\": \\"custom\\",
\\"message\\": \\"Font choice not part of allowed list\\",
\\"path\\": [
\\"pair_name\\"
]
}
]"
` );
} );
it( 'should not validate when pair_name is not a string', () => {
const invalidType = { pair_name: 123 };
expect( () => fontChoiceValidator.parse( invalidType ) )
.toThrowErrorMatchingInlineSnapshot( `
"[
{
\\"code\\": \\"invalid_type\\",
\\"expected\\": \\"string\\",
\\"received\\": \\"number\\",
\\"path\\": [
\\"pair_name\\"
],
\\"message\\": \\"Expected string, received number\\"
}
]"
` );
} );
} );

View File

@ -0,0 +1,47 @@
/**
* Internal dependencies
*/
import { footerValidator } from '..';
describe( 'footerValidator', () => {
it( 'should validate when footer is part of the allowed list', () => {
const validFooter = { slug: 'woocommerce-blocks/footer-large' };
expect( () => footerValidator.parse( validFooter ) ).not.toThrow();
} );
it( 'should not validate when footer is not part of the allowed list', () => {
const invalidFooter = {
slug: 'woocommerce-blocks/footer-large-invalid',
};
expect( () => footerValidator.parse( invalidFooter ) )
.toThrowErrorMatchingInlineSnapshot( `
"[
{
\\"code\\": \\"custom\\",
\\"message\\": \\"Footer not part of allowed list\\",
\\"path\\": [
\\"slug\\"
]
}
]"
` );
} );
it( 'should not validate when slug is not a string', () => {
const invalidType = { slug: 123 };
expect( () => footerValidator.parse( invalidType ) )
.toThrowErrorMatchingInlineSnapshot( `
"[
{
\\"code\\": \\"invalid_type\\",
\\"expected\\": \\"string\\",
\\"received\\": \\"number\\",
\\"path\\": [
\\"slug\\"
],
\\"message\\": \\"Expected string, received number\\"
}
]"
` );
} );
} );

View File

@ -0,0 +1,47 @@
/**
* Internal dependencies
*/
import { headerValidator } from '..';
describe( 'headerValidator', () => {
it( 'should validate when header is part of the allowed list', () => {
const validHeader = { slug: 'woocommerce-blocks/header-large' };
expect( () => headerValidator.parse( validHeader ) ).not.toThrow();
} );
it( 'should not validate when header is not part of the allowed list', () => {
const invalidHeader = {
slug: 'woocommerce-blocks/header-large-invalid',
};
expect( () => headerValidator.parse( invalidHeader ) )
.toThrowErrorMatchingInlineSnapshot( `
"[
{
\\"code\\": \\"custom\\",
\\"message\\": \\"Header not part of allowed list\\",
\\"path\\": [
\\"slug\\"
]
}
]"
` );
} );
it( 'should not validate when slug is not a string', () => {
const invalidType = { slug: 123 };
expect( () => headerValidator.parse( invalidType ) )
.toThrowErrorMatchingInlineSnapshot( `
"[
{
\\"code\\": \\"invalid_type\\",
\\"expected\\": \\"string\\",
\\"received\\": \\"number\\",
\\"path\\": [
\\"slug\\"
],
\\"message\\": \\"Expected string, received number\\"
}
]"
` );
} );
} );

View File

@ -0,0 +1,28 @@
/**
* Internal dependencies
*/
import { LookAndToneCompletionResponse } from '../../types';
import { lookAndTone } from '..';
describe( 'parseLookAndToneCompletionResponse', () => {
it( 'should return a valid object when given valid JSON', () => {
const validObj = JSON.parse(
'{"look": "Contemporary", "tone": "Neutral"}'
);
const result = lookAndTone.responseValidation( validObj );
const expected: LookAndToneCompletionResponse = {
look: 'Contemporary',
tone: 'Neutral',
};
expect( result ).toEqual( expected );
} );
it( 'should throw an error and record an event for valid JSON but invalid values', () => {
const invalidValuesObj = {
completion: '{"look": "Invalid", "tone": "Invalid"}',
};
expect( () =>
lookAndTone.responseValidation( invalidValuesObj )
).toThrow( 'Invalid values in Look and Tone completion response' );
} );
} );

View File

@ -1,106 +1,32 @@
/* eslint-disable @woocommerce/dependency-group */
/* eslint-disable @typescript-eslint/ban-ts-comment */
/**
* External dependencies
*/
import { __experimentalRequestJetpackToken as requestJetpackToken } from '@woocommerce/ai';
import apiFetch from '@wordpress/api-fetch';
import { recordEvent } from '@woocommerce/tracks';
import { Sender } from 'xstate';
import { OPTIONS_STORE_NAME } from '@woocommerce/data';
import { Sender, assign, createMachine } from 'xstate';
import { dispatch, resolveSelect } from '@wordpress/data';
// @ts-ignore No types for this exist yet.
import { store as coreStore } from '@wordpress/core-data';
// @ts-ignore No types for this exist yet.
import { mergeBaseAndUserConfigs } from '@wordpress/edit-site/build-module/components/global-styles/global-styles-provider';
/**
* Internal dependencies
*/
import { designWithAiStateMachineContext } from './types';
import { lookAndTone } from './prompts';
import { FONT_PAIRINGS } from '../assembler-hub/sidebar/global-styles/font-pairing-variations/constants';
import { COLOR_PALETTES } from '../assembler-hub/sidebar/global-styles/color-palette-variations/constants';
import {
Look,
Tone,
VALID_LOOKS,
VALID_TONES,
designWithAiStateMachineContext,
} from './types';
export interface LookAndToneCompletionResponse {
look: Look;
tone: Tone;
}
interface MaybeLookAndToneCompletionResponse {
completion: string;
}
export const isLookAndToneCompletionResponse = (
obj: unknown
): obj is LookAndToneCompletionResponse => {
return (
obj !== undefined &&
obj !== null &&
typeof obj === 'object' &&
'look' in obj &&
VALID_LOOKS.includes( obj.look as Look ) &&
'tone' in obj &&
VALID_TONES.includes( obj.tone as Tone )
);
};
export const parseLookAndToneCompletionResponse = (
obj: MaybeLookAndToneCompletionResponse
): LookAndToneCompletionResponse => {
try {
const o = JSON.parse( obj.completion );
if ( isLookAndToneCompletionResponse( o ) ) {
return o;
}
} catch {
recordEvent(
'customize_your_store_look_and_tone_ai_completion_response_error',
{ error_type: 'json_parse_error', response: JSON.stringify( obj ) }
);
}
recordEvent(
'customize_your_store_look_and_tone_ai_completion_response_error',
{
error_type: 'valid_json_invalid_values',
response: JSON.stringify( obj ),
}
);
throw new Error( 'Could not parse Look and Tone completion response.' );
};
export const getLookAndTone = async (
context: designWithAiStateMachineContext
) => {
const prompt = [
'You are a WordPress theme expert.',
'Analyze the following store description and determine the look and tone of the theme.',
`For look, you can choose between ${ VALID_LOOKS.join( ',' ) }.`,
`For tone of the description, you can choose between ${ VALID_TONES.join(
','
) }.`,
'Your response should be in json with look and tone values.',
'\n',
context.businessInfoDescription.descriptionText,
];
const { token } = await requestJetpackToken();
const url = new URL(
'https://public-api.wordpress.com/wpcom/v2/text-completion'
);
url.searchParams.append( 'feature', 'woo_cys' );
const data: {
completion: string;
} = await apiFetch( {
url: url.toString(),
method: 'POST',
data: {
token,
prompt: prompt.join( '\n' ),
_fields: 'completion',
},
} );
return parseLookAndToneCompletionResponse( data );
};
patternsToNameMap,
getTemplatePatterns,
LARGE_BUSINESS_TEMPLATES,
SMALL_MEDIUM_BUSINESS_TEMPLATES,
} from '../assembler-hub/hooks/use-home-templates';
const browserPopstateHandler =
() => ( sendBack: Sender< { type: 'EXTERNAL_URL_UPDATE' } > ) => {
@ -113,7 +39,354 @@ const browserPopstateHandler =
};
};
export const getCompletion = async < ValidResponseObject >( {
queryId,
prompt,
version,
responseValidation,
retryCount,
}: {
queryId: string;
prompt: string;
version: string;
responseValidation: ( arg0: string ) => ValidResponseObject;
retryCount: number;
} ) => {
const { token } = await requestJetpackToken();
let data: {
completion: string;
};
let parsedCompletionJson;
try {
const url = new URL(
'https://public-api.wordpress.com/wpcom/v2/text-completion'
);
url.searchParams.append( 'feature', 'woo_cys' );
data = await apiFetch( {
url: url.toString(),
method: 'POST',
data: {
token,
prompt,
_fields: 'completion',
},
} );
} catch ( error ) {
recordEvent( 'customize_your_store_ai_completion_api_error', {
query_id: queryId,
version,
retry_count: retryCount,
error_type: 'api_request_error',
} );
throw error;
}
try {
parsedCompletionJson = JSON.parse( data.completion );
} catch {
recordEvent( 'customize_your_store_ai_completion_response_error', {
query_id: queryId,
version,
retry_count: retryCount,
error_type: 'json_parse_error',
response: data.completion,
} );
throw new Error(
`Error validating Jetpack AI text completions response for ${ queryId }`
);
}
try {
const validatedResponse = responseValidation( parsedCompletionJson );
recordEvent( 'customize_your_store_ai_completion_success', {
query_id: queryId,
version,
retry_count: retryCount,
} );
return validatedResponse;
} catch ( error ) {
recordEvent( 'customize_your_store_ai_completion_response_error', {
query_id: queryId,
version,
retry_count: retryCount,
error_type: 'valid_json_invalid_values',
response: data.completion,
} );
throw error;
}
};
export const getLookAndTone = async (
context: designWithAiStateMachineContext
) => {
return getCompletion( {
...lookAndTone,
prompt: lookAndTone.prompt(
context.businessInfoDescription.descriptionText
),
retryCount: 0,
} );
};
export const queryAiEndpoint = createMachine(
{
id: 'query-ai-endpoint',
predictableActionArguments: true,
initial: 'init',
context: {
// these values are all overwritten by incoming parameters
prompt: '',
queryId: '',
version: '',
responseValidation: () => true,
retryCount: 0,
validatedResponse: {} as unknown,
},
states: {
init: {
always: 'querying',
entry: [ 'setRetryCount' ],
},
querying: {
invoke: {
src: 'getCompletion',
onDone: {
target: 'success',
actions: [ 'handleAiResponse' ],
},
onError: {
target: 'error',
},
},
},
error: {
always: [
{
cond: ( context ) => context.retryCount >= 3,
target: 'failed',
},
{
target: 'querying',
actions: assign( {
retryCount: ( context ) => context.retryCount + 1,
} ),
},
],
},
failed: {
type: 'final',
data: {
result: 'failed',
},
},
success: {
type: 'final',
data: ( context ) => {
return {
result: 'success',
response: context.validatedResponse,
};
},
},
},
},
{
actions: {
handleAiResponse: assign( {
validatedResponse: ( _context, event: unknown ) =>
( event as { data: unknown } ).data,
} ),
setRetryCount: assign( {
retryCount: 0,
} ),
},
services: {
getCompletion,
},
}
);
export const updateStorePatterns = async (
context: designWithAiStateMachineContext
) => {
try {
// TODO: Probably move this to a more appropriate place with a check. We should set this when the user granted permissions during the onboarding phase.
await dispatch( OPTIONS_STORE_NAME ).updateOptions( {
woocommerce_blocks_allow_ai_connection: true,
} );
await apiFetch( {
path: '/wc/store/patterns',
method: 'POST',
data: {
business_description:
context.businessInfoDescription.descriptionText,
},
} );
} catch ( error ) {
recordEvent( 'customize_your_store_update_store_pattern_api_error', {
error: error instanceof Error ? error.message : 'unknown',
} );
throw error;
}
};
// Update the current global styles of theme
const updateGlobalStyles = async ( {
colorPaletteName = COLOR_PALETTES[ 0 ].title,
fontPairingName = FONT_PAIRINGS[ 0 ].title,
}: {
colorPaletteName: string;
fontPairingName: string;
} ) => {
const colorPalette = COLOR_PALETTES.find(
( palette ) => palette.title === colorPaletteName
);
const fontPairing = FONT_PAIRINGS.find(
( pairing ) => pairing.title === fontPairingName
);
const globalStylesId = await resolveSelect(
coreStore
// @ts-ignore No types for this exist yet.
).__experimentalGetCurrentGlobalStylesId();
// @ts-ignore No types for this exist yet.
const { saveEntityRecord } = dispatch( coreStore );
await saveEntityRecord(
'root',
'globalStyles',
{
id: globalStylesId,
styles: mergeBaseAndUserConfigs(
colorPalette?.styles || {},
fontPairing?.styles || {}
),
settings: mergeBaseAndUserConfigs(
colorPalette?.settings || {},
fontPairing?.settings || {}
),
},
{
throwOnError: true,
}
);
};
// Update the current theme template
const updateTemplate = async ( {
headerSlug,
businessSize,
homepageTemplateId,
footerSlug,
}: {
headerSlug: string;
businessSize: 'SMB' | 'LB';
homepageTemplateId:
| keyof typeof SMALL_MEDIUM_BUSINESS_TEMPLATES
| keyof typeof LARGE_BUSINESS_TEMPLATES;
footerSlug: string;
} ) => {
const patterns = ( await resolveSelect(
coreStore
// @ts-ignore No types for this exist yet.
).getBlockPatterns() ) as Pattern[];
const patternsByName = patternsToNameMap( patterns );
const headerPattern = patternsByName[ headerSlug ];
const footerPattern = patternsByName[ footerSlug ];
const homepageTemplate = getTemplatePatterns(
businessSize === 'SMB'
? SMALL_MEDIUM_BUSINESS_TEMPLATES[ homepageTemplateId ]
: LARGE_BUSINESS_TEMPLATES[ homepageTemplateId ],
patternsByName
);
const content = [ headerPattern, ...homepageTemplate, footerPattern ]
.filter( Boolean )
.map( ( pattern ) => pattern.content )
.join( '\n\n' );
const currentTemplate = await resolveSelect(
coreStore
// @ts-ignore No types for this exist yet.
).__experimentalGetTemplateForLink( '/' );
// @ts-ignore No types for this exist yet.
const { saveEntityRecord } = dispatch( coreStore );
await saveEntityRecord(
'postType',
currentTemplate.type,
{
id: currentTemplate.id,
content,
},
{
throwOnError: true,
}
);
};
export const assembleSite = async (
context: designWithAiStateMachineContext
) => {
try {
await updateGlobalStyles( {
colorPaletteName: context.aiSuggestions.defaultColorPalette.default,
fontPairingName: context.aiSuggestions.fontPairing,
} );
recordEvent( 'customize_your_store_ai_update_global_styles_success' );
} catch ( error ) {
// TODO handle error
// eslint-disable-next-line no-console
console.error( error );
recordEvent(
'customize_your_store_ai_update_global_styles_response_error',
{
error: error instanceof Error ? error.message : 'unknown',
}
);
}
try {
await updateTemplate( {
headerSlug: context.aiSuggestions.header,
// TODO: Get from context
businessSize: 'SMB',
homepageTemplateId: 'template1',
footerSlug: context.aiSuggestions.footer,
} );
recordEvent( 'customize_your_store_ai_update_template_success' );
} catch ( error ) {
// TODO handle error
// eslint-disable-next-line no-console
console.error( error );
recordEvent( 'customize_your_store_ai_update_template_response_error', {
error: error instanceof Error ? error.message : 'unknown',
} );
}
// @ts-ignore No types for this exist yet.
const { invalidateResolutionForStoreSelector } = dispatch( coreStore );
// Invalid the selectors so that the new template/style are used in assembler hub.
invalidateResolutionForStoreSelector( 'getEntityRecord' );
invalidateResolutionForStoreSelector(
'__experimentalGetCurrentGlobalStylesId'
);
invalidateResolutionForStoreSelector( '__experimentalGetTemplateForLink' );
};
export const services = {
getLookAndTone,
browserPopstateHandler,
queryAiEndpoint,
assembleSite,
updateStorePatterns,
};

View File

@ -10,6 +10,10 @@ import { getQuery } from '@woocommerce/navigation';
import {
designWithAiStateMachineContext,
designWithAiStateMachineEvents,
FontPairing,
Header,
Footer,
ColorPaletteResponse,
} from './types';
import {
BusinessInfoDescription,
@ -19,6 +23,12 @@ import {
} from './pages';
import { actions } from './actions';
import { services } from './services';
import {
defaultColorPalette,
fontPairings,
defaultHeader,
defaultFooter,
} from './prompts';
export const hasStepInUrl = (
_ctx: unknown,
@ -60,13 +70,18 @@ export const designWithAiStateMachineDefinition = createMachine(
businessInfoDescription: {
descriptionText: '',
},
lookAndFeel: {
choice: '',
},
toneOfVoice: {
choice: '',
},
aiSuggestions: {
defaultColorPalette: {} as ColorPaletteResponse,
fontPairing: '' as FontPairing[ 'pair_name' ],
header: '' as Header[ 'slug' ],
footer: '' as Footer[ 'slug' ],
},
},
initial: 'navigate',
states: {
@ -264,8 +279,161 @@ export const designWithAiStateMachineDefinition = createMachine(
step: 'api-call-loader',
},
],
type: 'parallel',
states: {
chooseColorPairing: {
initial: 'pending',
states: {
pending: {
invoke: {
src: 'queryAiEndpoint',
data: ( context ) => {
return {
...defaultColorPalette,
prompt: defaultColorPalette.prompt(
context
.businessInfoDescription
.descriptionText,
context.lookAndFeel
.choice,
context.toneOfVoice
.choice
),
};
},
onDone: {
actions: [
'assignDefaultColorPalette',
],
target: 'success',
},
},
},
success: { type: 'final' },
},
},
chooseFontPairing: {
initial: 'pending',
states: {
pending: {
invoke: {
src: 'queryAiEndpoint',
data: ( context ) => {
return {
...fontPairings,
prompt: fontPairings.prompt(
context
.businessInfoDescription
.descriptionText,
context.lookAndFeel
.choice,
context.toneOfVoice
.choice
),
};
},
onDone: {
actions: [
'assignFontPairing',
],
target: 'success',
},
},
},
success: { type: 'final' },
},
},
chooseHeader: {
initial: 'pending',
states: {
pending: {
invoke: {
src: 'queryAiEndpoint',
data: ( context ) => {
return {
...defaultHeader,
prompt: defaultHeader.prompt(
context
.businessInfoDescription
.descriptionText,
context.lookAndFeel
.choice,
context.toneOfVoice
.choice
),
};
},
onDone: {
actions: [ 'assignHeader' ],
target: 'success',
},
},
},
success: { type: 'final' },
},
},
chooseFooter: {
initial: 'pending',
states: {
pending: {
invoke: {
src: 'queryAiEndpoint',
data: ( context ) => {
return {
...defaultFooter,
prompt: defaultFooter.prompt(
context
.businessInfoDescription
.descriptionText,
context.lookAndFeel
.choice,
context.toneOfVoice
.choice
),
};
},
onDone: {
actions: [ 'assignFooter' ],
target: 'success',
},
},
},
success: { type: 'final' },
},
},
updateStorePatterns: {
initial: 'pending',
states: {
pending: {
invoke: {
src: 'updateStorePatterns',
onDone: {
target: 'success',
},
onError: {
// TODO: handle error
target: 'success',
},
},
},
success: { type: 'final' },
},
},
},
onDone: 'postApiCallLoader',
},
postApiCallLoader: {
invoke: {
src: 'assembleSite',
onDone: {
actions: [
sendParent( () => ( {
type: 'THEME_SUGGESTED',
} ) ),
],
},
},
},
postApiCallLoader: {},
},
},
},

View File

@ -0,0 +1,28 @@
.woocommerce-onboarding-loader {
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
background-color: #fff;
.woocommerce-onboarding-loader-wrapper {
align-items: flex-start;
display: flex;
flex-direction: column;
justify-content: center;
max-width: 520px;
min-height: 400px;
.woocommerce-onboarding-loader-container {
text-align: center;
min-height: 400px;
}
.loader-hearticon {
position: relative;
top: 2px;
left: 2px;
}
}
}

View File

@ -2,63 +2,154 @@
* External dependencies
*/
import { recordEvent } from '@woocommerce/tracks';
import { __experimentalRequestJetpackToken as requestJetpackToken } from '@woocommerce/ai';
import apiFetch from '@wordpress/api-fetch';
/**
* Internal dependencies
*/
import {
parseLookAndToneCompletionResponse,
LookAndToneCompletionResponse,
} from '../services';
import { getCompletion } from '../services';
jest.mock( '@woocommerce/tracks', () => ( {
recordEvent: jest.fn(),
} ) );
describe( 'parseLookAndToneCompletionResponse', () => {
jest.mock( '@woocommerce/ai', () => ( {
__experimentalRequestJetpackToken: jest.fn(),
} ) );
jest.mock( '@wordpress/api-fetch', () => jest.fn() );
jest.mock(
'@wordpress/edit-site/build-module/components/global-styles/global-styles-provider',
() => ( {
mergeBaseAndUserConfigs: jest.fn(),
} )
);
describe( 'getCompletion', () => {
beforeEach( () => {
( recordEvent as jest.Mock ).mockClear();
jest.clearAllMocks();
} );
it( 'should return a valid object when given valid JSON', () => {
const validObj = {
completion: '{"look": "Contemporary", "tone": "Neutral"}',
};
const result = parseLookAndToneCompletionResponse( validObj );
const expected: LookAndToneCompletionResponse = {
look: 'Contemporary',
tone: 'Neutral',
};
expect( result ).toEqual( expected );
expect( recordEvent ).not.toHaveBeenCalled();
} );
it( 'should successfully get completion', async () => {
( requestJetpackToken as jest.Mock ).mockResolvedValue( {
token: 'fake_token',
} );
( apiFetch as unknown as jest.Mock ).mockResolvedValue( {
completion: JSON.stringify( { key: 'value' } ),
} );
const responseValidation = jest.fn( ( json ) => json );
it( 'should throw an error and record an event for JSON parse error', () => {
const invalidObj = { completion: 'invalid JSON' };
expect( () =>
parseLookAndToneCompletionResponse( invalidObj )
).toThrow( 'Could not parse Look and Tone completion response.' );
expect( recordEvent ).toHaveBeenCalledWith(
'customize_your_store_look_and_tone_ai_completion_response_error',
const result = await getCompletion( {
queryId: 'query1',
prompt: 'test prompt',
responseValidation,
retryCount: 0,
version: '1',
} );
expect( result ).toEqual( { key: 'value' } );
expect( responseValidation ).toBeCalledWith( { key: 'value' } );
expect( recordEvent ).toBeCalledWith(
'customize_your_store_ai_completion_success',
{
error_type: 'json_parse_error',
response: JSON.stringify( invalidObj ),
query_id: 'query1',
retry_count: 0,
version: '1',
}
);
} );
it( 'should throw an error and record an event for valid JSON but invalid values', () => {
const invalidValuesObj = {
completion: '{"look": "Invalid", "tone": "Invalid"}',
};
expect( () =>
parseLookAndToneCompletionResponse( invalidValuesObj )
).toThrow( 'Could not parse Look and Tone completion response.' );
expect( recordEvent ).toHaveBeenCalledWith(
'customize_your_store_look_and_tone_ai_completion_response_error',
it( 'should handle API fetch error', async () => {
( requestJetpackToken as jest.Mock ).mockResolvedValue( {
token: 'fake_token',
} );
( apiFetch as unknown as jest.Mock ).mockRejectedValue(
new Error( 'API error' )
);
await expect(
getCompletion( {
queryId: 'query1',
prompt: 'test prompt',
responseValidation: () => {},
retryCount: 0,
version: '1',
} )
).rejects.toThrow( 'API error' );
expect( recordEvent ).toBeCalledWith(
'customize_your_store_ai_completion_api_error',
{
query_id: 'query1',
retry_count: 0,
error_type: 'api_request_error',
version: '1',
}
);
} );
it( 'should handle JSON parse error', async () => {
( requestJetpackToken as jest.Mock ).mockResolvedValue( {
token: 'fake_token',
} );
( apiFetch as unknown as jest.Mock ).mockResolvedValue( {
completion: 'invalid json',
} );
await expect(
getCompletion( {
queryId: 'query1',
prompt: 'test prompt',
responseValidation: () => {},
retryCount: 0,
version: '1',
} )
).rejects.toThrow(
`Error validating Jetpack AI text completions response for query1`
);
expect( recordEvent ).toBeCalledWith(
'customize_your_store_ai_completion_response_error',
{
query_id: 'query1',
retry_count: 0,
error_type: 'json_parse_error',
response: 'invalid json',
version: '1',
}
);
} );
it( 'should handle validation error', async () => {
( requestJetpackToken as jest.Mock ).mockResolvedValue( {
token: 'fake_token',
} );
( apiFetch as unknown as jest.Mock ).mockResolvedValue( {
completion: JSON.stringify( { key: 'invalid value' } ),
} );
const responseValidation = jest.fn( () => {
throw new Error( 'Validation error' );
} );
await expect(
getCompletion( {
queryId: 'query1',
prompt: 'test prompt',
responseValidation,
retryCount: 0,
version: '1',
} )
).rejects.toThrow( 'Validation error' );
expect( recordEvent ).toBeCalledWith(
'customize_your_store_ai_completion_response_error',
{
query_id: 'query1',
retry_count: 0,
error_type: 'valid_json_invalid_values',
response: JSON.stringify( invalidValuesObj ),
response: JSON.stringify( { key: 'invalid value' } ),
version: '1',
}
);
} );

View File

@ -1,3 +1,18 @@
/**
* External dependencies
*/
import { z } from 'zod';
/**
* Internal dependencies
*/
import {
colorPaletteValidator,
fontChoiceValidator,
headerValidator,
footerValidator,
colorPaletteResponseValidator,
} from './prompts';
export type designWithAiStateMachineContext = {
businessInfoDescription: {
descriptionText: string;
@ -8,6 +23,12 @@ export type designWithAiStateMachineContext = {
toneOfVoice: {
choice: Tone | '';
};
aiSuggestions: {
defaultColorPalette: ColorPaletteResponse;
fontPairing: FontPairing[ 'pair_name' ];
header: Header[ 'slug' ];
footer: Footer[ 'slug' ];
};
// If we require more data from options, previously provided core profiler details,
// we can retrieve them in preBusinessInfoDescription and then assign them here
};
@ -31,3 +52,19 @@ export const VALID_LOOKS = [ 'Contemporary', 'Classic', 'Bold' ] as const;
export const VALID_TONES = [ 'Informal', 'Neutral', 'Formal' ] as const;
export type Look = ( typeof VALID_LOOKS )[ number ];
export type Tone = ( typeof VALID_TONES )[ number ];
export interface LookAndToneCompletionResponse {
look: Look;
tone: Tone;
}
export type ColorPalette = z.infer< typeof colorPaletteValidator >;
export type ColorPaletteResponse = z.infer<
typeof colorPaletteResponseValidator
>;
export type FontPairing = z.infer< typeof fontChoiceValidator >;
export type Header = z.infer< typeof headerValidator >;
export type Footer = z.infer< typeof footerValidator >;

View File

@ -4,7 +4,13 @@
import { Sender, createMachine } from 'xstate';
import { useEffect, useMemo, useState } from '@wordpress/element';
import { useMachine, useSelector } from '@xstate/react';
import { getQuery, updateQueryString } from '@woocommerce/navigation';
import {
getNewPath,
getQuery,
updateQueryString,
} from '@woocommerce/navigation';
import { OPTIONS_STORE_NAME } from '@woocommerce/data';
import { dispatch } from '@wordpress/data';
/**
* Internal dependencies
@ -57,6 +63,16 @@ const updateQueryStep = (
}
};
const redirectToWooHome = () => {
window.location.href = getNewPath( {}, '/', {} );
};
const markTaskComplete = async () => {
return dispatch( OPTIONS_STORE_NAME ).updateOptions( {
woocommerce_admin_customize_store_completed: 'yes',
} );
};
const browserPopstateHandler =
() => ( sendBack: Sender< { type: 'EXTERNAL_URL_UPDATE' } > ) => {
const popstateHandler = () => {
@ -70,6 +86,7 @@ const browserPopstateHandler =
export const machineActions = {
updateQueryStep,
redirectToWooHome,
};
export const customizeStoreStateMachineActions = {
@ -81,6 +98,7 @@ export const customizeStoreStateMachineServices = {
...introServices,
...transitionalServices,
browserPopstateHandler,
markTaskComplete,
};
export const customizeStoreStateMachineDefinition = createMachine( {
id: 'customizeStore',
@ -175,7 +193,7 @@ export const customizeStoreStateMachineDefinition = createMachine( {
target: 'assemblerHub',
},
CLICKED_ON_BREADCRUMB: {
target: 'backToHomescreen',
actions: 'redirectToWooHome',
},
SELECTED_NEW_THEME: {
target: 'appearanceTask',
@ -220,6 +238,14 @@ export const customizeStoreStateMachineDefinition = createMachine( {
},
},
postAssemblerHub: {
invoke: {
src: 'markTaskComplete',
onDone: {
target: 'waitForSitePreview',
},
},
},
waitForSitePreview: {
after: {
// Wait for 5 seconds before redirecting to the transitional page. This is to ensure that the site preview image is refreshed.
5000: {
@ -246,11 +272,10 @@ export const customizeStoreStateMachineDefinition = createMachine( {
},
on: {
GO_BACK_TO_HOME: {
target: 'backToHomescreen',
actions: 'redirectToWooHome',
},
},
},
backToHomescreen: {},
appearanceTask: {},
},
} );

View File

@ -1,8 +1,17 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { chevronLeft } from '@wordpress/icons';
/**
* Internal dependencies
*/
import { CustomizeStoreComponent } from '../types';
import './intro.scss';
export type events =
| { type: 'DESIGN_WITH_AI' }
| { type: 'CLICKED_ON_BREADCRUMB' }
@ -15,33 +24,106 @@ export * as services from './services';
export const Intro: CustomizeStoreComponent = ( { sendEvent, context } ) => {
const {
intro: { themeCards, activeTheme },
intro: { themeCards },
} = context;
return (
<>
<h1>Intro</h1>
<div>Active theme: { activeTheme }</div>
{ themeCards?.map( ( themeCard ) => (
<button
key={ themeCard.name }
onClick={ () =>
sendEvent( {
type: 'SELECTED_NEW_THEME',
payload: { theme: themeCard.name },
} )
}
>
{ themeCard.name }
</button>
) ) }
<button onClick={ () => sendEvent( { type: 'DESIGN_WITH_AI' } ) }>
Design with AI
</button>
<button
onClick={ () => sendEvent( { type: 'SELECTED_ACTIVE_THEME' } ) }
>
Assembler Hub
</button>
<div className="woocommerce-customize-store-header">
<h1>{ 'Site title' }</h1>
</div>
<div className="woocommerce-customize-store-container">
<div className="woocommerce-customize-store-sidebar">
<div className="woocommerce-customize-store-sidebar__title">
<button
onClick={ () => {
sendEvent( 'CLICKED_ON_BREADCRUMB' );
} }
>
{ chevronLeft }
</button>
{ __( 'Customize your store', 'woocommerce' ) }
</div>
<p>
{ __(
'Create a store that reflects your brand and business. Select one of our professionally designed themes to customize, or create your own using AI.',
'woocommerce'
) }
</p>
</div>
<div className="woocommerce-customize-store-main">
<div className="woocommerce-customize-store-banner">
<div
className={ `woocommerce-customize-store-banner-content` }
>
<h1>
{ __(
'Use the power of AI to design your store',
'woocommerce'
) }
</h1>
<p>
{ __(
'Design the look of your store, create pages, and generate copy using our built-in AI tools.',
'woocommerce'
) }
</p>
<button
onClick={ () =>
sendEvent( { type: 'DESIGN_WITH_AI' } )
}
>
{ __( 'Design with AI', 'woocommerce' ) }
</button>
</div>
</div>
<p>
{ __(
'Or select a professionally designed theme to customize and make your own.',
'woocommerce'
) }
</p>
<div className="woocommerce-customize-store-theme-cards">
{ themeCards?.map( ( themeCard ) => (
<div className="theme-card" key={ themeCard.slug }>
<div>
<img
src={ themeCard.image }
alt={ themeCard.description }
/>
</div>
<h2 className="theme-card__title">
{ themeCard.name }
</h2>
</div>
) ) }
</div>
<div className="woocommerce-customize-store-browse-themes">
<button
onClick={ () =>
sendEvent( {
type: 'SELECTED_BROWSE_ALL_THEMES',
} )
}
>
{ __( 'Browse all themes', 'woocommerce' ) }
</button>
</div>
<button
onClick={ () =>
sendEvent( { type: 'SELECTED_ACTIVE_THEME' } )
}
>
Assembler Hub
</button>
</div>
</div>
</>
);
};

View File

@ -0,0 +1,155 @@
.woocommerce-profile-wizard__container {
display: flex;
flex-direction: column;
button {
cursor: pointer;
}
}
.woocommerce-customize-store-header {
min-height: 64px;
padding: 1rem;
width: 100%;
h1 {
font-size: 0.8125rem;
font-weight: 500;
margin: 0;
padding: 0;
line-height: 1.5rem;
}
}
.woocommerce-customize-store-container {
display: flex;
flex-direction: row;
}
.woocommerce-customize-store-sidebar {
flex: 0 0 380px;
padding: 1rem;
.woocommerce-customize-store-sidebar__title {
color: #1e1e1e;
font-size: 1rem;
font-weight: 600;
margin: 0;
padding: 0;
line-height: 2.5;
}
button {
background-color: transparent;
border: none;
line-height: 1;
padding-right: 0;
vertical-align: middle;
}
svg {
color: inherit;
height: 24px;
width: 24px;
margin: 0.25rem;
}
p {
padding: 0 1rem;
color: #757575;
max-width: 20rem;
}
}
.woocommerce-customize-store-main {
margin-right: 2.5rem;
p {
color: #2f2f2f;
font-size: 1rem;
line-height: 1.5;
margin-bottom: 1.5rem;
}
}
.woocommerce-customize-store-banner {
background: var(--woo-purple-woo-purple-0, #f2edff);
background-image: url(../assets/images/banner-design-with-ai.svg);
background-repeat: no-repeat;
background-position: bottom right;
background-size: contain;
border-radius: 4px;
display: flex;
margin: 1.25rem 0 3.375rem;
min-height: 343px;
padding: 70px 0;
width: 820px;
.woocommerce-customize-store-banner-content {
width: 345px;
margin-left: 50px;
h1 {
font-size: 1.5rem;
line-height: 1.33;
}
p {
margin: 1rem 0 2rem 0;
}
button {
background-color: #3858e9;
border: none;
border-radius: 2px;
color: #fff;
display: inline-block;
line-height: 1.25rem;
padding: 10px 15px;
&:hover {
background-color: #2234e0;
}
}
}
}
.woocommerce-customize-store-theme-cards {
display: flex;
flex-wrap: wrap;
gap: 2rem;
max-width: 820px;
.theme-card {
flex-basis: 45%;
img {
border-radius: 4px;
border: 1px solid #e9e9e9;
width: 394px;
}
.theme-card__title {
font-size: 1rem;
font-weight: 600;
margin: 1.5rem 0 0.5rem;
}
}
}
.woocommerce-customize-store-browse-themes {
text-align: center;
button {
background-color: #fff;
border: 1px solid #3858e9;
border-radius: 2px;
color: #3858e9;
display: inline-block;
font-size: 0.8125rem;
margin: 3.75rem 0;
padding: 0.5rem 0.75rem;
}
}

View File

@ -3,12 +3,55 @@
export const fetchThemeCards = async () => {
return [
{
slug: 'twentytwentyone',
name: 'Twenty Twenty One',
description: 'The default theme for WordPress.',
image: 'https://i0.wp.com/s2.wp.com/wp-content/themes/pub/twentytwentyone/screenshot.png',
styleVariations: [],
},
{
slug: 'twentytwenty',
name: 'Twenty Twenty',
description: 'The previous default theme for WordPress.',
image: 'https://i0.wp.com/s2.wp.com/wp-content/themes/pub/twentytwenty/screenshot.png',
styleVariations: [],
},
{
slug: 'tsubaki',
name: 'Tsubaki',
description:
'Tsubaki puts the spotlight on your products and your customers. This theme leverages WooCommerce to provide you with intuitive product navigation and the patterns you need to master digital merchandising.',
image: 'https://i0.wp.com/s2.wp.com/wp-content/themes/premium/tsubaki/screenshot.png',
styleVariations: [],
},
{
slug: 'winkel',
name: 'Winkel',
description:
'Winkel is a minimal, product-focused theme featuring Payments block. Its clean, cool look combined with a simple layout makes it perfect for showcasing fashion items clothes, shoes, and accessories.',
image: 'https://i0.wp.com/s2.wp.com/wp-content/themes/pub/winkel/screenshot.png',
styleVariations: [
{
title: 'Default',
primary: '#ffffff',
secondary: '#676767',
},
{
title: 'Charcoal',
primary: '#1f2527',
secondary: '#9fd3e8',
},
{
title: 'Rainforest',
primary: '#eef4f7',
secondary: '#35845d',
},
{
title: 'Ruby Wine',
primary: '#ffffff',
secondary: '#c8133e',
},
],
},
];
};

View File

@ -1,6 +1,9 @@
export type ThemeCard = {
// placeholder props, possibly take reference from https://github.com/Automattic/wp-calypso/blob/1f1b79210c49ef0d051f8966e24122229a334e29/packages/design-picker/src/components/theme-card/index.tsx#L32
slug: string;
name: string;
description: string;
image: string;
isActive: boolean;
styleVariations: string[];
};

View File

@ -20,6 +20,10 @@
body.woocommerce-customize-store.js.is-fullscreen-mode {
margin-top: 0 !important;
height: 100%;
& > div.tour-kit.woocommerce-tour-kit > div > div.tour-kit-spotlight.is-visible {
outline: 99999px solid rgba(0, 0, 0, 0.15);
}
}
.woocommerce-cys-layout {
@ -78,7 +82,7 @@ body.woocommerce-customize-store.js.is-fullscreen-mode {
padding: 12px;
width: 404px;
border-radius: 2px;
background: var(--gutenberg-transparent-blueberry, rgba(56, 88, 233, 0.04));
background: var(--wp-admin-theme-color-background-04, rgba(168, 168, 170, 0.301));
color: var(--gutenberg-gray-800, #2f2f2f);
p {

View File

@ -19,6 +19,7 @@ import { EmbeddedBodyLayout } from './embedded-body-layout';
import { WcAdminPaymentsGatewaysBannerSlot } from './payments/payments-settings-banner-slotfill';
import { WcAdminConflictErrorSlot } from './settings/conflict-error-slotfill.js';
import './xstate.js';
import { deriveWpAdminBackgroundColours } from './utils/derive-wp-admin-background-colours';
// Modify webpack pubilcPath at runtime based on location of WordPress Plugin.
// eslint-disable-next-line no-undef,camelcase
@ -48,6 +49,8 @@ const embeddedRoot = document.getElementById( 'woocommerce-embedded-root' );
const settingsGroup = 'wc_admin';
const hydrateUser = getAdminSetting( 'currentUserData' );
deriveWpAdminBackgroundColours();
if ( appRoot ) {
let HydratedPageLayout = withSettingsHydration(
settingsGroup,

View File

@ -24,6 +24,10 @@
cursor: default;
}
&__content {
white-space: pre-line;
}
.components-snackbar__content-with-icon {
margin-left: 32px;
}

View File

@ -1,12 +1,14 @@
<svg width="74" height="100" viewBox="0 0 74 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M67.7896 0L62.6619 4.11855L57.5289 0L52.4011 4.11855L47.2682 0L42.1353 4.11855L37.0075 0L31.8746 4.11855L26.7468 0L21.6138 4.11855L16.4809 0L11.348 4.11855L6.21501 0L0.916992 4.25258V95.7474L6.20986 100L11.3428 95.8763L16.4706 100L21.6035 95.8763L26.7313 100L31.8642 95.8763L36.9972 100L42.125 95.8763L47.2579 100L52.3857 95.8763L57.5186 100L62.6515 95.8763L67.7896 100L73.0825 95.7474V4.25258L67.7896 0Z" fill="#E0E0E0"/>
<path d="M49.4449 18.7216H7.10547V22.5103H49.4449V18.7216Z" fill="#757575"/>
<path d="M49.4449 30.2784H7.10547V34.067H49.4449V30.2784Z" fill="#757575"/>
<path d="M49.4449 41.8351H7.10547V45.6237H49.4449V41.8351Z" fill="#757575"/>
<path d="M66.8991 18.7216H56.9102V22.5103H66.8991V18.7216Z" fill="white"/>
<path d="M66.8991 30.2783H56.9102V34.067H66.8991V30.2783Z" fill="white"/>
<path d="M66.8991 41.835H56.9102V45.6237H66.8991V41.835Z" fill="white"/>
<path d="M66.8993 63.9176H50.4043V71.1341H66.8993V63.9176Z" fill="#757575"/>
<path d="M7.13379 55.5258H66.8714" stroke="#271B3D" stroke-width="0.510311" stroke-miterlimit="10"/>
<path d="M51.2154 89.8917C50.6639 89.8917 50.1845 89.7731 49.8082 89.4999C48.0556 88.2473 47.8597 85.768 47.6845 83.5772C47.4886 81.0618 47.2876 79.5205 45.736 79.5205C44.437 79.5205 43.5659 81.7731 42.7205 83.9484C41.705 86.5669 40.6535 89.2731 38.6122 89.2731C36.4473 89.2731 36.1895 87.1752 35.937 85.1494C35.6741 83.0205 35.4266 81.0051 33.2256 81.0051V80.4948C35.8802 80.4948 36.1792 82.9329 36.4473 85.0875C36.7308 87.3762 37.1586 88.4071 38.6071 88.4535C39.8648 88.4896 41.2875 86.2267 42.2411 83.768C43.1896 81.3247 44.2978 78.2886 45.9473 78.2886C48.0504 78.2886 48.2721 80.3762 48.4061 82.8195C48.5401 85.2628 48.5401 87.9741 50.102 89.0927C52.8546 91.0618 62.4011 83.2473 66.2259 79.4174L67.0558 80.3711C66.5609 80.804 56.0093 89.8968 51.2154 89.8968V89.8917Z" fill="#CCCCCC"/>
</svg>
<svg width="80" height="80" viewBox="0 0 80 80" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_2483_9556)">
<path d="M15.9776 43.968C11.1189 43.968 5.34584 44.544 2.95838 46.928C2.95838 46.928 2.95838 46.928 2.95439 46.932C2.95439 46.932 2.95439 46.932 2.95039 46.936C0.574906 49.332 -9.13044e-07 55.112 -7.00082e-07 59.984C-4.8712e-07 64.856 0.574907 70.64 2.95039 73.032C2.95039 73.032 2.95039 73.032 2.95439 73.036C2.95439 73.036 2.95438 73.036 2.95838 73.04C5.34584 75.424 11.1189 76 15.9776 76C20.8364 76 26.6094 75.424 28.9969 73.04C28.9969 73.04 28.9969 73.04 29.0009 73.036C29.0049 73.032 29.0009 73.036 29.0049 73.032C31.3804 70.636 31.9553 64.856 31.9553 59.984C31.9553 55.112 31.3804 49.328 29.0049 46.936C29.0049 46.936 29.0049 46.936 29.0009 46.932C28.9969 46.928 29.0009 46.932 28.9969 46.928C26.6094 44.544 20.8364 43.968 15.9776 43.968Z" fill="#757575"/>
<path d="M63.9776 3.968C59.1189 3.968 53.3458 4.544 50.9584 6.928C50.9584 6.928 50.9584 6.928 50.9544 6.932C50.9544 6.932 50.9544 6.932 50.9504 6.936C48.5749 9.332 48 15.112 48 19.984C48 24.856 48.5749 30.64 50.9504 33.032C50.9504 33.032 50.9504 33.032 50.9544 33.036C50.9544 33.036 50.9544 33.036 50.9584 33.04C53.3458 35.424 59.1189 36 63.9776 36C68.8364 36 74.6094 35.424 76.9969 33.04C76.9969 33.04 76.9969 33.04 77.0009 33.036C77.0049 33.032 77.0009 33.036 77.0049 33.032C79.3804 30.636 79.9553 24.856 79.9553 19.984C79.9553 15.112 79.3804 9.328 77.0049 6.936C77.0049 6.936 77.0049 6.936 77.0009 6.932C76.9969 6.928 77.0009 6.932 76.9969 6.92799C74.6094 4.544 68.8364 3.968 63.9776 3.968Z" fill="#757575"/>
<path d="M40 60C40 64.8656 40.7193 70.6467 43.6963 73.0375C43.6963 73.0375 43.6963 73.0375 43.7013 73.0415C43.7013 73.0415 43.7013 73.0415 43.7063 73.0455C46.6983 75.4243 53.9161 76 60 76C66.0839 76 73.3067 75.4243 76.2937 73.0455C76.2937 73.0455 76.2937 73.0455 76.2987 73.0415C76.2987 73.0415 76.2987 73.0415 76.3037 73.0375C79.2807 70.6467 80 64.8656 80 60C80 55.1344 79.2807 49.3533 76.3037 46.9625C76.3037 46.9625 76.3037 46.9625 76.2987 46.9585C76.2937 46.9545 76.2987 46.9585 76.2937 46.9545C73.3017 44.5757 66.0839 44 60 44C53.9161 44 46.6933 44.5757 43.7063 46.9545C43.7063 46.9545 43.7063 46.9545 43.7013 46.9585C43.6963 46.9625 43.7013 46.9585 43.6963 46.9625C40.7193 49.3533 40 55.1344 40 60Z" fill="#E0E0E0"/>
<path d="M-1.39876e-06 20C-9.73403e-07 24.8656 0.719276 30.6467 3.6963 33.0375C3.6963 33.0375 3.6963 33.0375 3.7013 33.0415C3.7013 33.0415 3.7013 33.0415 3.70629 33.0455C6.6983 35.4243 13.9161 36 20 36C26.0839 36 33.3067 35.4243 36.2937 33.0455C36.2937 33.0455 36.2937 33.0455 36.2987 33.0415C36.2987 33.0415 36.2987 33.0415 36.3037 33.0375C39.2807 30.6467 40 24.8656 40 20C40 15.1344 39.2807 9.35332 36.3037 6.96251C36.3037 6.96251 36.3037 6.96251 36.2987 6.95852C36.2937 6.95452 36.2987 6.95852 36.2937 6.95452C33.3017 4.57571 26.0839 4 20 4C13.9161 4 6.6933 4.57571 3.70629 6.95452C3.70629 6.95452 3.70629 6.95452 3.7013 6.95852C3.6963 6.96252 3.70129 6.95852 3.6963 6.96252C0.719274 9.35332 -1.82413e-06 15.1344 -1.39876e-06 20Z" fill="#E0E0E0"/>
<path d="M38.5095 14.0378C40.9729 11.6218 45.2004 11.3695 45.2004 10.4C45.2004 9.43044 40.9729 9.1782 38.5095 6.76221C36.0461 4.34621 35.789 0.199998 34.8004 0.199998C33.8118 0.199998 33.5546 4.34621 31.0913 6.76221C28.6279 9.1782 24.4004 9.43044 24.4004 10.4C24.4004 11.3695 28.6279 11.6218 31.0913 14.0378C33.5546 16.4538 33.8118 20.6 34.8004 20.6C35.789 20.6 36.0461 16.4538 38.5095 14.0378Z" fill="#757575"/>
</g>
<defs>
<clipPath id="clip0_2483_9556">
<rect width="80" height="80" fill="white" transform="translate(0 80) rotate(-90)"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

@ -4,11 +4,11 @@
box-sizing: content-box;
margin: auto;
max-width: $content-max-width;
padding: $header-height-mobile $content-spacing-small $content-spacing-small;
padding: 0 $content-spacing-small $content-spacing-small;
}
@media screen and (min-width: $breakpoint-medium) {
.woocommerce-marketplace__content {
padding: $header-height-desktop $content-spacing-large $content-spacing-large;
padding: 0 $content-spacing-large $content-spacing-large;
}
}

View File

@ -112,7 +112,12 @@ export default function Extensions(): JSX.Element {
return <NoResults />;
}
return <ProductListContent products={ products } />;
return (
<>
<CategorySelector />
<ProductListContent products={ products } />
</>
);
}
return (
@ -120,7 +125,6 @@ export default function Extensions(): JSX.Element {
<h2 className="woocommerce-marketplace__product-list-title woocommerce-marketplace__product-list-title--extensions">
{ title }
</h2>
<CategorySelector />
{ content() }
</div>
);

View File

@ -14,7 +14,7 @@ import WooIcon from '../../assets/images/woo-icon.svg';
import { MARKETPLACE_HOST } from '../constants';
const refundPolicyTitle = createInterpolateElement(
__( '30 day <a>money back guarantee</a>', 'woocommerce' ),
__( '30-day <a>money-back guarantee</a>', 'woocommerce' ),
{
// eslint-disable-next-line jsx-a11y/anchor-has-content
a: <a href={ MARKETPLACE_HOST + '/refund-policy/' } />,

View File

@ -5,6 +5,7 @@
padding: $grid-unit-80 $grid-unit-40;
display: flex;
flex-direction: column;
align-items: center;
margin-top: $grid-unit-30;
}
@ -13,12 +14,13 @@
}
.woocommerce-marketplace__no-results__icon {
height: 100px;
height: 80px;
}
.woocommerce-marketplace__no-results__description {
text-align: center;
font-size: 13px;
max-width: 52ch;
p {
color: $gutenberg-gray-700;

View File

@ -90,13 +90,15 @@ export default function NoResults(): JSX.Element {
className="woocommerce-marketplace__no-results__icon"
src={ NoResultsIcon }
alt={ __( 'No results.', 'woocommerce' ) }
width="80"
height="80"
/>
<div className="woocommerce-marketplace__no-results__description">
<h3 className="woocommerce-marketplace__no-results__description--bold">
{ sprintf(
// translators: %s: search term
__(
'We didn\'t find any results for "%s"',
"We didn't find any results for “%s”",
'woocommerce'
),
noResultsTerm

View File

@ -22,12 +22,14 @@ export default function ProductListContent( props: {
title: product.title,
icon: product.icon,
vendorName: product.vendorName,
vendorUrl: appendURLParams( product.vendorUrl, [
[ 'utm_source', 'extensionsscreen' ],
[ 'utm_medium', 'product' ],
[ 'utm_campaign', 'wcaddons' ],
[ 'utm_content', 'devpartner' ],
] ),
vendorUrl: product.vendorUrl
? appendURLParams( product.vendorUrl, [
[ 'utm_source', 'extensionsscreen' ],
[ 'utm_medium', 'product' ],
[ 'utm_campaign', 'wcaddons' ],
[ 'utm_content', 'devpartner' ],
] )
: '',
price: product.price,
url: appendURLParams(
product.url,

View File

@ -3,11 +3,31 @@
.woocommerce-admin-page__extensions {
background: #fff;
#wpbody-content {
/* Prevent double-scrollbar issue on WooCommerce > Extension pages */
overflow: hidden;
}
.woocommerce-layout__primary {
margin: 0;
margin: $header-height-mobile 0 0;
@media (min-width: $breakpoint-medium) {
margin-top: $header-height-desktop;
}
}
.woocommerce-layout__main {
padding: 0;
}
/* On marketplace pages, reposition store alerts so they don't collide with other components */
.woocommerce-store-alerts {
margin-left: 16px;
margin-right: 16px;
@media (min-width: $breakpoint-medium) {
margin-left: 32px;
margin-right: 32px;
}
}
}

View File

@ -49,7 +49,7 @@ export const ProductFormActions: React.FC = () => {
const { isDirty, isValidForm, values, resetForm } =
useFormContext< Product >();
useConfirmUnsavedChanges( isDirty, preventLeavingProductForm );
useConfirmUnsavedChanges( isDirty, preventLeavingProductForm() );
useCustomerEffortScoreExitPageTracker(
! values.id ? 'new_product' : 'editing_new_product',

Some files were not shown because too many files have changed in this diff Show More