From 48870bb5924019a2ae55eb40918c12d71a98adc6 Mon Sep 17 00:00:00 2001 From: Daniil Bratukhin Date: Sat, 7 Dec 2024 03:17:24 -0300 Subject: [PATCH 01/13] internal(editable-control): fix nested property setter --- .../InternalEditableControl.ts | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/src/elements/internal/InternalEditableControl/InternalEditableControl.ts b/src/elements/internal/InternalEditableControl/InternalEditableControl.ts index 9c570dba..3f5b7470 100644 --- a/src/elements/internal/InternalEditableControl/InternalEditableControl.ts +++ b/src/elements/internal/InternalEditableControl/InternalEditableControl.ts @@ -45,12 +45,28 @@ export class InternalEditableControl extends InternalControl { }; setValue = (newValue: unknown): void => { - if (this.jsonPath) { - const json = JSON.parse(this.nucleon?.form[this.property] ?? this.jsonTemplate); - set(json, this.jsonPath, newValue); - this.nucleon?.edit({ [this.property]: JSON.stringify(json) }); + const [formProperty, ...nestedPath] = this.property.split('.'); + + if (nestedPath.length) { + const nestedForm = this.nucleon?.form[formProperty] ?? {}; + + if (this.jsonPath) { + const json = JSON.parse(this.nucleon?.form[formProperty] ?? this.jsonTemplate); + set(json, this.jsonPath, newValue); + set(nestedForm, nestedPath, JSON.stringify(json)); + } else { + set(nestedForm, nestedPath, newValue); + } + + this.nucleon?.edit({ [formProperty]: nestedForm }); } else { - this.nucleon?.edit({ [this.property]: newValue }); + if (this.jsonPath) { + const json = JSON.parse(this.nucleon?.form[formProperty] ?? this.jsonTemplate); + set(json, this.jsonPath, newValue); + this.nucleon?.edit({ [formProperty]: JSON.stringify(json) }); + } else { + this.nucleon?.edit({ [formProperty]: newValue }); + } } }; From 9d0a9476a1d0e199b364c19222b23a7761816bae Mon Sep 17 00:00:00 2001 From: Daniil Bratukhin Date: Sat, 7 Dec 2024 03:23:58 -0300 Subject: [PATCH 02/13] internal(summary-control): add details/summary layout --- .../InternalSummaryControl.test.ts | 105 +++++++++++++++++- .../InternalSummaryControl.ts | 63 ++++++++++- 2 files changed, 163 insertions(+), 5 deletions(-) diff --git a/src/elements/internal/InternalSummaryControl/InternalSummaryControl.test.ts b/src/elements/internal/InternalSummaryControl/InternalSummaryControl.test.ts index aa6bc619..fccdfb39 100644 --- a/src/elements/internal/InternalSummaryControl/InternalSummaryControl.test.ts +++ b/src/elements/internal/InternalSummaryControl/InternalSummaryControl.test.ts @@ -20,11 +20,21 @@ describe('InternalSummaryControl', () => { expect(new Control()).to.be.instanceOf(customElements.get('foxy-internal-editable-control')); }); + it('has a reactive property "layout" that defaults to null', () => { + expect(Control).to.have.deep.nested.property('properties.layout', {}); + expect(new Control()).to.have.property('layout', null); + }); + + it('has a reactive property "open" that defaults to false', () => { + expect(Control).to.have.deep.nested.property('properties.open', { type: Boolean }); + expect(new Control()).to.have.property('open', false); + }); + it('renders nothing in light DOM', () => { expect(new Control().renderLightDom()).to.be.undefined; }); - it('renders label', async () => { + it('renders label in default layout', async () => { const control = await fixture(html` `); @@ -38,7 +48,7 @@ describe('InternalSummaryControl', () => { expect(control.renderRoot).to.include.text('Foo bar'); }); - it('renders helper text', async () => { + it('renders helper text in default layout', async () => { const control = await fixture(html` `); @@ -52,11 +62,100 @@ describe('InternalSummaryControl', () => { expect(control.renderRoot).to.include.text('Test helper text'); }); - it('renders default slot', async () => { + it('renders default slot in default layout', async () => { const control = await fixture(html` `); expect(control.renderRoot).to.include.html(''); }); + + it('renders details/summary in "details" layout', async () => { + const control = await fixture(html` + + `); + + const details = control.renderRoot.querySelector('details')!; + const summary = control.renderRoot.querySelector('details > summary')!; + + expect(details).to.exist; + expect(summary).to.exist; + expect(details.open).to.be.false; + + control.open = true; + await control.requestUpdate(); + expect(details.open).to.be.true; + }); + + it('renders label inside of the details summary in details layout', async () => { + const control = await fixture(html` + + `); + + const summary = control.renderRoot.querySelector('summary'); + expect(summary).to.include.text('label'); + + control.label = 'Foo bar'; + await control.requestUpdate(); + + expect(summary).to.not.include.text('label'); + expect(summary).to.include.text('Foo bar'); + }); + + it('renders helper text inside of the details summary in details layout', async () => { + const control = await fixture(html` + + `); + + const summary = control.renderRoot.querySelector('summary'); + expect(summary).to.include.text('helper_text'); + + control.helperText = 'Test helper text'; + await control.requestUpdate(); + + expect(summary).to.not.include.text('helper_text'); + expect(summary).to.include.text('Test helper text'); + }); + + it('renders default slot inside of the details content in details layout', async () => { + const control = await fixture(html` + + `); + + const summary = control.renderRoot.querySelector('details'); + expect(summary).to.include.html(''); + }); + + it('toggles open state when details is toggled', async () => { + const control = await fixture(html` + + `); + + const details = control.renderRoot.querySelector('details')!; + expect(control.open).to.be.false; + + details.open = true; + details.dispatchEvent(new Event('toggle')); + await control.requestUpdate(); + expect(control.open).to.be.true; + + details.open = false; + details.dispatchEvent(new Event('toggle')); + await control.requestUpdate(); + expect(control.open).to.be.false; + }); + + it('dispatches "toggle" event when details is toggled', async () => { + const control = await fixture(html` + + `); + + const details = control.renderRoot.querySelector('details')!; + let eventCount = 0; + + control.addEventListener('toggle', () => eventCount++); + details.dispatchEvent(new Event('toggle')); + await control.requestUpdate(); + expect(eventCount).to.equal(1); + }); }); diff --git a/src/elements/internal/InternalSummaryControl/InternalSummaryControl.ts b/src/elements/internal/InternalSummaryControl/InternalSummaryControl.ts index 58657185..9dd2aad0 100644 --- a/src/elements/internal/InternalSummaryControl/InternalSummaryControl.ts +++ b/src/elements/internal/InternalSummaryControl/InternalSummaryControl.ts @@ -1,10 +1,18 @@ -import type { CSSResultArray } from 'lit-element'; -import type { TemplateResult } from 'lit-html'; +import type { CSSResultArray, PropertyDeclarations } from 'lit-element'; +import { svg, TemplateResult } from 'lit-html'; import { InternalEditableControl } from '../InternalEditableControl/InternalEditableControl'; import { html, css } from 'lit-element'; export class InternalSummaryControl extends InternalEditableControl { + static get properties(): PropertyDeclarations { + return { + ...super.properties, + layout: {}, + open: { type: Boolean }, + }; + } + static get styles(): CSSResultArray { return [ ...super.styles, @@ -13,15 +21,66 @@ export class InternalSummaryControl extends InternalEditableControl { background-color: var(--lumo-contrast-5pct); padding: calc(0.625em + (var(--lumo-border-radius) / 4) - 1px); } + + details summary > div { + border-radius: var(--lumo-border-radius); + } + + details[open] summary > div { + border-radius: var(--lumo-border-radius) var(--lumo-border-radius) 0 0; + } `, ]; } + layout: null | 'details' = null; + + open = false; + renderLightDom(): void { return; } renderControl(): TemplateResult { + if (this.layout === 'details') { + return html` +
{ + const details = evt.currentTarget as HTMLDetailsElement; + this.open = details.open; + if (!evt.composed) this.dispatchEvent(new CustomEvent('toggle')); + }} + > + +
+

+ ${this.label} + + ${svg``} + +

+

${this.helperText}

+
+
+
+ +
+
+ `; + } + return html`

${this.label}

From f6155b66fd51818f504a5cbe73895e434d99832e Mon Sep 17 00:00:00 2001 From: Daniil Bratukhin Date: Sat, 7 Dec 2024 04:50:15 -0300 Subject: [PATCH 03/13] fix(foxy-nucleon): don't override content type --- src/elements/public/NucleonElement/API.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/elements/public/NucleonElement/API.ts b/src/elements/public/NucleonElement/API.ts index dcac5adb..ed8a9b04 100644 --- a/src/elements/public/NucleonElement/API.ts +++ b/src/elements/public/NucleonElement/API.ts @@ -19,8 +19,10 @@ export class API extends CoreAPI { new Promise((resolve, reject) => { const request = typeof args[0] === 'string' ? new API.WHATWGRequest(...args) : args[0]; - request.headers.set('Content-Type', 'application/json'); request.headers.set('FOXY-API-VERSION', '1'); + if (!request.headers.has('Content-Type')) { + request.headers.set('Content-Type', 'application/json'); + } const event = new FetchEvent('fetch', { cancelable: true, From b9aa57378bb31a2623d327b59243ddb9affef24b Mon Sep 17 00:00:00 2001 From: Daniil Bratukhin Date: Sat, 7 Dec 2024 04:51:49 -0300 Subject: [PATCH 04/13] 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 d760d23f..56e62ef8 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 00000000..7fa4eb88 --- /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 00000000..52b1a7d1 --- /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 00000000..be8c76bb --- /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 00000000..9e0b3c40 --- /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 00000000..ce475815 --- /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 00000000..7d4df976 --- /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 00000000..3f28d0d3 --- /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 00000000..72959e6e --- /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 00000000..7d4df976 --- /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 00000000..0fb18d9f --- /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 00000000..0bd380d7 --- /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 00000000..ac81923c --- /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 00000000..d3af9d40 --- /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 31346bb5..527d90c7 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 1f100eea..61346265 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 38dd76a8..d215d6e7 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 47c3ade4..795cade2 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 2620166c..f2397198 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 350f41b6..75757aef 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 00000000..51133039 --- /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 63696664..f0e14c29 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', From bccae77567d91367af39586fab2c81b74ee654b1 Mon Sep 17 00:00:00 2001 From: Daniil Bratukhin Date: Wed, 11 Dec 2024 14:20:55 -0300 Subject: [PATCH 05/13] fix(foxy-customer-portal): hide customer form header Closes #179 --- .../CustomerPortal/InternalCustomerPortalLoggedOutView.test.ts | 2 +- .../CustomerPortal/InternalCustomerPortalLoggedOutView.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/elements/public/CustomerPortal/InternalCustomerPortalLoggedOutView.test.ts b/src/elements/public/CustomerPortal/InternalCustomerPortalLoggedOutView.test.ts index 73d58567..cfaca347 100644 --- a/src/elements/public/CustomerPortal/InternalCustomerPortalLoggedOutView.test.ts +++ b/src/elements/public/CustomerPortal/InternalCustomerPortalLoggedOutView.test.ts @@ -725,7 +725,7 @@ describe('InternalCustomerPortalLoggedOutView', () => { expect(form).to.have.attribute('disabledcontrols', 'abc:def'); expect(form).to.have.attribute( 'hiddencontrols', - 'tax-id is-anonymous password-old forgot-password timestamps delete qux' + 'header tax-id is-anonymous password-old forgot-password timestamps delete qux' ); expect(form).to.have.attribute('parent', 'foxy://customer-api/signup'); expect(form).to.have.attribute('group', 'test'); diff --git a/src/elements/public/CustomerPortal/InternalCustomerPortalLoggedOutView.ts b/src/elements/public/CustomerPortal/InternalCustomerPortalLoggedOutView.ts index f860937c..e409f530 100644 --- a/src/elements/public/CustomerPortal/InternalCustomerPortalLoggedOutView.ts +++ b/src/elements/public/CustomerPortal/InternalCustomerPortalLoggedOutView.ts @@ -341,6 +341,7 @@ export class InternalCustomerPortalLoggedOutView extends Base { private readonly __renderSignUpForm = () => { const scope = 'sign-up:form'; const hidden = [ + 'header', 'tax-id', 'is-anonymous', 'password-old', From 1e2ced888b155bfa958aa14232570b2982cee395 Mon Sep 17 00:00:00 2001 From: Daniil Bratukhin Date: Fri, 13 Dec 2024 17:22:55 -0300 Subject: [PATCH 06/13] internal(summary-control): add support for `section` layout --- .../InternalSummaryControl/InternalSummaryControl.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/elements/internal/InternalSummaryControl/InternalSummaryControl.ts b/src/elements/internal/InternalSummaryControl/InternalSummaryControl.ts index 9dd2aad0..c2ee5c58 100644 --- a/src/elements/internal/InternalSummaryControl/InternalSummaryControl.ts +++ b/src/elements/internal/InternalSummaryControl/InternalSummaryControl.ts @@ -17,7 +17,7 @@ export class InternalSummaryControl extends InternalEditableControl { return [ ...super.styles, css` - ::slotted(*) { + :host(:not([layout='section'])) slot::slotted(*) { background-color: var(--lumo-contrast-5pct); padding: calc(0.625em + (var(--lumo-border-radius) / 4) - 1px); } @@ -33,7 +33,7 @@ export class InternalSummaryControl extends InternalEditableControl { ]; } - layout: null | 'details' = null; + layout: null | 'section' | 'details' = null; open = false; @@ -60,13 +60,13 @@ export class InternalSummaryControl extends InternalEditableControl { ?hidden=${!this.label && !this.helperText} >

${this.label} ${svg``} From e0dcd1eaf3f96b4e7c64f94fd768729695a7742f Mon Sep 17 00:00:00 2001 From: Daniil Bratukhin Date: Fri, 13 Dec 2024 17:25:24 -0300 Subject: [PATCH 07/13] internal: add default item category to demo server --- src/server/hapi/createDataset.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/server/hapi/createDataset.ts b/src/server/hapi/createDataset.ts index d215d6e7..17a55a07 100644 --- a/src/server/hapi/createDataset.ts +++ b/src/server/hapi/createDataset.ts @@ -882,8 +882,8 @@ export const createDataset: () => Dataset = () => ({ store_id: 0, admin_email_template_uri: '', customer_email_template_uri: '', - code: `CATEGORY_${id}`, - name: `Test Category ${id}`, + code: id === 0 ? 'DEFAULT' : `CATEGORY_${id}`, + name: id === 0 ? 'Default Item Category' : `Test Category ${id}`, item_delivery_type: 'notshipped', max_downloads_per_customer: 3, max_downloads_time_period: 24, From a6f1320eebc751623165199b2b4bd6adb887eb4e Mon Sep 17 00:00:00 2001 From: Daniil Bratukhin Date: Fri, 13 Dec 2024 17:29:19 -0300 Subject: [PATCH 08/13] feat(foxy-experimental-add-to-cart-builder): various ui updates --- .../ExperimentalAddToCartBuilder.ts | 90 +- ...ExperimentalAddToCartBuilderItemControl.ts | 93 +- .../ExperimentalAddToCartBuilder/types.ts | 56 +- .../experimental-add-to-cart-builder/en.json | 856 +++++++++--------- 4 files changed, 594 insertions(+), 501 deletions(-) diff --git a/src/elements/public/ExperimentalAddToCartBuilder/ExperimentalAddToCartBuilder.ts b/src/elements/public/ExperimentalAddToCartBuilder/ExperimentalAddToCartBuilder.ts index 52b1a7d1..4c745e1f 100644 --- a/src/elements/public/ExperimentalAddToCartBuilder/ExperimentalAddToCartBuilder.ts +++ b/src/elements/public/ExperimentalAddToCartBuilder/ExperimentalAddToCartBuilder.ts @@ -7,6 +7,7 @@ import type { Data } from './types'; import { TranslatableMixin } from '../../../mixins/translatable'; import { ResponsiveMixin } from '../../../mixins/responsive'; +import { BooleanSelector } from '@foxy.io/sdk/core'; import { decode, encode } from 'html-entities'; import { InternalForm } from '../../internal/InternalForm/InternalForm'; import { previewCSS } from './preview.css'; @@ -94,14 +95,22 @@ export class ExperimentalAddToCartBuilder extends Base { private __openState: boolean[] = []; + get hiddenSelector(): BooleanSelector { + const alwaysMatch = [super.hiddenSelector.toString()]; + alwaysMatch.unshift('header:copy-id', 'header:copy-json', 'undo'); + return new BooleanSelector(alwaysMatch.join(' ').trim()); + } + 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.renderHeader()} +

-
+ ${this.form.items?.map((product, index) => { return html` { ?open=${ifDefined(this.__openState[index])} @toggle=${(evt: CustomEvent) => { const details = evt.currentTarget as InternalSummaryControl; - this.__openState[index] = details.open; - this.__openState = [...this.__openState]; + if (details.open) { + this.__openState = this.__openState.map((_, i) => i === index); + } else { + this.__openState[index] = details.open; + this.__openState = [...this.__openState]; + } }} > { store=${ifDefined(storeUrl)} index=${index} infer="" + .defaultItemCategory=${this.__defaultItemCategory} @remove=${() => { const newProducts = this.form.items?.filter((_, i) => i !== index); this.edit({ items: newProducts }); @@ -134,9 +148,10 @@ export class ExperimentalAddToCartBuilder extends Base { { - const newItem = { name: '', price: 0, custom_options: [] }; + const newItem = { name: '', price: 0, custom_options: [], sub_frequency: '1m' }; const existingItems = this.form.items ?? []; this.edit({ items: [...existingItems, newItem] }); this.__openState = [...new Array(existingItems.length).fill(false), true]; @@ -144,7 +159,7 @@ export class ExperimentalAddToCartBuilder extends Base { > -
+
${addToCartCode @@ -283,7 +298,10 @@ export class ExperimentalAddToCartBuilder extends Base { `} - + + + + { > + + ${this.form.cart !== 'checkout' + ? html` + + + + + ` + : ''} + ${super.renderBody()}
@@ -323,6 +351,15 @@ export class ExperimentalAddToCartBuilder extends Base { > + +
@@ -363,7 +366,7 @@ export class ExperimentalAddToCartBuilder extends Base {