From b9aa57378bb31a2623d327b59243ddb9affef24b Mon Sep 17 00:00:00 2001 From: Daniil Bratukhin Date: Sat, 7 Dec 2024 04:51:49 -0300 Subject: [PATCH] feat: add `foxy-experimental-add-to-cart-builder` element --- custom-elements.json | 332 +++++++ .../ExperimentalAddToCartBuilder.stories.ts | 28 + .../ExperimentalAddToCartBuilder.ts | 870 ++++++++++++++++++ .../ExperimentalAddToCartBuilder/index.ts | 21 + ...imentalAddToCartBuilderCustomOptionCard.ts | 16 + .../index.ts | 10 + .../types.ts | 10 + ...imentalAddToCartBuilderCustomOptionForm.ts | 138 +++ .../index.ts | 14 + .../types.ts | 10 + ...ExperimentalAddToCartBuilderItemControl.ts | 400 ++++++++ .../index.ts | 26 + .../preview.css.ts | 152 +++ .../ExperimentalAddToCartBuilder/types.ts | 58 ++ src/elements/public/index.defined.ts | 1 + src/elements/public/index.ts | 1 + src/server/hapi/createDataset.ts | 154 +++- src/server/hapi/defaults.ts | 10 + src/server/hapi/links.ts | 4 + src/server/virtual/index.ts | 9 + .../experimental-add-to-cart-builder/en.json | 545 +++++++++++ web-test-runner.groups.js | 4 + 22 files changed, 2810 insertions(+), 3 deletions(-) create mode 100644 src/elements/public/ExperimentalAddToCartBuilder/ExperimentalAddToCartBuilder.stories.ts create mode 100644 src/elements/public/ExperimentalAddToCartBuilder/ExperimentalAddToCartBuilder.ts create mode 100644 src/elements/public/ExperimentalAddToCartBuilder/index.ts create mode 100644 src/elements/public/ExperimentalAddToCartBuilder/internal/InternalExperimentalAddToCartBuilderCustomOptionCard/InternalExperimentalAddToCartBuilderCustomOptionCard.ts create mode 100644 src/elements/public/ExperimentalAddToCartBuilder/internal/InternalExperimentalAddToCartBuilderCustomOptionCard/index.ts create mode 100644 src/elements/public/ExperimentalAddToCartBuilder/internal/InternalExperimentalAddToCartBuilderCustomOptionCard/types.ts create mode 100644 src/elements/public/ExperimentalAddToCartBuilder/internal/InternalExperimentalAddToCartBuilderCustomOptionForm/InternalExperimentalAddToCartBuilderCustomOptionForm.ts create mode 100644 src/elements/public/ExperimentalAddToCartBuilder/internal/InternalExperimentalAddToCartBuilderCustomOptionForm/index.ts create mode 100644 src/elements/public/ExperimentalAddToCartBuilder/internal/InternalExperimentalAddToCartBuilderCustomOptionForm/types.ts create mode 100644 src/elements/public/ExperimentalAddToCartBuilder/internal/InternalExperimentalAddToCartBuilderItemControl/InternalExperimentalAddToCartBuilderItemControl.ts create mode 100644 src/elements/public/ExperimentalAddToCartBuilder/internal/InternalExperimentalAddToCartBuilderItemControl/index.ts create mode 100644 src/elements/public/ExperimentalAddToCartBuilder/preview.css.ts create mode 100644 src/elements/public/ExperimentalAddToCartBuilder/types.ts create mode 100644 src/static/translations/experimental-add-to-cart-builder/en.json diff --git a/custom-elements.json b/custom-elements.json index d760d23f4..56e62ef88 100644 --- a/custom-elements.json +++ b/custom-elements.json @@ -12481,6 +12481,338 @@ } ] }, + { + "name": "foxy-experimental-add-to-cart-builder", + "path": "./src/elements/public/ExperimentalAddToCartBuilder/index.ts", + "description": "WARNING: this element is marked as experimental and is subject to change in future releases.\nWe will not be maintaining backwards compatibility for elements in the experimental namespace.\nIf you are using this element, please make sure to use a fixed version of the package in your `package.json`.\n\nThis element allows you to create an add-to-cart form and link for your store.", + "attributes": [ + { + "name": "default-domain", + "description": "Default host domain for stores that don't use a custom domain name, e.g. `foxycart.com`." + }, + { + "name": "encode-helper", + "description": "URL of the HMAC encoder endpoint." + }, + { + "name": "locale-codes", + "description": "URL of the `fx:locale_codes` property helper. This will be used to determine the currency code." + }, + { + "name": "store", + "description": "URL of the store this add-to-cart code is created for." + }, + { + "name": "simplify-ns-loading", + "type": "boolean", + "default": "false" + }, + { + "name": "ns", + "type": "string", + "default": "\"defaultNS\"" + }, + { + "name": "status", + "description": "Status message to render at the top of the form. If `null`, the message is hidden.", + "type": "object" + }, + { + "name": "mode", + "type": "string", + "default": "\"production\"" + }, + { + "name": "readonly", + "type": "boolean", + "default": "false" + }, + { + "name": "readonlycontrols", + "default": "\"False\"" + }, + { + "name": "disabled", + "type": "boolean", + "default": "false" + }, + { + "name": "disabledcontrols", + "default": "\"False\"" + }, + { + "name": "hidden", + "type": "boolean", + "default": "false" + }, + { + "name": "hiddencontrols", + "default": "\"False\"" + }, + { + "name": "lang", + "description": "Optional ISO 639-1 code describing the language element content is written in.\nChanging the `lang` attribute will update the value of this property.", + "type": "string", + "default": "\"\"" + }, + { + "name": "parent", + "description": "Optional URL of the collection this element's resource belongs to.\nChanging the `parent` attribute will update the value of this property.", + "type": "string", + "default": "\"\"" + }, + { + "name": "related", + "description": "Optional URI list of the related resources. If Rumour encounters a related\nresource on creation or deletion, it will be reloaded from source.", + "type": "array", + "default": "[]" + }, + { + "name": "virtual-host", + "description": "Unique identifier for the virtual host used by the element to serve internal requests.\n\nCurrently only one endpoint is supported: `foxy:///form/`.\nThis endpoint supports POST, GET, PATCH and DELETE methods and functions like a hAPI server.\n\nFor example, `GET foxy://nucleon-123/form/subscriptions/allowNextDateModification/0` on a NucleonElement\nwith `fx:customer_portal_settings` will return the first item of the `subscriptions.allowNextDateModification` array.", + "default": "\"uniqueId('nucleon-')\"" + }, + { + "name": "group", + "description": "Rumour group. Elements in different groups will not share updates. Empty by default.", + "type": "string" + }, + { + "name": "href", + "description": "Optional URL of the resource to load. Switches element to `idle.template` state if empty (default).", + "type": "string" + }, + { + "name": "infer", + "description": "Set a name for this element here to enable property inference. Set to `null` to disable.", + "type": "string" + } + ], + "properties": [ + { + "name": "defaultDomain", + "attribute": "default-domain", + "description": "Default host domain for stores that don't use a custom domain name, e.g. `foxycart.com`." + }, + { + "name": "encodeHelper", + "attribute": "encode-helper", + "description": "URL of the HMAC encoder endpoint." + }, + { + "name": "localeCodes", + "attribute": "locale-codes", + "description": "URL of the `fx:locale_codes` property helper. This will be used to determine the currency code." + }, + { + "name": "store", + "attribute": "store", + "description": "URL of the store this add-to-cart code is created for." + }, + { + "name": "simplifyNsLoading", + "attribute": "simplify-ns-loading", + "type": "boolean", + "default": "false" + }, + { + "name": "ns", + "attribute": "ns", + "type": "string", + "default": "\"defaultNS\"" + }, + { + "name": "t", + "type": "Translator", + "default": "\"(key, options) => {\\n const I18nElement = customElements.get('foxy-i18n') as typeof I18n | undefined;\\n\\n if (!I18nElement) return key;\\n\\n let keys: string[];\\n\\n if (this.simplifyNsLoading) {\\n const namespaces = this.ns.split(' ').filter(v => v.length > 0);\\n const path = [...namespaces.slice(1), key].join('.');\\n keys = namespaces[0] ? [`${namespaces[0]}:${path}`] : [path];\\n } else {\\n keys = this.ns\\n .split(' ')\\n .reverse()\\n .map(v => v.trim())\\n .filter(v => v.length > 0)\\n .reverse()\\n .map((v, i, a) => `${v}:${[...a.slice(i + 1), key].join('.')}`);\\n }\\n\\n keys.push(key);\\n\\n return I18nElement.i18next.t(keys, { lng: this.lang, ...options }).toString();\\n }\"" + }, + { + "name": "generalErrorPrefix", + "description": "Validation errors with this prefix will show up at the top of the form.", + "type": "string", + "default": "\"error:\"" + }, + { + "name": "status", + "attribute": "status", + "description": "Status message to render at the top of the form. If `null`, the message is hidden.", + "type": "object" + }, + { + "name": "headerTitleKey", + "description": "Getter that returns a i18n key for the optional form header title.", + "type": "string" + }, + { + "name": "headerTitleOptions", + "description": "I18next options to pass to the header title translation function.", + "type": "Record" + }, + { + "name": "headerSubtitleKey", + "description": "Getter that returns a i18n key for the optional form header subtitle. Note that subtitle is shown only when data is avaiable.", + "type": "string" + }, + { + "name": "headerSubtitleOptions", + "description": "I18next options to pass to the header subtitle translation function. Note that subtitle is shown only when data is avaiable.", + "type": "Record" + }, + { + "name": "headerCopyIdValue", + "description": "ID that will be written to clipboard when Copy ID button in header is clicked.", + "type": "string | number" + }, + { + "name": "templates", + "default": "{}" + }, + { + "name": "mode", + "attribute": "mode", + "type": "string", + "default": "\"production\"" + }, + { + "name": "readonly", + "attribute": "readonly", + "type": "boolean", + "default": "false" + }, + { + "name": "readonlyControls", + "attribute": "readonlycontrols", + "default": "\"False\"" + }, + { + "name": "disabled", + "attribute": "disabled", + "type": "boolean", + "default": "false" + }, + { + "name": "disabledControls", + "attribute": "disabledcontrols", + "default": "\"False\"" + }, + { + "name": "hidden", + "attribute": "hidden", + "type": "boolean", + "default": "false" + }, + { + "name": "hiddenControls", + "attribute": "hiddencontrols", + "default": "\"False\"" + }, + { + "name": "readonlySelector", + "type": "BooleanSelector" + }, + { + "name": "disabledSelector", + "type": "BooleanSelector" + }, + { + "name": "hiddenSelector", + "type": "BooleanSelector" + }, + { + "name": "UpdateEvent", + "description": "Instances of this event are dispatched on an element whenever it changes its\nstate (e.g. when going from `busy` to `idle` or on `form` data change).\nThis event isn't cancelable, and it does not bubble.", + "type": "typeof UpdateEvent", + "default": "\"UpdateEvent\"" + }, + { + "name": "Rumour", + "description": "Creates a tagged [Rumour](https://sdk.foxy.dev/classes/_core_index_.rumour.html)\ninstance if it doesn't exist or returns cached one otherwise. NucleonElements\nuse empty Rumour group by default.", + "type": "((group: string) => Rumour) & MemoizedFunction", + "default": "\"memoize<(group: string) => Rumour>(() => new Rumour())\"" + }, + { + "name": "API", + "description": "Universal [API](https://sdk.foxy.dev/classes/_core_index_.api.html) client\nthat dispatches cancellable `FetchEvent` on an element before each request.", + "type": "typeof API", + "default": "\"API\"" + }, + { + "name": "lang", + "attribute": "lang", + "description": "Optional ISO 639-1 code describing the language element content is written in.\nChanging the `lang` attribute will update the value of this property.", + "type": "string", + "default": "\"\"" + }, + { + "name": "parent", + "attribute": "parent", + "description": "Optional URL of the collection this element's resource belongs to.\nChanging the `parent` attribute will update the value of this property.", + "type": "string", + "default": "\"\"" + }, + { + "name": "related", + "attribute": "related", + "description": "Optional URI list of the related resources. If Rumour encounters a related\nresource on creation or deletion, it will be reloaded from source.", + "type": "array", + "default": "[]" + }, + { + "name": "virtualHost", + "attribute": "virtual-host", + "description": "Unique identifier for the virtual host used by the element to serve internal requests.\n\nCurrently only one endpoint is supported: `foxy:///form/`.\nThis endpoint supports POST, GET, PATCH and DELETE methods and functions like a hAPI server.\n\nFor example, `GET foxy://nucleon-123/form/subscriptions/allowNextDateModification/0` on a NucleonElement\nwith `fx:customer_portal_settings` will return the first item of the `subscriptions.allowNextDateModification` array.", + "default": "\"uniqueId('nucleon-')\"" + }, + { + "name": "failure", + "description": "If network request returns non-2XX code, the entire error response\nwill be available via this getter.\n\nThis property is readonly. Changing failure records via this property is\nnot guaranteed to work. NucleonElement does not provide a way to override error status.", + "type": "Response | null" + }, + { + "name": "errors", + "description": "Array of validation errors returned from `NucleonElement.v8n` checks.\n\nThis property is readonly. Adding or removing error codes via this property is\nnot guaranteed to work. NucleonElement does not provide a way to override validity status.", + "type": "string[]" + }, + { + "name": "form", + "description": "Resource snapshot with edits applied. Empty object if unavailable.\n\nThis property and its value are readonly. Assignments like `element.data.foo = 'bar'`\nare not guaranteed to work. Please use `element.edit({ foo: 'bar' })` instead.\nIf you need to replace the entire data object, consider using `element.data`.", + "type": "Partial" + }, + { + "name": "data", + "description": "Resource snapshot as-is, no edits applied. Null if unavailable.\n\nReturned value is not reactive. Assignments like `element.data.foo = 'bar'`\nare not guaranteed to work. Please set the property instead: `element.data = { ...element.data, foo: 'bar' }`.\nIf you're processing user input, consider using `element.form` and `element.edit()` instead.", + "type": "TData | null" + }, + { + "name": "group", + "attribute": "group", + "description": "Rumour group. Elements in different groups will not share updates. Empty by default.", + "type": "string" + }, + { + "name": "href", + "attribute": "href", + "description": "Optional URL of the resource to load. Switches element to `idle.template` state if empty (default).", + "type": "string" + }, + { + "name": "infer", + "attribute": "infer", + "description": "Set a name for this element here to enable property inference. Set to `null` to disable.", + "type": "string" + } + ], + "events": [ + { + "name": "update", + "description": "Instance of `NucleonElement.UpdateEvent`. Dispatched on an element whenever it changes its state." + }, + { + "name": "fetch", + "description": "Instance of `NucleonElement.API.FetchEvent`. Emitted before each API request." + } + ] + }, { "name": "foxy-filter-attribute-card", "path": "./src/elements/public/FilterAttributeCard/index.ts", diff --git a/src/elements/public/ExperimentalAddToCartBuilder/ExperimentalAddToCartBuilder.stories.ts b/src/elements/public/ExperimentalAddToCartBuilder/ExperimentalAddToCartBuilder.stories.ts new file mode 100644 index 000000000..7fa4eb883 --- /dev/null +++ b/src/elements/public/ExperimentalAddToCartBuilder/ExperimentalAddToCartBuilder.stories.ts @@ -0,0 +1,28 @@ +import './index'; + +import { Summary } from '../../../storygen/Summary'; +import { getMeta } from '../../../storygen/getMeta'; +import { getStory } from '../../../storygen/getStory'; + +const summary: Summary = { + href: 'https://demo.api/hapi/experimental_add_to_cart_snippets/0', + parent: 'https://demo.api/hapi/experimental_add_to_cart_snippets', + nucleon: true, + localName: 'foxy-experimental-add-to-cart-builder', + translatable: true, + configurable: {}, +}; + +export default getMeta(summary); + +const ext = ` + default-domain="foxycart.com" + encode-helper="https://demo.api/virtual/encode" + locale-codes="https://demo.api/hapi/property_helpers/7" + store="https://demo.api/hapi/stores/0" +`; + +export const WithData = getStory({ ...summary, ext, code: true }); +export const Empty = getStory({ ...summary, ext }); + +Empty.args.href = ''; diff --git a/src/elements/public/ExperimentalAddToCartBuilder/ExperimentalAddToCartBuilder.ts b/src/elements/public/ExperimentalAddToCartBuilder/ExperimentalAddToCartBuilder.ts new file mode 100644 index 000000000..52b1a7d11 --- /dev/null +++ b/src/elements/public/ExperimentalAddToCartBuilder/ExperimentalAddToCartBuilder.ts @@ -0,0 +1,870 @@ +import type { PropertyDeclarations, TemplateResult } from 'lit-element'; +import type { InternalSummaryControl } from '../../internal/InternalSummaryControl/InternalSummaryControl'; +import type { NucleonElement } from '../NucleonElement/NucleonElement'; +import type { Resource } from '@foxy.io/sdk/core'; +import type { Rels } from '@foxy.io/sdk/backend'; +import type { Data } from './types'; + +import { TranslatableMixin } from '../../../mixins/translatable'; +import { ResponsiveMixin } from '../../../mixins/responsive'; +import { decode, encode } from 'html-entities'; +import { InternalForm } from '../../internal/InternalForm/InternalForm'; +import { previewCSS } from './preview.css'; +import { html, svg } from 'lit-element'; +import { ifDefined } from 'lit-html/directives/if-defined'; +import { classMap } from '../../../utils/class-map'; + +import debounce from 'lodash-es/debounce'; + +const NS = 'experimental-add-to-cart-builder'; +const Base = ResponsiveMixin(TranslatableMixin(InternalForm, NS)); + +/** + * WARNING: this element is marked as experimental and is subject to change in future releases. + * We will not be maintaining backwards compatibility for elements in the experimental namespace. + * If you are using this element, please make sure to use a fixed version of the package in your `package.json`. + * + * This element allows you to create an add-to-cart form and link for your store. + * + * @element foxy-experimental-add-to-cart-builder + */ +export class ExperimentalAddToCartBuilder extends Base { + static get properties(): PropertyDeclarations { + return { + ...super.properties, + defaultDomain: { attribute: 'default-domain' }, + encodeHelper: { attribute: 'encode-helper' }, + localeCodes: { attribute: 'locale-codes' }, + store: {}, + __previousUnsignedCode: { attribute: false }, + __previousSignedCode: { attribute: false }, + __signingState: { attribute: false }, + __openState: { attribute: false }, + }; + } + + /** Default host domain for stores that don't use a custom domain name, e.g. `foxycart.com`. */ + defaultDomain: string | null = null; + + /** URL of the HMAC encoder endpoint. */ + encodeHelper: string | null = null; + + /** URL of the `fx:locale_codes` property helper. This will be used to determine the currency code. */ + localeCodes: string | null = null; + + /** URL of the store this add-to-cart code is created for. */ + store: string | null = null; + + private readonly __signingSeparator = `--${Date.now()}${(Math.random() * 100000).toFixed(0)}--`; + + private readonly __signAsync = debounce(async (html: string, encodeHelper: string) => { + if (html === this.__previousUnsignedCode && this.__previousSignedCode) return; + + const isCancelled = () => html !== this.__previousUnsignedCode; + this.__signingState = 'busy'; + + try { + const res = await new ExperimentalAddToCartBuilder.API(this).fetch(encodeHelper, { + headers: { 'Content-Type': 'text/plain' }, + method: 'POST', + body: html, + }); + + if (!isCancelled()) { + if (res.ok) { + const result = (await res.json()).result as string; + if (!isCancelled()) { + this.__signingState = 'idle'; + this.__previousSignedCode = result.replace(/value="--OPEN--" data-replace/gi, 'value'); + } + } else { + this.__signingState = 'fail'; + } + } + } catch { + if (!isCancelled()) this.__signingState = 'fail'; + } + }, 500); + + private __previousUnsignedCode = ''; + + private __previousSignedCode = ''; + + private __signingState: 'idle' | 'busy' | 'fail' = 'idle'; + + private __openState: boolean[] = []; + + renderBody(): TemplateResult { + const addToCartCode = this.__getAddToCartCode(); + const storeUrl = this.data?._links['fx:store'].href ?? this.store ?? void 0; + const store = this.__storeLoader?.data; + + return html` +
+
+ ${this.form.items?.map((product, index) => { + return html` + { + const details = evt.currentTarget as InternalSummaryControl; + this.__openState[index] = details.open; + this.__openState = [...this.__openState]; + }} + > + { + const newProducts = this.form.items?.filter((_, i) => i !== index); + this.edit({ items: newProducts }); + this.__openState = this.__openState.filter((_, i) => i !== index); + }} + > + + + `; + })} + + { + const newItem = { name: '', price: 0, custom_options: [] }; + const existingItems = this.form.items ?? []; + this.edit({ items: [...existingItems, newItem] }); + this.__openState = [...new Array(existingItems.length).fill(false), true]; + }} + > + + +
+ +
+ ${addToCartCode + ? html` + +
+ +
+ +
+
+ ${addToCartCode.formHTML} +
+ +
+
+ + +
+
+ + + +
+ +
+ ${svg``} +

+ + + + +

+
+
+ + +
+ + + + ${addToCartCode.linkHref} + + +
+
+ + +
+
+ + + +
+
+ ` + : html` + +
+ +
+
+ `} + + + + + + + + + + + +
+
+ + + + + + + + + + ${this.form.items?.map((product, index) => { + return html` + + + ${product.custom_options.map((option, i) => { + return html` + + `; + })} + `; + })} + `; + } + + updated(changes: Map): void { + super.updated(changes); + + if (this.in('idle') && !this.form.items?.length) { + this.edit({ items: [{ name: '', price: 0, custom_options: [] }] }); + this.__openState = [true]; + } + + if (this.form.items?.length && !this.__openState.length) { + this.__openState = [true, ...new Array(this.form.items.length - 1).fill(false)]; + } + } + + submit(): void { + // Do nothing – in this version of the element, this form is not meant to be submitted. + } + + private get __defaultTemplateSetHref() { + try { + const url = new URL(this.__storeLoader?.data?._links['fx:template_sets'].href ?? ''); + url.searchParams.set('code', 'DEFAULT'); + return url.toString(); + } catch { + return undefined; + } + } + + private get __resolvedCurrencyCode() { + type Loader = NucleonElement>; + + const localeCodesLoader = this.renderRoot.querySelector('#localeCodesHelperLoader'); + const currentLocale = this.__resolvedTemplateSet?.locale_code; + const allLocales = localeCodesLoader?.data?.values; + const localeInfo = currentLocale ? allLocales?.[currentLocale] : void 0; + + return localeInfo ? /Currency: ([A-Z]{3})/g.exec(localeInfo)?.[1]?.toUpperCase() : void 0; + } + + private get __resolvedTemplateSet() { + type TemplateSetsLoader = NucleonElement>; + type TemplateSetLoader = NucleonElement>; + const $ = (s: string) => this.renderRoot.querySelector(s); + + return ( + $('#templateSetLoader')?.data ?? + $('#defaultTemplateSetLoader')?.data?._embedded['fx:template_sets'][0] + ); + } + + private get __resolvedCartUrl() { + const store = this.__storeLoader?.data; + + if (store) { + if (store.use_remote_domain) { + return `https://${store.store_domain}/cart`; + } else if (this.defaultDomain !== null) { + return `https://${store.store_domain}.${this.defaultDomain}/cart`; + } + } + + return null; + } + + private get __storeLoader() { + type Loader = NucleonElement>; + return this.renderRoot.querySelector('#storeLoaderId'); + } + + private __getItemCategoryLoader(productIndex: number, optionIndex?: number) { + type Loader = NucleonElement>; + const prefix = `#itemCategoryLoaderProduct${productIndex}`; + const selector = typeof optionIndex === 'number' ? `${prefix}Option${optionIndex}` : prefix; + return this.renderRoot.querySelector(selector); + } + + private __getAddToCartFormHTML() { + const currencyCode = this.__resolvedCurrencyCode; + const templateSet = this.__resolvedTemplateSet; + const cartUrl = this.__resolvedCartUrl; + const store = this.__storeLoader?.data; + + if (!this.defaultDomain || !templateSet || !store || !currencyCode || !cartUrl) return ''; + + let output = `
`; + let level = 1; + + const newline = () => `\n${' '.repeat(level * 2)}`; + const addHiddenInput = (name: string, value: string) => { + const encodedValue = encode(value); + const encodedName = encode(name); + output += `${newline()}`; + }; + + if (templateSet.code !== 'DEFAULT') addHiddenInput('template_set', templateSet.code); + if (this.form.empty) addHiddenInput('empty', this.form.empty); + if (this.form.cart === 'checkout') addHiddenInput('cart', 'checkout'); + + const items = this.form.items ?? []; + const hasMoreThanOneProduct = items.length > 1; + + for (let productIndex = 0; productIndex < items.length; ++productIndex) { + const itemCategoryLoader = this.__getItemCategoryLoader(productIndex); + const itemCategory = itemCategoryLoader?.data; + const product = items[productIndex]; + + if (!product.name || !product.price) return ''; + if (product.item_category_uri && !itemCategory) return ''; + + const hasConfigurableQuantity = product.quantity_min !== product.quantity_max; + const hasConfigurablePrice = product.price_configurable; + const hasConfigurableOptions = product.custom_options.some( + (v, i, a) => v.value_configurable || a.findIndex(vv => vv.name === v.name) !== i + ); + + const useFieldset = + hasMoreThanOneProduct && + (hasConfigurablePrice || hasConfigurableQuantity || hasConfigurableOptions); + + if (useFieldset) { + output += `${newline()}
`; + level++; + output += `${newline()}${encode(product.name)}`; + } + + const prefix = productIndex === 0 ? '' : `${productIndex + 1}:`; + addHiddenInput(`${prefix}name`, product.name); + const price = `${product.price}${currencyCode}`; + + if (product.price_configurable) { + const encodedPrice = encode(price); + output += `${newline()}`; + } else { + addHiddenInput(`${prefix}price`, price); + } + + if (itemCategory && itemCategory.code !== 'DEFAULT') { + addHiddenInput(`${prefix}category`, itemCategory.code); + } + + if (product.code) addHiddenInput(`${prefix}code`, product.code); + if (product.parent_code) addHiddenInput(`${prefix}parent_code`, product.parent_code); + + if (product.image) { + addHiddenInput(`${prefix}image`, product.image); + if (product.url) addHiddenInput(`${prefix}url`, product.url); + } + + if (product.sub_enabled) { + if (product.sub_frequency) { + addHiddenInput(`${prefix}sub_frequency`, product.sub_frequency); + + if (product.sub_startdate) { + if (product.sub_startdate_format === 'yyyymmdd') { + const date = new Date(product.sub_startdate); + const year = date.getFullYear(); + const month = (date.getMonth() + 1).toString().padStart(2, '0'); + const day = date.getDate().toString().padStart(2, '0'); + addHiddenInput(`${prefix}sub_startdate`, `${year}${month}${day}`); + } else { + addHiddenInput(`${prefix}sub_startdate`, String(product.sub_startdate)); + } + } + + if (product.sub_enddate) { + if (product.sub_enddate_format === 'yyyymmdd') { + const date = new Date(product.sub_enddate); + const year = date.getFullYear(); + const month = (date.getMonth() + 1).toString().padStart(2, '0'); + const day = date.getDate().toString().padStart(2, '0'); + addHiddenInput(`${prefix}sub_enddate`, `${year}${month}${day}`); + } else { + addHiddenInput(`${prefix}sub_enddate`, product.sub_enddate); + } + } + } + } + + if (product.discount_name && product.discount_type && product.discount_details) { + addHiddenInput( + `${prefix}discount_${product.discount_type}`, + `${product.discount_name}{${product.discount_details}}` + ); + } + + if (product.expires_value) { + if (product.expires_format === 'timestamp') { + addHiddenInput(`${prefix}expires`, (product.expires_value / 1000).toFixed(0)); + } else { + addHiddenInput(`${prefix}expires`, product.expires_value.toFixed(0)); + } + } + + if (product.quantity_min || product.quantity_max) { + output += `${newline()}`; + } else if ((product.quantity ?? 1) > 1) { + addHiddenInput(`${prefix}quantity`, (product.quantity ?? 1).toString()); + } + + if (product.expires_format !== 'minutes') { + if (product.quantity_min) { + addHiddenInput(`${prefix}quantity_min`, product.quantity_min.toFixed(0)); + } + if (product.quantity_max) { + addHiddenInput(`${prefix}quantity_max`, product.quantity_max.toFixed(0)); + } + } + + if (product.length) addHiddenInput(`${prefix}length`, product.length.toFixed(3)); + if (product.width) addHiddenInput(`${prefix}width`, product.width.toFixed(3)); + if (product.height) addHiddenInput(`${prefix}height`, product.height.toFixed(3)); + if (product.weight) addHiddenInput(`${prefix}weight`, product.weight.toFixed(3)); + + if (store.features_multiship) { + output += `${newline()}`; + } + + const groupedCustomOptions = product.custom_options.reduce((acc, option) => { + if (!acc[option.name]) acc[option.name] = []; + acc[option.name].push(option); + return acc; + }, {} as Record); + + for (const optionName in groupedCustomOptions) { + const group = groupedCustomOptions[optionName]; + + if (group.length === 1) { + const optionIndex = product.custom_options.indexOf(group[0]); + const itemCategory = this.__getItemCategoryLoader(productIndex, optionIndex)?.data; + const modifiers = this.__getOptionModifiers(group[0], itemCategory ?? null, currencyCode); + const value = `${group[0].value}${modifiers}`; + const name = `${prefix}${optionName}`; + + if (group[0].value_configurable) { + output += `${newline()}`; + } else { + addHiddenInput(name, value); + } + } else { + output += `${newline()}`; + } + } + + if (useFieldset) { + level--; + output += `${newline()}
`; + } + } + + const encodedSubmitCaption = encode(this.t('preview.submit_caption')); + output += `${newline()}`; + level--; + output += `${newline()}
`; + + return output; + } + + private __getAddToCartLinkHref() { + const currencyCode = this.__resolvedCurrencyCode; + const templateSet = this.__resolvedTemplateSet; + const cartUrl = this.__resolvedCartUrl; + const store = this.__storeLoader?.data; + + if (!this.defaultDomain || !templateSet || !store || !currencyCode || !cartUrl) return ''; + + const url = new URL(cartUrl); + + if (templateSet.code !== 'DEFAULT') url.searchParams.set('template_set', templateSet.code); + if (this.form.empty) url.searchParams.set('empty', this.form.empty); + if (this.form.cart === 'checkout') url.searchParams.set('cart', 'checkout'); + + for (let index = 0; index < (this.form.items?.length ?? 0); ++index) { + const product = this.form.items![index]; + const prefix = index === 0 ? '' : `${index + 1}:`; + const itemCategory = this.__getItemCategoryLoader(index)?.data; + + if (product.item_category_uri && !itemCategory) return ''; + + if (itemCategory && itemCategory.code !== 'DEFAULT') { + url.searchParams.set(`${prefix}category`, itemCategory.code); + } + + url.searchParams.set(`${prefix}name`, product.name); + url.searchParams.set(`${prefix}price`, `${product.price}${currencyCode}`); + + if (product.code) url.searchParams.set(`${prefix}code`, product.code); + if (product.parent_code) url.searchParams.set(`${prefix}parent_code`, product.parent_code); + + if (product.image) { + url.searchParams.set(`${prefix}image`, product.image); + if (product.url) url.searchParams.set(`${prefix}url`, product.url); + } + + if (product.sub_enabled) { + if (product.sub_frequency) { + url.searchParams.set(`${prefix}sub_frequency`, product.sub_frequency); + + if (product.sub_startdate) { + if (product.sub_startdate_format === 'yyyymmdd') { + const date = new Date(product.sub_startdate); + const year = date.getFullYear(); + const month = (date.getMonth() + 1).toString().padStart(2, '0'); + const day = date.getDate().toString().padStart(2, '0'); + url.searchParams.set(`${prefix}sub_startdate`, `${year}${month}${day}`); + } else { + url.searchParams.set(`${prefix}sub_startdate`, String(product.sub_startdate)); + } + } + + if (product.sub_enddate) { + if (product.sub_enddate_format === 'yyyymmdd') { + const date = new Date(product.sub_enddate); + const year = date.getFullYear(); + const month = (date.getMonth() + 1).toString().padStart(2, '0'); + const day = date.getDate().toString().padStart(2, '0'); + url.searchParams.set(`${prefix}sub_enddate`, `${year}${month}${day}`); + } else { + url.searchParams.set(`${prefix}sub_enddate`, product.sub_enddate); + } + } + } + } + + if (product.discount_name && product.discount_type && product.discount_details) { + url.searchParams.set( + `${prefix}discount_${product.discount_type}`, + `${product.discount_name}{${product.discount_details}}` + ); + } + + if (product.expires_value) { + if (product.expires_format === 'timestamp') { + url.searchParams.set(`${prefix}expires`, (product.expires_value / 1000).toFixed(0)); + } else { + url.searchParams.set(`${prefix}expires`, product.expires_value.toFixed(0)); + } + } + + if ((product.quantity ?? 1) > 1) { + url.searchParams.set(`${prefix}quantity`, (product.quantity ?? 1).toFixed(0)); + } + + if (product.expires_format !== 'minutes') { + if (product.quantity_min) { + url.searchParams.set(`${prefix}quantity_min`, product.quantity_min.toString()); + } + if (product.quantity_max) { + url.searchParams.set(`${prefix}quantity_max`, product.quantity_max.toString()); + } + } + + if (product.weight) url.searchParams.set(`${prefix}weight`, product.weight.toFixed(3)); + if (product.length) url.searchParams.set(`${prefix}length`, product.length.toFixed(3)); + if (product.width) url.searchParams.set(`${prefix}width`, product.width.toFixed(3)); + if (product.height) url.searchParams.set(`${prefix}height`, product.height.toFixed(3)); + + product.custom_options.forEach((option, optionIndex) => { + const itemCategory = this.__getItemCategoryLoader(index, optionIndex)?.data; + const modifiers = this.__getOptionModifiers(option, itemCategory ?? null, currencyCode); + url.searchParams.set(`${prefix}${option.name}`, `${option.value ?? ''}${modifiers}`); + }); + } + + return url.toString(); + } + + private __getAddToCartCode() { + const store = this.__storeLoader?.data; + if (!this.encodeHelper || !store) return null; + + const formHTML = this.__getAddToCartFormHTML(); + const linkHref = this.__getAddToCartLinkHref(); + if (!formHTML || !linkHref) return null; + + const linkHTML = `Add to cart`; + const unsignedCode = `${formHTML}${this.__signingSeparator}${linkHTML}`; + + if (unsignedCode === this.__previousUnsignedCode && this.__previousSignedCode) { + const signedCode = this.__previousSignedCode.split(this.__signingSeparator); + return { + formHTML: signedCode[0], + linkHref: decode(signedCode[1].substring(9, signedCode[1].length - 17)), + }; + } + + this.__previousUnsignedCode = unsignedCode; + this.__previousSignedCode = ''; + if (store.use_cart_validation) this.__signAsync(unsignedCode, this.encodeHelper); + return { formHTML, linkHref }; + } + + private __getOptionModifiers( + option: Data['items'][number]['custom_options'][number], + optionItemCategory: Resource | null, + currencyCode: string + ) { + if (option.value_configurable) return ''; + const modifiers = []; + + if (option.price) { + const operator = option.replace_price ? ':' : Math.sign(option.price) === -1 ? '-' : '+'; + if (option.replace_price || option.price !== 0) + modifiers.push(`p${operator}${Math.abs(option.price)}${currencyCode}`); + } + + if (option.weight) { + const operator = option.replace_weight ? ':' : Math.sign(option.weight) === -1 ? '-' : '+'; + if (option.replace_weight || option.weight !== 0) + modifiers.push(`w${operator}${Math.abs(option.weight)}`); + } + + if (option.item_category_uri && !optionItemCategory) return ''; + if (optionItemCategory) modifiers.push(`y:${optionItemCategory.code}`); + + if (option.code) { + const operator = option.replace_code ? ':' : '+'; + if (option.replace_code || option.code !== '') modifiers.push(`c${operator}${option.code}`); + } + + return modifiers.length ? `{${modifiers.join('|')}}` : ''; + } +} diff --git a/src/elements/public/ExperimentalAddToCartBuilder/index.ts b/src/elements/public/ExperimentalAddToCartBuilder/index.ts new file mode 100644 index 000000000..be8c76bbf --- /dev/null +++ b/src/elements/public/ExperimentalAddToCartBuilder/index.ts @@ -0,0 +1,21 @@ +import '@vaadin/vaadin-button'; + +import '../../internal/InternalResourcePickerControl/index'; +import '../../internal/InternalSummaryControl/index'; +import '../../internal/InternalSwitchControl/index'; +import '../../internal/InternalSelectControl/index'; +import '../../internal/InternalForm/index'; + +import '../TemplateSetCard/index'; +import '../CopyToClipboard/index'; +import '../NucleonElement/index'; +import '../Spinner/index'; +import '../I18n/index'; + +import './internal/InternalExperimentalAddToCartBuilderItemControl/index'; + +import { ExperimentalAddToCartBuilder } from './ExperimentalAddToCartBuilder'; + +customElements.define('foxy-experimental-add-to-cart-builder', ExperimentalAddToCartBuilder); + +export { ExperimentalAddToCartBuilder }; diff --git a/src/elements/public/ExperimentalAddToCartBuilder/internal/InternalExperimentalAddToCartBuilderCustomOptionCard/InternalExperimentalAddToCartBuilderCustomOptionCard.ts b/src/elements/public/ExperimentalAddToCartBuilder/internal/InternalExperimentalAddToCartBuilderCustomOptionCard/InternalExperimentalAddToCartBuilderCustomOptionCard.ts new file mode 100644 index 000000000..9e0b3c404 --- /dev/null +++ b/src/elements/public/ExperimentalAddToCartBuilder/internal/InternalExperimentalAddToCartBuilderCustomOptionCard/InternalExperimentalAddToCartBuilderCustomOptionCard.ts @@ -0,0 +1,16 @@ +import type { TemplateResult } from 'lit-html'; +import type { Data } from './types'; + +import { InternalCard } from '../../../../internal/InternalCard/InternalCard'; +import { html } from 'lit-html'; + +export class InternalExperimentalAddToCartBuilderCustomOptionCard extends InternalCard { + renderBody(): TemplateResult { + return html` +
+ ${this.data?.name} + ${this.data?.value} +
+ `; + } +} diff --git a/src/elements/public/ExperimentalAddToCartBuilder/internal/InternalExperimentalAddToCartBuilderCustomOptionCard/index.ts b/src/elements/public/ExperimentalAddToCartBuilder/internal/InternalExperimentalAddToCartBuilderCustomOptionCard/index.ts new file mode 100644 index 000000000..ce475815a --- /dev/null +++ b/src/elements/public/ExperimentalAddToCartBuilder/internal/InternalExperimentalAddToCartBuilderCustomOptionCard/index.ts @@ -0,0 +1,10 @@ +import '../../../../internal/InternalCard/index'; + +import { InternalExperimentalAddToCartBuilderCustomOptionCard } from './InternalExperimentalAddToCartBuilderCustomOptionCard'; + +customElements.define( + 'foxy-internal-experimental-add-to-cart-builder-custom-option-card', + InternalExperimentalAddToCartBuilderCustomOptionCard +); + +export { InternalExperimentalAddToCartBuilderCustomOptionCard }; diff --git a/src/elements/public/ExperimentalAddToCartBuilder/internal/InternalExperimentalAddToCartBuilderCustomOptionCard/types.ts b/src/elements/public/ExperimentalAddToCartBuilder/internal/InternalExperimentalAddToCartBuilderCustomOptionCard/types.ts new file mode 100644 index 000000000..7d4df9761 --- /dev/null +++ b/src/elements/public/ExperimentalAddToCartBuilder/internal/InternalExperimentalAddToCartBuilderCustomOptionCard/types.ts @@ -0,0 +1,10 @@ +import type { ExperimentalAddToCartSnippet } from '../../types'; +import type { Graph, Resource } from '@foxy.io/sdk/core'; + +interface ExperimentalAddToCartSnippetCustomOption extends Graph { + curie: 'fx:experimental_add_to_cart_snippet_custom_option'; + links: { self: ExperimentalAddToCartSnippetCustomOption }; + props: ExperimentalAddToCartSnippet['props']['items'][number]['custom_options'][number]; +} + +export type Data = Resource; diff --git a/src/elements/public/ExperimentalAddToCartBuilder/internal/InternalExperimentalAddToCartBuilderCustomOptionForm/InternalExperimentalAddToCartBuilderCustomOptionForm.ts b/src/elements/public/ExperimentalAddToCartBuilder/internal/InternalExperimentalAddToCartBuilderCustomOptionForm/InternalExperimentalAddToCartBuilderCustomOptionForm.ts new file mode 100644 index 000000000..3f28d0d35 --- /dev/null +++ b/src/elements/public/ExperimentalAddToCartBuilder/internal/InternalExperimentalAddToCartBuilderCustomOptionForm/InternalExperimentalAddToCartBuilderCustomOptionForm.ts @@ -0,0 +1,138 @@ +import type { PropertyDeclarations, TemplateResult } from 'lit-element'; +import type { NucleonElement } from '../../../NucleonElement/NucleonElement'; +import type { Resource } from '@foxy.io/sdk/core'; +import type { Rels } from '@foxy.io/sdk/backend'; +import type { Data } from './types'; + +import { TranslatableMixin } from '../../../../../mixins/translatable'; +import { BooleanSelector } from '@foxy.io/sdk/core'; +import { InternalForm } from '../../../../internal/InternalForm/InternalForm'; +import { ifDefined } from 'lit-html/directives/if-defined'; +import { html } from 'lit-html'; + +const NS = 'internal-experimental-add-to-cart-builder-custom-option-form'; +const Base = TranslatableMixin(InternalForm, NS); + +export class InternalExperimentalAddToCartBuilderCustomOptionForm extends Base { + static get properties(): PropertyDeclarations { + return { + ...super.properties, + defaultWeightUnit: { attribute: 'default-weight-unit' }, + existingOptions: { type: Array, attribute: 'existing-options' }, + itemCategories: { attribute: 'item-categories' }, + currencyCode: { attribute: 'currency-code' }, + }; + } + + defaultWeightUnit: string | null = null; + + existingOptions: Data[] = []; + + itemCategories: string | null = null; + + currencyCode: string | null = null; + + get readonlySelector(): BooleanSelector { + const alwaysMatch = [super.readonlySelector.toString()]; + if (this.data) alwaysMatch.unshift('basics-group'); + return new BooleanSelector(alwaysMatch.join(' ').trim()); + } + + get disabledSelector(): BooleanSelector { + const alwaysMatch = [super.disabledSelector.toString()]; + + if (!this.data) { + if (this.existingOptions.some(o => o.name === this.form.name)) { + alwaysMatch.unshift('basics-group:value-configurable'); + } + } + + return new BooleanSelector(alwaysMatch.join(' ').trim()); + } + + renderBody(): TemplateResult { + return html` + + + + + + + + + + ${this.form.value_configurable + ? '' + : html` + + + + + + + + + + + + + + + + + + + + + + + + + `} + `; + } + + protected async _sendPost(edits: Partial): Promise { + const existingOptions = this.existingOptions.filter(o => o.name === edits.name); + + if (existingOptions.some(o => o.value_configurable)) { + throw ['error:option_exists_configurable']; + } + + if (existingOptions.some(o => o.value === edits.value)) { + throw ['error:option_exists']; + } + + return super._sendPost(edits); + } + + private get __resolvedDefaultWeightUnit() { + type Loader = NucleonElement>; + const loader = this.renderRoot.querySelector('#itemCategoryLoader'); + return loader?.data?.default_weight_unit ?? this.defaultWeightUnit; + } +} diff --git a/src/elements/public/ExperimentalAddToCartBuilder/internal/InternalExperimentalAddToCartBuilderCustomOptionForm/index.ts b/src/elements/public/ExperimentalAddToCartBuilder/internal/InternalExperimentalAddToCartBuilderCustomOptionForm/index.ts new file mode 100644 index 000000000..72959e6e8 --- /dev/null +++ b/src/elements/public/ExperimentalAddToCartBuilder/internal/InternalExperimentalAddToCartBuilderCustomOptionForm/index.ts @@ -0,0 +1,14 @@ +import '../../../../internal/InternalResourcePickerControl/index'; +import '../../../../internal/InternalSummaryControl/index'; +import '../../../../internal/InternalSwitchControl/index'; +import '../../../../internal/InternalNumberControl/index'; +import '../../../../internal/InternalTextControl/index'; +import '../../../../internal/InternalForm/index'; + +import '../../../NucleonElement/index'; + +import { InternalExperimentalAddToCartBuilderCustomOptionForm as Form } from './InternalExperimentalAddToCartBuilderCustomOptionForm'; + +customElements.define('foxy-internal-experimental-add-to-cart-builder-custom-option-form', Form); + +export { Form as InternalExperimentalAddToCartBuilderCustomOptionForm }; diff --git a/src/elements/public/ExperimentalAddToCartBuilder/internal/InternalExperimentalAddToCartBuilderCustomOptionForm/types.ts b/src/elements/public/ExperimentalAddToCartBuilder/internal/InternalExperimentalAddToCartBuilderCustomOptionForm/types.ts new file mode 100644 index 000000000..7d4df9761 --- /dev/null +++ b/src/elements/public/ExperimentalAddToCartBuilder/internal/InternalExperimentalAddToCartBuilderCustomOptionForm/types.ts @@ -0,0 +1,10 @@ +import type { ExperimentalAddToCartSnippet } from '../../types'; +import type { Graph, Resource } from '@foxy.io/sdk/core'; + +interface ExperimentalAddToCartSnippetCustomOption extends Graph { + curie: 'fx:experimental_add_to_cart_snippet_custom_option'; + links: { self: ExperimentalAddToCartSnippetCustomOption }; + props: ExperimentalAddToCartSnippet['props']['items'][number]['custom_options'][number]; +} + +export type Data = Resource; diff --git a/src/elements/public/ExperimentalAddToCartBuilder/internal/InternalExperimentalAddToCartBuilderItemControl/InternalExperimentalAddToCartBuilderItemControl.ts b/src/elements/public/ExperimentalAddToCartBuilder/internal/InternalExperimentalAddToCartBuilderItemControl/InternalExperimentalAddToCartBuilderItemControl.ts new file mode 100644 index 000000000..0fb18d9f9 --- /dev/null +++ b/src/elements/public/ExperimentalAddToCartBuilder/internal/InternalExperimentalAddToCartBuilderItemControl/InternalExperimentalAddToCartBuilderItemControl.ts @@ -0,0 +1,400 @@ +import type { PropertyDeclarations, TemplateResult } from 'lit-element'; +import type { ExperimentalAddToCartBuilder } from '../../ExperimentalAddToCartBuilder'; +import type { DiscountBuilder } from '../../../DiscountBuilder/DiscountBuilder'; +import type { NucleonElement } from '../../../NucleonElement/NucleonElement'; +import type { Resource } from '@foxy.io/sdk/core'; +import type { Rels } from '@foxy.io/sdk/backend'; +import type { Data } from '../../types'; + +import { InternalControl } from '../../../../internal/InternalControl/InternalControl'; +import { ifDefined } from 'lit-html/directives/if-defined'; +import { html } from 'lit-html'; + +export class InternalExperimentalAddToCartBuilderItemControl extends InternalControl { + static get properties(): PropertyDeclarations { + return { + ...super.properties, + itemCategories: { attribute: 'item-categories' }, + currencyCode: { attribute: 'currency-code' }, + index: { type: Number }, + store: {}, + }; + } + + itemCategories: string | null = null; + + currencyCode: string | null = null; + + index = 0; + + renderControl(): TemplateResult { + const itemCategory = this.__itemCategoryLoader?.data; + const nucleon = this.nucleon as ExperimentalAddToCartBuilder | null; + const index = this.index; + const item = nucleon?.form.items?.[index]; + + return html` +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + ${item?.image + ? html` + + + ` + : ''} + + + + + + + ${item?.sub_enabled + ? html` + + + + + + + ${item.sub_startdate_format === 'yyyymmdd' + ? html` + + + ` + : item.sub_startdate_format === 'duration' + ? html` + + + ` + : item.sub_startdate_format === 'dd' + ? html` + + + ` + : ''} + + + + + ${item.sub_enddate_format === 'yyyymmdd' + ? html` + + + ` + : item.sub_enddate_format === 'duration' + ? html` + + + ` + : ''} + ` + : ''} + + + + + + + ${item?.discount_name + ? html` + + this.__handleDiscountBuilderChange(evt, item, index)} + > + + ` + : ''} + + + + + + + ${item?.expires_format === 'minutes' + ? html` + + + ` + : item?.expires_format === 'timestamp' + ? html` + + + ` + : ''} + + + + + + + ${item?.expires_format === 'minutes' + ? '' + : html` + + + + + + `} + + + + + + + + + + + + + + + + + + + + ${nucleon?.form.items?.length === 1 + ? html`` + : html` + this.dispatchEvent(new CustomEvent('remove'))} + > + + + `} + + +
+ `; + } + + private get __itemCategoryLoader() { + type Loader = NucleonElement>; + return this.renderRoot.querySelector('#itemCategoryLoader'); + } + + private __handleDiscountBuilderChange( + evt: CustomEvent, + item: Data['items'][number], + index: number + ) { + const builder = evt.currentTarget as DiscountBuilder; + const nucleon = this.nucleon as ExperimentalAddToCartBuilder | null; + + item.discount_details = builder.parsedValue.details; + item.discount_type = builder.parsedValue.type; + item.discount_name = builder.parsedValue.name; + + const items = nucleon?.form.items ?? []; + items.splice(index, 1, item); + nucleon?.edit({ items: [...items] }); + } +} diff --git a/src/elements/public/ExperimentalAddToCartBuilder/internal/InternalExperimentalAddToCartBuilderItemControl/index.ts b/src/elements/public/ExperimentalAddToCartBuilder/internal/InternalExperimentalAddToCartBuilderItemControl/index.ts new file mode 100644 index 000000000..0bd380d7c --- /dev/null +++ b/src/elements/public/ExperimentalAddToCartBuilder/internal/InternalExperimentalAddToCartBuilderItemControl/index.ts @@ -0,0 +1,26 @@ +import '@vaadin/vaadin-button'; + +import '../../../../internal/InternalResourcePickerControl/index'; +import '../../../../internal/InternalFrequencyControl/index'; +import '../../../../internal/InternalAsyncListControl/index'; +import '../../../../internal/InternalSummaryControl/index'; +import '../../../../internal/InternalSwitchControl/index'; +import '../../../../internal/InternalSelectControl/index'; +import '../../../../internal/InternalNumberControl/index'; +import '../../../../internal/InternalDateControl/index'; +import '../../../../internal/InternalTextControl/index'; +import '../../../../internal/InternalControl/index'; + +import '../../../ItemCategoryCard/index'; +import '../../../DiscountBuilder/index'; +import '../../../NucleonElement/index'; +import '../../../I18n/index'; + +import '../InternalExperimentalAddToCartBuilderCustomOptionCard/index'; +import '../InternalExperimentalAddToCartBuilderCustomOptionForm/index'; + +import { InternalExperimentalAddToCartBuilderItemControl as Control } from './InternalExperimentalAddToCartBuilderItemControl'; + +customElements.define('foxy-internal-experimental-add-to-cart-builder-item-control', Control); + +export { Control as InternalExperimentalAddToCartBuilderItem }; diff --git a/src/elements/public/ExperimentalAddToCartBuilder/preview.css.ts b/src/elements/public/ExperimentalAddToCartBuilder/preview.css.ts new file mode 100644 index 000000000..ac81923c5 --- /dev/null +++ b/src/elements/public/ExperimentalAddToCartBuilder/preview.css.ts @@ -0,0 +1,152 @@ +/** CSS reset adapted from github.com/necolas/normalize.css */ +export const previewCSS = ` + +`; diff --git a/src/elements/public/ExperimentalAddToCartBuilder/types.ts b/src/elements/public/ExperimentalAddToCartBuilder/types.ts new file mode 100644 index 000000000..d3af9d40c --- /dev/null +++ b/src/elements/public/ExperimentalAddToCartBuilder/types.ts @@ -0,0 +1,58 @@ +import type { Graph, Resource } from '@foxy.io/sdk/core'; +import type { Rels } from '@foxy.io/sdk/backend'; + +/** WARNING: this API doesn't exist. Fields and data types may change without notice. */ +export interface ExperimentalAddToCartSnippet extends Graph { + curie: 'fx:experimental_add_to_cart_snippet'; + links: { + 'self': ExperimentalAddToCartSnippet; + 'fx:store': Rels.Store; + }; + props: { + template_set_uri: string | null; + empty: 'false' | 'true' | 'reset'; + cart: 'add' | 'checkout'; + items: { + name: string; + item_category_uri?: string | null; + price: number; + price_configurable?: boolean; + code?: string | null; + parent_code?: string | null; + image?: string | null; + url?: string | null; + sub_enabled?: boolean; + sub_frequency?: string | null; + sub_startdate_format?: 'none' | 'yyyymmdd' | 'dd' | 'duration'; + sub_startdate?: string | number | null; + sub_enddate_format?: 'none' | 'yyyymmdd' | 'duration'; + sub_enddate?: string | null; + discount_details?: string | null; + discount_type?: string | null; + discount_name?: string | null; + expires_format?: 'minutes' | 'timestamp' | 'none'; + expires_value?: number | null; + quantity?: number | null; + quantity_max?: number | null; + quantity_min?: number | null; + weight?: number | null; + length?: number | null; + width?: number | null; + height?: number | null; + custom_options: { + name: string; + value?: string; + value_configurable?: boolean; + price?: number | null; + replace_price?: boolean; + weight?: number | null; + replace_weight?: boolean; + code?: string | null; + replace_code?: boolean; + item_category_uri?: string | null; + }[]; + }[]; + }; +} + +export type Data = Resource; diff --git a/src/elements/public/index.defined.ts b/src/elements/public/index.defined.ts index 31346bb5b..527d90c7f 100644 --- a/src/elements/public/index.defined.ts +++ b/src/elements/public/index.defined.ts @@ -45,6 +45,7 @@ export { DownloadableForm } from './DownloadableForm/index'; export { EmailTemplateCard } from './EmailTemplateCard/index'; export { EmailTemplateForm } from './EmailTemplateForm/index'; export { ErrorEntryCard } from './ErrorEntryCard/index'; +export { ExperimentalAddToCartBuilder } from './ExperimentalAddToCartBuilder/index'; export { FilterAttributeCard } from './FilterAttributeCard/index'; export { FilterAttributeForm } from './FilterAttributeForm/index'; export { FormDialog } from './FormDialog/index'; diff --git a/src/elements/public/index.ts b/src/elements/public/index.ts index 1f100eeaa..613462659 100644 --- a/src/elements/public/index.ts +++ b/src/elements/public/index.ts @@ -45,6 +45,7 @@ export { DownloadableForm } from './DownloadableForm/DownloadableForm'; export { EmailTemplateCard } from './EmailTemplateCard/EmailTemplateCard'; export { EmailTemplateForm } from './EmailTemplateForm/EmailTemplateForm'; export { ErrorEntryCard } from './ErrorEntryCard/ErrorEntryCard'; +export { ExperimentalAddToCartBuilder } from './ExperimentalAddToCartBuilder/ExperimentalAddToCartBuilder'; export { FilterAttributeCard } from './FilterAttributeCard/FilterAttributeCard'; export { FilterAttributeForm } from './FilterAttributeForm/FilterAttributeForm'; export { FormDialog } from './FormDialog/FormDialog'; diff --git a/src/server/hapi/createDataset.ts b/src/server/hapi/createDataset.ts index 38dd76a8f..d215d6e72 100644 --- a/src/server/hapi/createDataset.ts +++ b/src/server/hapi/createDataset.ts @@ -619,7 +619,7 @@ export const createDataset: () => Dataset = () => ({ use_webhook: false, webhook_url: '', webhook_key: '', - use_cart_validation: false, + use_cart_validation: true, use_single_sign_on: false, single_sign_on_url: '', customer_password_hash_type: 'phpass', @@ -1780,7 +1780,7 @@ export const createDataset: () => Dataset = () => ({ code: 'DEFAULT', description: 'Default Template Set', language: 'english', - locale_code: 'en_US', + locale_code: 'en_AU', config: '', date_created: '2012-08-10T11:58:54-0700', date_modified: '2012-08-10T11:58:54-0700', @@ -1805,7 +1805,7 @@ export const createDataset: () => Dataset = () => ({ code: 'TEST', description: 'Test (Localdev)', language: 'english', - locale_code: 'en_US', + locale_code: 'en_CA', config: '', date_created: '2012-08-10T11:58:54-0700', date_modified: '2012-08-10T11:58:54-0700', @@ -1982,4 +1982,152 @@ export const createDataset: () => Dataset = () => ({ date_modified: '2022-12-01T10:07:05-0800', }, ], + + experimental_add_to_cart_snippets: [ + { + id: 0, + store_id: 0, + template_set_uri: 'https://demo.api/hapi/template_sets/0', + empty: 'true', + cart: 'checkout', + items: [ + { + name: 'Red T-Shirt', + item_category_uri: 'https://demo.api/hapi/item_categories/0', + price: 24.99, + price_configurable: true, + code: 'REDTSHIRT', + parent_code: null, + image: 'https://picsum.photos/256', + url: 'https://example.com', + sub_enabled: true, + sub_frequency: '6m', + sub_startdate_format: 'dd', + sub_startdate: 12, + sub_enddate_format: 'duration', + sub_enddate: '1y', + discount_details: null, + discount_type: null, + discount_name: null, + expires_format: 'minutes', + expires_value: 60, + quantity: 1, + quantity_options: [1, 2, 3], + quantity_configurable: true, + quantity_max: 3, + quantity_min: 1, + weight: 0.5, + length: 25, + width: 25, + height: 5, + custom_options: [ + { + name: 'Print', + value: 'None', + value_configurable: false, + price: 0, + replace_price: false, + weight: 0, + replace_weight: false, + code: null, + replace_code: false, + item_category_uri: null, + }, + { + name: 'Print', + value: 'Abstract', + value_configurable: false, + price: 1.99, + replace_price: false, + weight: 0, + replace_weight: false, + code: '-ABSTRACT', + replace_code: false, + item_category_uri: null, + }, + { + name: 'Print', + value: 'Waves', + value_configurable: false, + price: 1.99, + replace_price: false, + weight: 0, + replace_weight: false, + code: '-WAVES', + replace_code: false, + item_category_uri: null, + }, + ], + }, + { + name: 'Yellow T-Shirt', + item_category_uri: 'https://demo.api/hapi/item_categories/0', + price: 24.99, + price_configurable: true, + code: 'YELLOWTSHIRT', + parent_code: null, + image: 'https://picsum.photos/256', + url: 'https://example.com', + sub_enabled: true, + sub_frequency: '6m', + sub_startdate_format: 'dd', + sub_startdate: 12, + sub_enddate_format: 'duration', + sub_enddate: '1y', + discount_details: null, + discount_type: null, + discount_name: null, + expires_format: 'minutes', + expires_value: 60, + quantity: 1, + quantity_options: [1, 2, 3], + quantity_configurable: true, + quantity_max: 3, + quantity_min: 1, + weight: 0.5, + length: 25, + width: 25, + height: 5, + custom_options: [ + { + name: 'Print', + value: 'None', + value_configurable: false, + price: 0, + replace_price: false, + weight: 0, + replace_weight: false, + code: null, + replace_code: false, + item_category_uri: null, + }, + { + name: 'Print', + value: 'Abstract', + value_configurable: false, + price: 1.99, + replace_price: false, + weight: 0, + replace_weight: false, + code: '-ABSTRACT', + replace_code: false, + item_category_uri: null, + }, + { + name: 'Print', + value: 'Waves', + value_configurable: false, + price: 1.99, + replace_price: false, + weight: 0, + replace_weight: false, + code: '-WAVES', + replace_code: false, + item_category_uri: null, + }, + ], + }, + ], + }, + ], }); diff --git a/src/server/hapi/defaults.ts b/src/server/hapi/defaults.ts index 47c3ade45..795cade2d 100644 --- a/src/server/hapi/defaults.ts +++ b/src/server/hapi/defaults.ts @@ -1087,4 +1087,14 @@ export const defaults: Defaults = { date_created: new Date().toISOString(), date_modified: new Date().toISOString(), }), + + experimental_add_to_cart_snippets: (query, dataset) => ({ + id: increment('experimental_add_to_cart_snippets', dataset), + store_id: parseInt(query.get('store_id') ?? '0'), + template_set_uri: null, + coupons: [], + empty: null, + cart: null, + custom_options: [], + }), }; diff --git a/src/server/hapi/links.ts b/src/server/hapi/links.ts index 2620166c6..f2397198d 100644 --- a/src/server/hapi/links.ts +++ b/src/server/hapi/links.ts @@ -556,4 +556,8 @@ export const links: Links = { 'fx:store': { href: `./stores/${user_id}` }, 'fx:user': { href: `./users/${user_id}` }, }), + + experimental_add_to_cart_snippets: ({ store_id }) => ({ + 'fx:store': { href: `./stores/${store_id}` }, + }), }; diff --git a/src/server/virtual/index.ts b/src/server/virtual/index.ts index 350f41b69..75757aef6 100644 --- a/src/server/virtual/index.ts +++ b/src/server/virtual/index.ts @@ -50,5 +50,14 @@ export function createRouter(): Router { return new Response(JSON.stringify(body)); }); + router.post('/:prefix/encode', async ({ request }) => { + const html = await request.text(); + const result = html + .replace(/value="--OPEN--"/g, 'data-bak="--OPEN--"') + .replace(/(value|name|href)="/g, '$1="demo-signature|') + .replace(/data-bak="--OPEN--"/g, 'value="--OPEN--"'); + return new Response(JSON.stringify({ result })); + }); + return router; } diff --git a/src/static/translations/experimental-add-to-cart-builder/en.json b/src/static/translations/experimental-add-to-cart-builder/en.json new file mode 100644 index 000000000..511330394 --- /dev/null +++ b/src/static/translations/experimental-add-to-cart-builder/en.json @@ -0,0 +1,545 @@ +{ + "item": { + "label": "New item", + "helper_text": "", + "basics-group": { + "label": "", + "helper_text": "", + "name": { + "label": "Name", + "placeholder": "Required", + "helper_text": "" + }, + "item-category-uri": { + "label": "Category", + "helper_text": "", + "placeholder": "DEFAULT", + "value": "{{ resource.name }}", + "dialog": { + "cancel": "Cancel", + "close": "Close", + "header": "Select an item category", + "selection": { + "label": "", + "helper_text": "", + "search": "Search", + "clear": "Clear", + "pagination": { + "search_button_text": "Search", + "first": "First", + "last": "Last", + "next": "Next", + "pagination": "{{from}}-{{to}} out of {{total}}", + "previous": "Previous", + "card": { + "spinner": { + "loading_busy": "Loading", + "loading_empty": "No item categories found", + "loading_error": "Unknown error" + } + } + } + } + }, + "card": { + "spinner": { + "loading_busy": "Loading", + "loading_empty": "Not assigned – click to select", + "loading_error": "Unknown error" + } + } + } + }, + "price-group": { + "label": "", + "helper_text": "", + "price": { + "label": "Price", + "placeholder": "0", + "helper_text": "" + }, + "price-default": { + "label": "Default price", + "placeholder": "0", + "helper_text": "" + }, + "price-configurable": { + "label": "Allow custom amount", + "helper_text": "For donations or pay-what-you-want items.", + "checked": "Yes", + "unchecked": "No" + } + }, + "code-group": { + "label": "", + "helper_text": "", + "code": { + "label": "SKU", + "placeholder": "Optional", + "helper_text": "" + }, + "parent-code": { + "label": "Parent SKU", + "placeholder": "Optional", + "helper_text": "For child items in a bundle." + } + }, + "appearance-group": { + "label": "Appearance", + "helper_text": "", + "image": { + "label": "Image URL", + "placeholder": "Optional", + "helper_text": "" + }, + "url": { + "label": "Product URL", + "placeholder": "Optional", + "helper_text": "" + } + }, + "subscriptions-group": { + "label": "Subscription", + "helper_text": "", + "sub-enabled": { + "label": "This product is a subscription", + "helper_text": "", + "checked": "Yes", + "unchecked": "No" + }, + "sub-frequency": { + "label": "Frequency", + "helper_text": "", + "placeholder": "0", + "select": "Select", + "day": "Day", + "day_plural": "Days", + "week": "Week", + "week_plural": "Weeks", + "month": "Month", + "month_plural": "Months", + "year": "Year", + "year_plural": "Years" + }, + "sub-startdate-format": { + "label": "Start", + "helper_text": "", + "placeholder": "Select", + "option_none": "Immediately", + "option_yyyymmdd": "On a specific date", + "option_dd": "On a specific day of the month", + "option_duration": "After a period of time" + }, + "sub-startdate-yyyymmdd": { + "label": "Start date", + "helper_text": "", + "display_value": "{{ value, date }}", + "placeholder": "Required" + }, + "sub-startdate-dd": { + "label": "Start day of the month", + "helper_text": "", + "placeholder": "Required" + }, + "sub-startdate-duration": { + "label": "Start after", + "helper_text": "", + "placeholder": "0", + "select": "Select", + "day": "Day", + "day_plural": "Days", + "week": "Week", + "week_plural": "Weeks", + "month": "Month", + "month_plural": "Months", + "year": "Year", + "year_plural": "Years" + }, + "sub-enddate-format": { + "label": "End", + "helper_text": "", + "placeholder": "Select", + "option_none": "When cancelled", + "option_yyyymmdd": "On a specific date", + "option_duration": "After a period of time" + }, + "sub-enddate-yyyymmdd": { + "label": "End date", + "helper_text": "", + "display_value": "{{ value, date }}", + "placeholder": "Required" + }, + "sub-enddate-duration": { + "label": "End after", + "helper_text": "", + "placeholder": "0", + "select": "Select", + "day": "Day", + "day_plural": "Days", + "week": "Week", + "week_plural": "Weeks", + "month": "Month", + "month_plural": "Months", + "year": "Year", + "year_plural": "Years" + } + }, + "discount-group": { + "label": "Discount", + "helper_text": "", + "discount-name": { + "label": "Name", + "helper_text": "", + "placeholder": "None" + }, + "discount-builder": { + "tier": "Tier", + "tier_by": "by", + "tier_if": "if", + "tier_allunits": "price of each item", + "tier_incremental": "price of additional items", + "tier_repeat": "price of next item", + "tier_single": "order total", + "tier_then": "then", + "quantity": "quantity", + "total": "total", + "reduce": "reduce", + "increase": "increase" + } + }, + "expires-group": { + "label": "Expiration", + "helper_text": "", + "expires-format": { + "label": "Expiration", + "helper_text": "", + "placeholder": "Select", + "option_none": "Doesn't expire", + "option_minutes": "After a number of minutes", + "option_timestamp": "On a specific date" + }, + "expires-value-timestamp": { + "label": "Expiration date", + "helper_text": "", + "display_value": "{{ value, date }}", + "placeholder": "Required" + }, + "expires-value-minutes": { + "label": "Expires after", + "helper_text": "", + "placeholder": "0" + } + }, + "quantity-group": { + "label": "Quantity", + "helper_text": "", + "quantity": { + "label": "Quantity", + "placeholder": "1", + "helper_text": "" + }, + "quantity-min": { + "label": "Minimum quantity", + "placeholder": "1", + "helper_text": "" + }, + "quantity-max": { + "label": "Maximum quantity", + "placeholder": "None", + "helper_text": "" + } + }, + "dimensions-group": { + "label": "Dimensions", + "helper_text": "", + "width": { + "label": "Width", + "helper_text": "", + "placeholder": "0" + }, + "height": { + "label": "Height", + "helper_text": "", + "placeholder": "0" + }, + "length": { + "label": "Length", + "helper_text": "", + "placeholder": "0" + }, + "weight": { + "label": "Weight", + "helper_text": "", + "placeholder": "0" + } + }, + "custom-options": { + "label": "Custom options", + "delete_header": "Remove custom option?", + "delete_message": "Please confirm that you'd like to remove this custom option from the product.", + "delete_confirm": "Remove", + "delete_cancel": "Cancel", + "dialog": { + "header_create": "Add option", + "header_update": "Update option", + "close": "Close", + "save": "Save", + "cancel": "Cancel", + "undo_header": "Unsaved changes", + "undo_message": "Looks like you didn't save your changes! What would you like to do with them?", + "undo_cancel": "Review", + "undo_confirm": "Discard", + "internal-experimental-add-to-cart-builder-custom-option-form": { + "error": { + "option_exists": "Option with this name and value already exists.", + "option_exists_configurable": "Only one option with this name can exist when its value is configurable." + }, + "basics-group": { + "label": "", + "helper_text": "", + "name": { + "label": "Name", + "placeholder": "Required", + "helper_text": "" + }, + "value": { + "label": "Value", + "placeholder": "Required", + "helper_text": "" + }, + "default-value": { + "label": "Default value", + "placeholder": "Optional", + "helper_text": "" + }, + "value-configurable": { + "label": "Let users enter a custom value", + "helper_text": "", + "checked": "Yes", + "unchecked": "No" + } + }, + "price-group": { + "label": "", + "helper_text": "", + "price": { + "label": "Price modifier", + "placeholder": "0", + "helper_text": "" + }, + "replace-price": { + "label": "Replace item price with this value", + "helper_text": "", + "checked": "Yes", + "unchecked": "No" + } + }, + "weight-group": { + "label": "", + "helper_text": "", + "weight": { + "label": "Weight modifier", + "placeholder": "0", + "helper_text": "" + }, + "replace-weight": { + "label": "Replace item weight with this value", + "helper_text": "", + "checked": "Yes", + "unchecked": "No" + } + }, + "code-group": { + "label": "", + "helper_text": "", + "code": { + "label": "Code modifier", + "placeholder": "None", + "helper_text": "" + }, + "replace-code": { + "label": "Replace item code with this value", + "helper_text": "", + "checked": "Yes", + "unchecked": "No" + } + }, + "category-group": { + "label": "", + "helper_text": "", + "item-category-uri": { + "label": "Change item category to", + "helper_text": "", + "placeholder": "Don't change", + "value": "{{ resource.code }}", + "dialog": { + "cancel": "Cancel", + "close": "Close", + "header": "Select an item category", + "selection": { + "label": "", + "helper_text": "", + "search": "Search", + "clear": "Clear", + "pagination": { + "search_button_text": "Search", + "first": "First", + "last": "Last", + "next": "Next", + "pagination": "{{from}}-{{to}} out of {{total}}", + "previous": "Previous", + "card": { + "spinner": { + "loading_busy": "Loading", + "loading_empty": "No item categories found", + "loading_error": "Unknown error" + } + } + } + } + }, + "card": { + "spinner": { + "loading_busy": "Loading", + "loading_empty": "Not assigned – click to select", + "loading_error": "Unknown error" + } + } + } + }, + "delete": { + "delete": "Delete", + "cancel": "Cancel", + "delete_prompt": "Are you sure you'd like to remove this custom option? You won't be able to bring it back." + }, + "undo": { + "caption": "Undo" + }, + "submit": { + "caption": "Save changes" + }, + "create": { + "caption": "Create" + }, + "spinner": { + "refresh": "Refresh", + "loading_busy": "Loading", + "loading_error": "Unknown error" + } + } + }, + "pagination": { + "card": { + "delete_button_text": "Delete", + "spinner": { + "loading_busy": "Loading", + "loading_empty": "No options", + "loading_error": "Unknown error" + } + }, + "create_button_text": "Add option +", + "first": "First", + "last": "Last", + "next": "Next", + "pagination": "{{from}}-{{to}} out of {{total}}", + "previous": "Previous" + } + }, + "delete": { + "caption": "Remove from bundle" + } + }, + "add-product": { + "caption": "Add another item" + }, + "cart-settings-group": { + "label": "", + "helper_text": "", + "template-set-uri": { + "label": "Template set", + "helper_text": "", + "value": "{{ resource.code }}", + "value_busy": "Loading...", + "value_fail": "Failed to load", + "placeholder": "DEFAULT", + "dialog": { + "cancel": "Cancel", + "close": "Close", + "header": "Select an template set", + "selection": { + "label": "Template sets", + "helper_text": "", + "pagination": { + "search_button_text": "Search", + "first": "First", + "last": "Last", + "next": "Next", + "pagination": "{{from}}-{{to}} out of {{total}}", + "previous": "Previous", + "card": { + "spinner": { + "loading_busy": "Loading", + "loading_empty": "No template sets found", + "loading_error": "Unknown error" + } + } + } + } + } + }, + "empty": { + "label": "Before adding new items", + "helper_text": "", + "placeholder": "Select", + "option_false": "Do nothing", + "option_true": "Clear the current cart", + "option_reset": "Create a new cart" + }, + "cart": { + "label": "Skip the cart and go straight to checkout", + "helper_text": "", + "checked": "Yes", + "unchecked": "No" + } + }, + "link": { + "label": "", + "helper_text": "", + "direct_link": "Direct link", + "empty": "Modify the item parameters to see the add-to-cart link.", + "copy-to-clipboard": { + "failed_to_copy": "Failed to copy", + "click_to_copy": "Copy to clipboard", + "copying": "Copying...", + "done": "Copied to clipboard" + } + }, + "preview": { + "label": "", + "helper_text": "", + "edits_tip": "You are free to edit this code and add styles to match your website. When editing, make sure to keep the names and values of the form elements intact, and preserve all Foxy URLs in the snippet.", + "edits_docs": "Learn more...", + "price_label": "Price:", + "quantity_label": "Quantity:", + "shipto_label": "Ship to:", + "submit_caption": "Add to cart", + "unavailable": { + "loading_empty": "Add name and price to every item to see the preview and the add-to-cart code." + }, + "spinner": { + "loading_busy": "Signing", + "loading_error": "Failed to sign" + }, + "copy-to-clipboard": { + "failed_to_copy": "Failed to copy", + "click_to_copy": "Copy to clipboard", + "copying": "Copying...", + "done": "Copied to clipboard" + } + }, + "spinner": { + "refresh": "Refresh", + "loading_busy": "Loading", + "loading_error": "Unknown error" + } +} \ No newline at end of file diff --git a/web-test-runner.groups.js b/web-test-runner.groups.js index 636966642..f0e14c29c 100644 --- a/web-test-runner.groups.js +++ b/web-test-runner.groups.js @@ -391,6 +391,10 @@ export const groups = [ name: 'foxy-error-entry-card', files: './src/elements/public/ErrorEntryCard/**/*.test.ts', }, + { + name: 'foxy-experimental-add-to-cart-builder', + files: './src/elements/public/ExperimentalAddToCartBuilder/**/*.test.ts', + }, { name: 'foxy-filter-attribute-card', files: './src/elements/public/FilterAttributeCard/**/*.test.ts',