diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000..7b449ba9 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,14 @@ +{ + "name": "foxy-elements", + "image": "mcr.microsoft.com/devcontainers/javascript-node:1-20-bookworm", + "features": { + "ghcr.io/rocker-org/devcontainer-features/apt-packages:1": { + "packages": "chromium" + } + }, + "mounts": [ + "source=${localEnv:HOME}/.ssh,target=/home/node/.ssh,type=bind,consistency=cached" + ], + "forwardPorts": [8000], + "postCreateCommand": "npm install" +} diff --git a/.gitignore b/.gitignore index 0cbef1c5..d30c7c93 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ ## editors /.idea /.nova +/.vscode ## system files .DS_Store diff --git a/package-lock.json b/package-lock.json index a81bfc19..af25d0e6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.0.0", "license": "MIT", "dependencies": { - "@foxy.io/sdk": "^1.15.0", + "@foxy.io/sdk": "^1.16.1", "@open-wc/lit-helpers": "^0.3.12", "@open-wc/scoped-elements": "^1.2.1", "@polymer/iron-icons": "^3.0.1", @@ -1822,9 +1822,9 @@ } }, "node_modules/@foxy.io/sdk": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/@foxy.io/sdk/-/sdk-1.15.0.tgz", - "integrity": "sha512-oEtp55dkQHbzRqml7fK2SStyQNdW9EroGnFwZq3SkWOsXf/pBiDtzfAwJVesm4BHEmHJ/dvj6Smb3IBn9nmVEg==", + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/@foxy.io/sdk/-/sdk-1.16.1.tgz", + "integrity": "sha512-noCyCbwbrnIL7jnIWduGD9e1oPeU3TbHPl1c/8MW7ceGVZ+op+h1eB3OlxbfenxoB9Z3qTZeGlhX7w7WOOvpDg==", "license": "MIT", "dependencies": { "@types/jsdom": "^16.2.5", diff --git a/package.json b/package.json index 5604a901..ba1d4b8d 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "prepack": "npm run lint && rimraf dist && node ./.build/compile-for-npm.js && rollup -c" }, "dependencies": { - "@foxy.io/sdk": "^1.15.0", + "@foxy.io/sdk": "^1.16.1", "@open-wc/lit-helpers": "^0.3.12", "@open-wc/scoped-elements": "^1.2.1", "@polymer/iron-icons": "^3.0.1", diff --git a/src/elements/public/AppliedCouponCodeForm/AppliedCouponCodeForm.test.ts b/src/elements/public/AppliedCouponCodeForm/AppliedCouponCodeForm.test.ts index e3edac97..c1fc59ec 100644 --- a/src/elements/public/AppliedCouponCodeForm/AppliedCouponCodeForm.test.ts +++ b/src/elements/public/AppliedCouponCodeForm/AppliedCouponCodeForm.test.ts @@ -5,7 +5,8 @@ import './index'; import { expect, fixture, html, waitUntil } from '@open-wc/testing'; import { AppliedCouponCodeForm as Form } from './AppliedCouponCodeForm'; -import { InternalCheckboxGroupControl } from '../../internal/InternalCheckboxGroupControl/InternalCheckboxGroupControl'; +import { InternalSummaryControl } from '../../internal/InternalSummaryControl/InternalSummaryControl'; +import { InternalSwitchControl } from '../../internal/InternalSwitchControl/InternalSwitchControl'; import { InternalTextControl } from '../../internal/InternalTextControl/InternalTextControl'; import { InternalForm } from '../../internal/InternalForm/InternalForm'; import { createRouter } from '../../../server'; @@ -13,9 +14,14 @@ import { getTestData } from '../../../testgen/getTestData'; import { stub } from 'sinon'; describe('AppliedCouponCodeForm', () => { - it('imports and registers foxy-internal-checkbox-group-control element', () => { - const constructor = customElements.get('foxy-internal-checkbox-group-control'); - expect(constructor).to.equal(InternalCheckboxGroupControl); + it('imports and registers foxy-internal-summary-control element', () => { + const constructor = customElements.get('foxy-internal-summary-control'); + expect(constructor).to.equal(InternalSummaryControl); + }); + + it('imports and registers foxy-internal-switch-control element', () => { + const constructor = customElements.get('foxy-internal-switch-control'); + expect(constructor).to.equal(InternalSwitchControl); }); it('imports and registers foxy-internal-text-control element', () => { @@ -91,26 +97,15 @@ describe('AppliedCouponCodeForm', () => { expect(control).to.have.property('helperText', 'code.helper_text_existing'); }); - it('renders a checkbox group control for "ignore_usage_limits" field in template state', async () => { + it('renders a switch control for "ignore_usage_limits" field in template state', async () => { const element = await fixture
( html`` ); - const control = element.renderRoot.querySelector( + const control = element.renderRoot.querySelector( '[infer="ignore-usage-limits"]' ); - const options = [{ value: 'checked', label: 'option_checked' }]; - - expect(control).to.be.instanceOf(InternalCheckboxGroupControl); - expect(control).to.have.deep.property('options', options); - - control?.setValue(['checked']); - expect(control?.getValue()).to.deep.equal(['checked']); - expect(element).to.have.nested.property('form.ignore_usage_limits', true); - - control?.setValue([]); - expect(control?.getValue()).to.deep.equal([]); - expect(element).to.have.nested.property('form.ignore_usage_limits', false); + expect(control).to.be.instanceOf(InternalSwitchControl); }); }); diff --git a/src/elements/public/AppliedCouponCodeForm/AppliedCouponCodeForm.ts b/src/elements/public/AppliedCouponCodeForm/AppliedCouponCodeForm.ts index 61f44ccb..971e4894 100644 --- a/src/elements/public/AppliedCouponCodeForm/AppliedCouponCodeForm.ts +++ b/src/elements/public/AppliedCouponCodeForm/AppliedCouponCodeForm.ts @@ -21,16 +21,6 @@ export class AppliedCouponCodeForm extends Base { return [({ code: v }) => !!v || 'code:v8n_required']; } - private readonly __ignoreUsageLimitsOptions = [{ value: 'checked', label: 'option_checked' }]; - - private readonly __getIgnoreUsageLimitsValue = () => { - return this.form.ignore_usage_limits ? ['checked'] : []; - }; - - private readonly __setIgnoreUsageLimitsValue = (newValue: string[]) => { - this.edit({ ignore_usage_limits: newValue.includes('checked') }); - }; - get readonlySelector(): BooleanSelector { return this.data ? new BooleanSelector('not=delete') : super.readonlySelector; } @@ -43,26 +33,21 @@ export class AppliedCouponCodeForm extends Base { return html` ${this.renderHeader()} - - - - ${this.data - ? '' - : html` - - - `} - - + + + + + ${this.data + ? '' + : html` + + + `} + ${super.renderBody()} `; diff --git a/src/elements/public/AppliedCouponCodeForm/index.ts b/src/elements/public/AppliedCouponCodeForm/index.ts index 0175871b..b93b2345 100644 --- a/src/elements/public/AppliedCouponCodeForm/index.ts +++ b/src/elements/public/AppliedCouponCodeForm/index.ts @@ -1,4 +1,5 @@ -import '../../internal/InternalCheckboxGroupControl/index'; +import '../../internal/InternalSummaryControl/index'; +import '../../internal/InternalSwitchControl/index'; import '../../internal/InternalTextControl/index'; import '../../internal/InternalForm/index'; diff --git a/src/elements/public/ClientForm/ClientForm.test.ts b/src/elements/public/ClientForm/ClientForm.test.ts index c004bf2f..4f26cea2 100644 --- a/src/elements/public/ClientForm/ClientForm.test.ts +++ b/src/elements/public/ClientForm/ClientForm.test.ts @@ -10,8 +10,8 @@ import { stub } from 'sinon'; import { Data } from './types'; describe('ClientForm', () => { - it('imports and defines foxy-internal-text-area-control', () => { - expect(customElements.get('foxy-internal-text-area-control')).to.exist; + it('imports and defines foxy-internal-summary-control', () => { + expect(customElements.get('foxy-internal-summary-control')).to.exist; }); it('imports and defines foxy-internal-text-control', () => { @@ -42,82 +42,108 @@ describe('ClientForm', () => { expect(renderHeaderMethod).to.have.been.called; }); + it('renders a foxy-internal-summary-control for general section', async () => { + const element = await fixture(html``); + const control = element.renderRoot.querySelector('[infer="general"]'); + expect(control).to.be.instanceOf(customElements.get('foxy-internal-summary-control')); + }); + + it('renders a foxy-internal-summary-control for project section', async () => { + const element = await fixture(html``); + const control = element.renderRoot.querySelector('[infer="project"]'); + expect(control).to.be.instanceOf(customElements.get('foxy-internal-summary-control')); + }); + + it('renders a foxy-internal-summary-control for company section', async () => { + const element = await fixture(html``); + const control = element.renderRoot.querySelector('[infer="company"]'); + expect(control).to.be.instanceOf(customElements.get('foxy-internal-summary-control')); + }); + + it('renders a foxy-internal-summary-control for contact section', async () => { + const element = await fixture(html``); + const control = element.renderRoot.querySelector('[infer="contact"]'); + expect(control).to.be.instanceOf(customElements.get('foxy-internal-summary-control')); + }); + it('renders a foxy-internal-text-control for client id', async () => { const element = await fixture(html``); - const control = element.renderRoot.querySelector('[infer="client-id"]'); + const control = element.renderRoot.querySelector('[infer="general"] [infer="client-id"]'); expect(control).to.be.instanceOf(customElements.get('foxy-internal-text-control')); }); it('renders a foxy-internal-text-control for client secret', async () => { const element = await fixture(html``); - const control = element.renderRoot.querySelector('[infer="client-secret"]'); + const control = element.renderRoot.querySelector('[infer="general"] [infer="client-secret"]'); expect(control).to.be.instanceOf(customElements.get('foxy-internal-text-control')); }); it('renders a foxy-internal-text-control for redirect uri', async () => { const element = await fixture(html``); - const control = element.renderRoot.querySelector('[infer="redirect-uri"]'); + const control = element.renderRoot.querySelector('[infer="general"] [infer="redirect-uri"]'); expect(control).to.be.instanceOf(customElements.get('foxy-internal-text-control')); }); it('renders a foxy-internal-text-control for project name', async () => { const element = await fixture(html``); - const control = element.renderRoot.querySelector('[infer="project-name"]'); + const control = element.renderRoot.querySelector('[infer="project"] [infer="project-name"]'); expect(control).to.be.instanceOf(customElements.get('foxy-internal-text-control')); }); - it('renders a foxy-internal-text-area-control for project description', async () => { + it('renders a foxy-internal-text-control for project description', async () => { const element = await fixture(html``); - const control = element.renderRoot.querySelector('[infer="project-description"]'); - expect(control).to.be.instanceOf(customElements.get('foxy-internal-text-area-control')); + const control = element.renderRoot.querySelector( + '[infer="project"] [infer="project-description"]' + ); + expect(control).to.be.instanceOf(customElements.get('foxy-internal-text-control')); }); it('renders a foxy-internal-text-control for company name', async () => { const element = await fixture(html``); - const control = element.renderRoot.querySelector('[infer="company-name"]'); + const control = element.renderRoot.querySelector('[infer="company"] [infer="company-name"]'); expect(control).to.be.instanceOf(customElements.get('foxy-internal-text-control')); }); it('renders a foxy-internal-text-control for company url', async () => { const element = await fixture(html``); - const control = element.renderRoot.querySelector('[infer="company-url"]'); + const control = element.renderRoot.querySelector('[infer="company"] [infer="company-url"]'); expect(control).to.be.instanceOf(customElements.get('foxy-internal-text-control')); }); it('renders a foxy-internal-text-control for company logo', async () => { const element = await fixture(html``); - const control = element.renderRoot.querySelector('[infer="company-logo"]'); + const control = element.renderRoot.querySelector('[infer="company"] [infer="company-logo"]'); expect(control).to.be.instanceOf(customElements.get('foxy-internal-text-control')); }); it('renders a foxy-internal-text-control for contact name', async () => { const element = await fixture(html``); - const control = element.renderRoot.querySelector('[infer="contact-name"]'); + const control = element.renderRoot.querySelector('[infer="contact"] [infer="contact-name"]'); expect(control).to.be.instanceOf(customElements.get('foxy-internal-text-control')); }); it('renders a foxy-internal-text-control for contact email', async () => { const element = await fixture(html``); - const control = element.renderRoot.querySelector('[infer="contact-email"]'); + const control = element.renderRoot.querySelector('[infer="contact"] [infer="contact-email"]'); expect(control).to.be.instanceOf(customElements.get('foxy-internal-text-control')); }); it('renders a foxy-internal-text-control for contact phone', async () => { const element = await fixture(html``); - const control = element.renderRoot.querySelector('[infer="contact-phone"]'); + const control = element.renderRoot.querySelector('[infer="contact"] [infer="contact-phone"]'); expect(control).to.be.instanceOf(customElements.get('foxy-internal-text-control')); }); it('always marks client secret control as readonly', async () => { const element = await fixture(html``); - expect(element.readonlySelector.matches('client-secret', true)).to.be.true; + expect(element.readonlySelector.matches('general:client-secret', true)).to.be.true; }); it('marks client id control as readonly when loaded', async () => { const element = await fixture(html``); - expect(element.readonlySelector.matches('client-id', true)).to.be.false; + expect(element.readonlySelector.matches('general:client-id', true)).to.be.false; element.data = await getTestData('./hapi/clients/0'); - expect(element.readonlySelector.matches('client-id', true)).to.be.true; + expect(element.readonlySelector.matches('general:client-id', true)).to.be.true; }); it('marks client id control as readonly when loading', async () => { @@ -126,15 +152,15 @@ describe('ClientForm', () => { router.handleEvent(evt)}> `); - expect(element.readonlySelector.matches('client-id', true)).to.be.false; + expect(element.readonlySelector.matches('general:client-id', true)).to.be.false; element.href = 'https://demo.api/virtual/stall'; await element.requestUpdate(); - expect(element.readonlySelector.matches('client-id', true)).to.be.true; + expect(element.readonlySelector.matches('general:client-id', true)).to.be.true; }); it('hides client secret control when empty', async () => { const element = await fixture(html``); - expect(element.hiddenSelector.matches('client-secret', true)).to.be.true; + expect(element.hiddenSelector.matches('general:client-secret', true)).to.be.true; }); it('uses custom options for header subtitle', async () => { diff --git a/src/elements/public/ClientForm/ClientForm.ts b/src/elements/public/ClientForm/ClientForm.ts index 02e52510..0fa790aa 100644 --- a/src/elements/public/ClientForm/ClientForm.ts +++ b/src/elements/public/ClientForm/ClientForm.ts @@ -27,14 +27,14 @@ export class ClientForm extends Base { } get readonlySelector(): BooleanSelector { - const alwaysMatch = ['client-secret']; - if (this.data || this.in({ busy: 'fetching' })) alwaysMatch.push('client-id'); + const alwaysMatch = ['general:client-secret']; + if (this.data || this.in({ busy: 'fetching' })) alwaysMatch.push('general:client-id'); return new BooleanSelector(`${alwaysMatch.join(' ')} ${super.readonlySelector.toString()}`); } get hiddenSelector(): BooleanSelector { const alwaysMatch: string[] = []; - if (!this.data && !this.in({ busy: 'fetching' })) alwaysMatch.push('client-secret'); + if (!this.data && !this.in({ busy: 'fetching' })) alwaysMatch.push('general:client-secret'); return new BooleanSelector(`${alwaysMatch.join(' ')} ${super.hiddenSelector.toString()}`); } @@ -42,34 +42,46 @@ export class ClientForm extends Base { return html` ${this.renderHeader()} -
- + + - + - + + - + + - - + + + + + + + - - + + - + + - + + - - -
+ + + + + + ${super.renderBody()} `; diff --git a/src/elements/public/ClientForm/index.ts b/src/elements/public/ClientForm/index.ts index b58ccf26..6f03595e 100644 --- a/src/elements/public/ClientForm/index.ts +++ b/src/elements/public/ClientForm/index.ts @@ -1,4 +1,4 @@ -import '../../internal/InternalTextAreaControl/index'; +import '../../internal/InternalSummaryControl/index'; import '../../internal/InternalTextControl/index'; import '../../internal/InternalForm/index'; diff --git a/src/elements/public/CollectionPages/CollectionPages.stories.mdx b/src/elements/public/CollectionPages/CollectionPages.stories.mdx index 5c038f71..28c03be6 100644 --- a/src/elements/public/CollectionPages/CollectionPages.stories.mdx +++ b/src/elements/public/CollectionPages/CollectionPages.stories.mdx @@ -104,7 +104,7 @@ Item render functions receive a context object with the following properties as - `lang` – same as `foxy-collection-pages[lang]`; - `href` – page's `_links.self.href` or a special value; - `data` - page object if available (e.g. when rendering attributes, it will be an attributes collection page); -- `html` - tag function from [lit-html](https://lit-html.polymer-project.org/guide#lit-html-templates). Render function is expected to return the output of this tag function. +- `html` - tag function from lit-html. Render function is expected to return the output of this tag function. ## Special URLs and loading states diff --git a/src/elements/public/CouponCodesForm/CouponCodesForm.ts b/src/elements/public/CouponCodesForm/CouponCodesForm.ts index 19a2c924..e290b52e 100644 --- a/src/elements/public/CouponCodesForm/CouponCodesForm.ts +++ b/src/elements/public/CouponCodesForm/CouponCodesForm.ts @@ -60,8 +60,7 @@ export class CouponCodesForm extends Base { } protected async _sendPost(edits: Partial): Promise { - const body = JSON.stringify(edits); - const data = await this._fetch(this.parent, { body, method: 'POST' }); + const data = await super._sendPost(edits, { mode: 'action' }); this.status = { key: 'success' }; return data; } diff --git a/src/elements/public/CustomerPortal/guides/adding-pages.stories.mdx b/src/elements/public/CustomerPortal/guides/adding-pages.stories.mdx index e9338181..bd3c5c55 100644 --- a/src/elements/public/CustomerPortal/guides/adding-pages.stories.mdx +++ b/src/elements/public/CustomerPortal/guides/adding-pages.stories.mdx @@ -56,7 +56,7 @@ If you run this code, you'll need to log in to see the links. It works! ## Styling -Let's add some CSS to make it look a bit better. Every template is rendered in an isolated Shadow DOM, so you'll need to link or define your styles **inside of the template**. Quick tip: use [Lumo](https://demo.vaadin.com/lumo-editor/) to make your custom content look native to the portal. +Let's add some CSS to make it look a bit better. Every template is rendered in an isolated Shadow DOM, so you'll need to link or define your styles **inside of the template**. Quick tip: use Lumo to make your custom content look native to the portal. ```html @@ -83,7 +83,7 @@ Now our navigation uses the primary theme color, medium spacing and font weight ## Adding custom page -For our orders page, let's copy the contents of `index.html` and paste them into a new page – for example, `orders.html`. On that page, we'll want to replace the default portal content (transactions, addresses, etc) with our own. Customer portal comes with a handy feature that can help us with that: **configurable controls**. Almost every section, input or button in the portal can be hidden with [BooleanSelector](https://sdk.foxy.dev/classes/_core_index_.booleanselector.html) in the `hiddencontrols` attribute. Let's write one and keep only customer name with the logout button: +For our orders page, let's copy the contents of `index.html` and paste them into a new page – for example, `orders.html`. On that page, we'll want to replace the default portal content (transactions, addresses, etc) with our own. Customer portal comes with a handy feature that can help us with that: **configurable controls**. Almost every section, input or button in the portal can be hidden with BooleanSelector in the `hiddencontrols` attribute. Let's write one and keep only customer name with the logout button: ```html Lit to create a web component loading a promo product from the Fake Store API project. Lit is a great starting point if it's your first time working with custom elements, plus you can play with it right in your browser – no compilation step needed. ### Defining an element diff --git a/src/elements/public/CustomerPortalSettingsForm/CustomerPortalSettingsForm.test.ts b/src/elements/public/CustomerPortalSettingsForm/CustomerPortalSettingsForm.test.ts index 1844c88c..2f2a4f88 100644 --- a/src/elements/public/CustomerPortalSettingsForm/CustomerPortalSettingsForm.test.ts +++ b/src/elements/public/CustomerPortalSettingsForm/CustomerPortalSettingsForm.test.ts @@ -1,4 +1,5 @@ -import type { InternalCheckboxGroupControl } from '../../internal/InternalCheckboxGroupControl/InternalCheckboxGroupControl'; +import type { InternalSwitchControl } from '../../internal/InternalSwitchControl/InternalSwitchControl'; +import type { InternalSummaryControl } from '../../internal/InternalSummaryControl/InternalSummaryControl'; import type { InternalEditableListControl } from '../../internal/InternalEditableListControl/InternalEditableListControl'; import type { InternalFrequencyControl } from '../../internal/InternalFrequencyControl/InternalFrequencyControl'; import type { InternalAsyncListControl } from '../../internal/InternalAsyncListControl/InternalAsyncListControl'; @@ -11,8 +12,12 @@ import { expect, fixture, html } from '@open-wc/testing'; import { stub } from 'sinon'; describe('CustomerPortalSettingsForm', () => { - it('imports and defines foxy-internal-checkbox-group-control', () => { - expect(customElements.get('foxy-internal-checkbox-group-control')).to.exist; + it('imports and defines foxy-internal-switch-control', () => { + expect(customElements.get('foxy-internal-switch-control')).to.exist; + }); + + it('imports and defines foxy-internal-summary-control', () => { + expect(customElements.get('foxy-internal-summary-control')).to.exist; }); it('imports and defines foxy-internal-editable-list-control', () => { @@ -182,12 +187,10 @@ describe('CustomerPortalSettingsForm', () => { expect(form.hiddenSelector.matches('header:copy-id', true)).to.be.true; }); - it('hides sign-up verification settings when sign-up is disabled', () => { + it('hides hcaptcha settings when sign-up is disabled', () => { const form = new Form(); - expect(form.hiddenSelector.matches('sign-up-verification-hcaptcha-site-key', true)).to.be.true; - expect(form.hiddenSelector.matches('sign-up-verification-hcaptcha-secret-key', true)).to.be - .true; + expect(form.hiddenSelector.matches('hcaptcha', true)).to.be.true; form.edit({ signUp: { @@ -200,9 +203,7 @@ describe('CustomerPortalSettingsForm', () => { }, }); - expect(form.hiddenSelector.matches('sign-up-verification-hcaptcha-site-key', true)).to.be.false; - expect(form.hiddenSelector.matches('sign-up-verification-hcaptcha-secret-key', true)).to.be - .false; + expect(form.hiddenSelector.matches('hcaptcha', true)).to.be.false; }); it('hides frequency modification rules when the list is empty', () => { @@ -275,80 +276,139 @@ describe('CustomerPortalSettingsForm', () => { expect(element).to.have.deep.nested.property('form.allowedOrigins', ['https://example.com']); }); - it('renders checkbox group control for portal features', async () => { + it('renders summary control for portal features', async () => { const element = await fixture( html`` ); - const control = element.renderRoot.querySelector( - 'foxy-internal-checkbox-group-control[infer="features"]' + const control = element.renderRoot.querySelector( + 'foxy-internal-summary-control[infer="features"]' )!; expect(control).to.exist; - expect(control.getValue()).to.deep.equal([]); - expect(control).to.have.deep.property('options', [ - { value: 'sso', label: 'option_sso' }, - { value: 'sign-up', label: 'option_sign_up' }, - { value: 'frequency-modification', label: 'option_frequency_modification' }, - { value: 'next-date-modification', label: 'option_next_date_modification' }, - ]); + }); + + it('renders switch control for SSO feature', async () => { + const element = await fixture( + html`` + ); - element.edit({ sso: true }); - expect(control.getValue()).to.deep.equal(['sso']); - control.setValue([]); + const control = element.renderRoot.querySelector( + 'foxy-internal-summary-control[infer="features"] foxy-internal-switch-control[infer="sso"]' + )!; + + expect(control).to.exist; + expect(control.getValue()).to.equal(false); + + control.setValue(true); + expect(element).to.have.nested.property('form.sso', true); + + control.setValue(false); expect(element).to.have.nested.property('form.sso', false); + }); + + it('renders switch control for sign-up feature', async () => { + const element = await fixture( + html`` + ); + + const control = element.renderRoot.querySelector( + 'foxy-internal-summary-control[infer="features"] foxy-internal-switch-control[infer="sign-up"]' + )!; + + expect(control).to.exist; + expect(control.getValue()).to.equal(false); + + control.setValue(true); + expect(element).to.have.nested.property('form.signUp.enabled', true); element.undo(); - element.edit({ - signUp: { - enabled: true, - verification: { - secretKey: '', - siteKey: '', - type: 'hcaptcha', - }, - }, - }); + expect(control.getValue()).to.equal(false); - expect(control.getValue()).to.deep.equal(['sign-up']); - control.setValue([]); + control.setValue(false); expect(element).to.have.nested.property('form.signUp.enabled', false); + }); + + it('renders switch control for frequency modification feature', async () => { + const element = await fixture( + html`` + ); + + const control = element.renderRoot.querySelector( + 'foxy-internal-summary-control[infer="features"] foxy-internal-switch-control[infer="frequency-modification"]' + )!; + + expect(control).to.exist; + expect(control.getValue()).to.equal(false); + + control.setValue(true); + expect(element).to.have.deep.nested.property('form.subscriptions.allowFrequencyModification', [ + { jsonataQuery: '*', values: [] }, + ]); element.undo(); - element.edit({ - subscriptions: { - allowFrequencyModification: [{ jsonataQuery: '*', values: ['1w'] }], - allowNextDateModification: false, - }, - }); + expect(control.getValue()).to.equal(false); - expect(control.getValue()).to.deep.equal(['frequency-modification']); - control.setValue([]); + control.setValue(false); expect(element).to.have.deep.nested.property( 'form.subscriptions.allowFrequencyModification', [] ); + }); + + it('renders switch control for next date modification feature', async () => { + const element = await fixture( + html`` + ); + + const control = element.renderRoot.querySelector( + 'foxy-internal-summary-control[infer="features"] foxy-internal-switch-control[infer="next-date-modification"]' + )!; + + expect(control).to.exist; + expect(control.getValue()).to.equal(false); + + control.setValue(true); + expect(element).to.have.nested.property('form.subscriptions.allowNextDateModification'); element.undo(); - element.edit({ - subscriptions: { - allowFrequencyModification: [], - allowNextDateModification: true, - }, - }); + expect(control.getValue()).to.equal(false); - expect(control.getValue()).to.deep.equal(['next-date-modification']); - control.setValue([]); + control.setValue(false); expect(element).to.have.nested.property('form.subscriptions.allowNextDateModification', false); }); + it('renders summary control for security settings', async () => { + const element = await fixture( + html`` + ); + + const control = element.renderRoot.querySelector( + 'foxy-internal-summary-control[infer="security"]' + )!; + + expect(control).to.exist; + }); + + it('renders summary control for hcaptcha settings', async () => { + const element = await fixture( + html`` + ); + + const control = element.renderRoot.querySelector( + 'foxy-internal-summary-control[infer="hcaptcha"]' + )!; + + expect(control).to.exist; + }); + it('renders password control for hcaptcha site key', async () => { const element = await fixture( html`` ); const control = element.renderRoot.querySelector( - 'foxy-internal-password-control[infer="sign-up-verification-hcaptcha-site-key"]' + '[infer="hcaptcha"] foxy-internal-password-control[infer="sign-up-verification-hcaptcha-site-key"]' )!; expect(control).to.exist; @@ -365,7 +425,7 @@ describe('CustomerPortalSettingsForm', () => { ); const control = element.renderRoot.querySelector( - 'foxy-internal-password-control[infer="sign-up-verification-hcaptcha-secret-key"]' + '[infer="hcaptcha"] foxy-internal-password-control[infer="sign-up-verification-hcaptcha-secret-key"]' )!; expect(control).to.exist; @@ -434,7 +494,7 @@ describe('CustomerPortalSettingsForm', () => { ); const control = element.renderRoot.querySelector( - 'foxy-internal-frequency-control[infer="session-lifespan-in-minutes"]' + 'foxy-internal-summary-control[infer="security"] foxy-internal-frequency-control[infer="session-lifespan-in-minutes"]' )!; expect(control).to.exist; @@ -456,7 +516,7 @@ describe('CustomerPortalSettingsForm', () => { ); const control = element.renderRoot.querySelector( - 'foxy-internal-password-control[infer="jwt-shared-secret"]' + 'foxy-internal-summary-control[infer="security"] foxy-internal-password-control[infer="jwt-shared-secret"]' )!; expect(control).to.exist; diff --git a/src/elements/public/CustomerPortalSettingsForm/CustomerPortalSettingsForm.ts b/src/elements/public/CustomerPortalSettingsForm/CustomerPortalSettingsForm.ts index 75b34271..4d31a2e5 100644 --- a/src/elements/public/CustomerPortalSettingsForm/CustomerPortalSettingsForm.ts +++ b/src/elements/public/CustomerPortalSettingsForm/CustomerPortalSettingsForm.ts @@ -95,46 +95,61 @@ export class CustomerPortalSettingsForm extends Base { }); }; - private readonly __featuresOptions = [ - { value: 'sso', label: 'option_sso' }, - { value: 'sign-up', label: 'option_sign_up' }, - { value: 'frequency-modification', label: 'option_frequency_modification' }, - { value: 'next-date-modification', label: 'option_next_date_modification' }, - ]; - - private readonly __featuresGetValue = () => { - const features: string[] = []; - - if (this.form.sso) features.push('sso'); - if (this.form.signUp?.enabled) features.push('sign-up'); - if (this.form.subscriptions?.allowNextDateModification) features.push('next-date-modification'); - if (this.form.subscriptions?.allowFrequencyModification.length) { - features.push('frequency-modification'); - } + private readonly __ssoGetValue = () => { + return this.form.sso ?? false; + }; - return features; + private readonly __ssoSetValue = (value: boolean) => { + this.edit({ sso: value }); }; - private readonly __featuresSetValue = (features: string[]) => { - const fmod = this.form.subscriptions?.allowFrequencyModification ?? []; + private readonly __signUpGetValue = () => { + return this.form.signUp?.enabled ?? false; + }; + private readonly __signUpSetValue = (value: boolean) => { this.edit({ - sso: features.includes('sso'), signUp: { - enabled: features.includes('sign-up'), + enabled: value, verification: this.form.signUp?.verification ?? { type: 'hcaptcha', siteKey: '', secretKey: '', }, }, + }); + }; + + private readonly __frequencyModificationGetValue = () => { + return (this.form.subscriptions?.allowFrequencyModification.length ?? 0) > 0; + }; + + private readonly __frequencyModificationSetValue = (value: boolean) => { + const fmod = this.form.subscriptions?.allowFrequencyModification ?? []; + + this.edit({ subscriptions: { - allowFrequencyModification: features.includes('frequency-modification') + allowFrequencyModification: value ? fmod.length === 0 ? [{ jsonataQuery: '*', values: [] }] : fmod : [], - allowNextDateModification: features.includes('next-date-modification') + allowNextDateModification: this.form.subscriptions?.allowNextDateModification ?? false, + }, + }); + }; + + private readonly __nextDateModificationGetValue = () => { + return this.form.subscriptions?.allowNextDateModification ?? false; + }; + + private readonly __nextDateModificationSetValue = (value: boolean) => { + const fmod = this.form.subscriptions?.allowFrequencyModification ?? []; + + this.edit({ + subscriptions: { + allowFrequencyModification: fmod, + allowNextDateModification: value ? this.form.subscriptions?.allowNextDateModification || [] : false, }, @@ -184,10 +199,7 @@ export class CustomerPortalSettingsForm extends Base { const alwaysMatch = ['header:copy-id', super.hiddenSelector.toString()]; if (!this.form.signUp?.enabled) { - alwaysMatch.push( - 'sign-up-verification-hcaptcha-site-key', - 'sign-up-verification-hcaptcha-secret-key' - ); + alwaysMatch.push('hcaptcha'); } if (!this.form.subscriptions?.allowFrequencyModification.length) { @@ -212,28 +224,52 @@ export class CustomerPortalSettingsForm extends Base { > - - - - - - - - + + + + + + + + + + + + + + + + + + + + { > - - - - - + + + + + + + ${super.renderBody()} `; diff --git a/src/elements/public/CustomerPortalSettingsForm/index.ts b/src/elements/public/CustomerPortalSettingsForm/index.ts index 6f002c38..30020461 100644 --- a/src/elements/public/CustomerPortalSettingsForm/index.ts +++ b/src/elements/public/CustomerPortalSettingsForm/index.ts @@ -1,4 +1,5 @@ -import '../../internal/InternalCheckboxGroupControl/index'; +import '../../internal/InternalSwitchControl/index'; +import '../../internal/InternalSummaryControl/index'; import '../../internal/InternalEditableListControl/index'; import '../../internal/InternalAsyncListControl/index'; import '../../internal/InternalFrequencyControl/index'; diff --git a/src/elements/public/Donation/Donation.stories.mdx b/src/elements/public/Donation/Donation.stories.mdx index 98c4f63b..e9be5e34 100644 --- a/src/elements/public/Donation/Donation.stories.mdx +++ b/src/elements/public/Donation/Donation.stories.mdx @@ -466,13 +466,13 @@ You can insert your own content in a number of pre-defined places called slots. By default the upon submission user is redirected directly to the checkout. This is controlled by the `cart` attribute. Set it to `add` to make the form merely add the donation to the cart. -[Please, refer to the documentation for more details](https://wiki.foxycart.com/v/2.0/cheat_sheet#transaction_non-product_specific_options). +Please, refer to the documentation for more details. ### Clearing other items in the cart The `empty` attribute can be set to 'true' to clear any contents in the cart before adding the donation. -[Please, refer to the documentation for more details](https://wiki.foxycart.com/v/2.0/cheat_sheet#transaction_non-product_specific_options). +Please, refer to the documentation for more details. ## API Reference @@ -480,7 +480,7 @@ The `empty` attribute can be set to 'true' to clear any contents in the cart bef ## Theming -Our elements are built with Vaadin Lumo theme and therefore share the list of CSS Custom Properties with it. You can find the latest documentation and theme editor on [demo.vaadin.com](https://demo.vaadin.com/lumo-editor/). +Our elements are built with Vaadin Lumo theme and therefore share the list of CSS Custom Properties with it. You can find the latest documentation and theme editor on demo.vaadin.com. ## Troubleshooting diff --git a/src/elements/public/FilterAttributeForm/FilterAttributeForm.test.ts b/src/elements/public/FilterAttributeForm/FilterAttributeForm.test.ts index 4f32189e..21afe97f 100644 --- a/src/elements/public/FilterAttributeForm/FilterAttributeForm.test.ts +++ b/src/elements/public/FilterAttributeForm/FilterAttributeForm.test.ts @@ -244,6 +244,50 @@ describe('FilterAttributeForm', () => { expect(button).to.not.exist; }); + it('renders Reset button when appropriate', async () => { + const router = createRouter(); + const element = await fixture(html` + router.handleEvent(evt)} + > + + `); + + let caption = element.renderRoot.querySelector('foxy-i18n[infer="action"][key="reset"]'); + let button = caption?.closest('vaadin-button'); + expect(button).to.not.exist; + + element.edit({ value: '/stores/0/transactions?filter_name=my+filter' }); + await element.requestUpdate(); + caption = element.renderRoot.querySelector('foxy-i18n[infer="action"][key="reset"]'); + button = caption?.closest('vaadin-button'); + expect(button).to.exist; + + const undoMethod = stub(element, 'undo'); + button?.dispatchEvent(new CustomEvent('click')); + expect(undoMethod).to.have.been.calledOnce; + + undoMethod.restore(); + element.undo(); + await element.requestUpdate(); + caption = element.renderRoot.querySelector('foxy-i18n[infer="action"][key="reset"]'); + button = caption?.closest('vaadin-button'); + expect(button).to.not.exist; + + element.href = 'https://demo.api/hapi/store_attributes/0'; + await waitUntil(() => element.in('idle')); + caption = element.renderRoot.querySelector('foxy-i18n[infer="action"][key="reset"]'); + button = caption?.closest('vaadin-button'); + expect(button).to.not.exist; + + element.edit({ value: '/stores/0/transactions?filter_name=updated+filter' }); + await element.requestUpdate(); + caption = element.renderRoot.querySelector('foxy-i18n[infer="action"][key="reset"]'); + button = caption?.closest('vaadin-button'); + expect(button).to.exist; + }); + it('uses fixed visibility and attribute name when creating a resource', async () => { const layout = html` { : html` `} + ${hasChanges + ? html` + this.undo()} + > + + + ` + : ''} ${!hasValue || (!filterQuery && !hasData) ? '' : html` diff --git a/src/elements/public/GenerateCodesForm/GenerateCodesForm.test.ts b/src/elements/public/GenerateCodesForm/GenerateCodesForm.test.ts index adae1f38..3dee468d 100644 --- a/src/elements/public/GenerateCodesForm/GenerateCodesForm.test.ts +++ b/src/elements/public/GenerateCodesForm/GenerateCodesForm.test.ts @@ -8,8 +8,8 @@ import { GenerateCodesForm } from './GenerateCodesForm'; import { InternalForm } from '../../internal/InternalForm/InternalForm'; describe('GenerateCodesForm', () => { - it('imports and defines foxy-internal-integer-control', () => { - expect(customElements.get('foxy-internal-integer-control')).to.exist; + it('imports and defines foxy-internal-summary-control', () => { + expect(customElements.get('foxy-internal-summary-control')).to.exist; }); it('imports and defines foxy-internal-number-control', () => { @@ -98,31 +98,39 @@ describe('GenerateCodesForm', () => { expect(element.hiddenSelector.matches('example', true)).to.be.true; }); + it('renders a foxy-internal-summary-control for parameters section', async () => { + const element = await fixture( + html`` + ); + const control = element.renderRoot.querySelector('[infer="parameters"]'); + expect(control).to.be.instanceOf(customElements.get('foxy-internal-summary-control')); + }); + it('renders text control for prefix', async () => { const element = await fixture( html`` ); - const control = element.renderRoot.querySelector('foxy-internal-text-control[infer=prefix]'); + const control = element.renderRoot.querySelector('[infer="parameters"] [infer=prefix]'); expect(control).to.exist; }); - it('renders integer control for length', async () => { + it('renders number control for length', async () => { const element = await fixture( html`` ); - const control = element.renderRoot.querySelector('foxy-internal-integer-control[infer=length]'); + const control = element.renderRoot.querySelector('[infer="parameters"] [infer=length]'); expect(control).to.exist; expect(control).to.have.attribute('min', '1'); }); - it('renders integer control for number of codes', async () => { + it('renders number control for number of codes', async () => { const element = await fixture( html`` ); const control = element.renderRoot.querySelector( - 'foxy-internal-integer-control[infer=number-of-codes]' + '[infer="parameters"] [infer=number-of-codes]' ); expect(control).to.exist; @@ -134,7 +142,7 @@ describe('GenerateCodesForm', () => { html`` ); const control = element.renderRoot.querySelector( - 'foxy-internal-number-control[infer=current-balance]' + '[infer="parameters"] [infer=current-balance]' ); expect(control).to.exist; @@ -145,9 +153,7 @@ describe('GenerateCodesForm', () => { const element = await fixture( html`` ); - const control = element.renderRoot.querySelector( - 'foxy-internal-source-control[infer=example]' - ) as InternalSourceControl; + const control = element.renderRoot.querySelector('[infer=example]') as InternalSourceControl; expect(control).to.exist; expect(control.getValue()).to.equal('1V3BJ3\nP4YNSW\n7DGT4Q'); diff --git a/src/elements/public/GenerateCodesForm/GenerateCodesForm.ts b/src/elements/public/GenerateCodesForm/GenerateCodesForm.ts index 1a3a95ec..e2e5e7c9 100644 --- a/src/elements/public/GenerateCodesForm/GenerateCodesForm.ts +++ b/src/elements/public/GenerateCodesForm/GenerateCodesForm.ts @@ -52,15 +52,22 @@ export class GenerateCodesForm extends Base { renderBody(): TemplateResult { return html` - + + -
- - - -
+ + + + + + + + +
- @@ -69,8 +76,7 @@ export class GenerateCodesForm extends Base { } protected async _sendPost(edits: Partial): Promise { - const body = JSON.stringify(edits); - const data = await this._fetch(this.parent, { body, method: 'POST' }); + const data = await super._sendPost(edits, { mode: 'action' }); this.status = { key: 'success' }; return data; } diff --git a/src/elements/public/GenerateCodesForm/index.ts b/src/elements/public/GenerateCodesForm/index.ts index 0d4335b5..5370e1fd 100644 --- a/src/elements/public/GenerateCodesForm/index.ts +++ b/src/elements/public/GenerateCodesForm/index.ts @@ -1,6 +1,6 @@ -import '../../internal/InternalIntegerControl/index'; import '../../internal/InternalNumberControl/index'; import '../../internal/InternalSourceControl/index'; +import '../../internal/InternalSummaryControl/index'; import '../../internal/InternalTextControl/index'; import '../../internal/InternalForm/index'; diff --git a/src/elements/public/GiftCardCodeForm/GiftCardCodeForm.ts b/src/elements/public/GiftCardCodeForm/GiftCardCodeForm.ts index ce444a53..30c86410 100644 --- a/src/elements/public/GiftCardCodeForm/GiftCardCodeForm.ts +++ b/src/elements/public/GiftCardCodeForm/GiftCardCodeForm.ts @@ -44,7 +44,7 @@ export class GiftCardCodeForm extends Base { getTransactionPageHref: TransactionPageHrefGetter | null = null; /** Returns a `fx:customer` Resource URL for a Customer ID. */ - getCustomerHref: (id: number | string) => string = id => { + getCustomerHref: (id: number) => string = id => { return `https://api.foxycart.com/customers/${id}`; }; @@ -68,14 +68,20 @@ export class GiftCardCodeForm extends Base { }; private readonly __customerGetValue = () => { - const link = this.data?._links?.['fx:customer']?.href; - const id = this.form.customer_id; - return id === undefined ? link : this.getCustomerHref(id); + const selectedId = this.form.customer_id; + const isLinkingCustomer = typeof selectedId === 'number'; + const isUnlinkingCustomer = selectedId === null; + + return isLinkingCustomer + ? this.getCustomerHref(selectedId as number) + : isUnlinkingCustomer + ? undefined + : this.data?._links?.['fx:customer']?.href; }; private readonly __customerSetValue = (v: string) => { const id = getResourceId(v); - this.edit({ customer_id: typeof id === 'number' ? id : '' }); + this.edit({ customer_id: typeof id === 'number' ? id : null }); }; private readonly __customerFilters: Option[] = [ diff --git a/src/elements/public/GiftCardCodeForm/types.ts b/src/elements/public/GiftCardCodeForm/types.ts index 4a7335f8..512a517b 100644 --- a/src/elements/public/GiftCardCodeForm/types.ts +++ b/src/elements/public/GiftCardCodeForm/types.ts @@ -3,6 +3,4 @@ import type { Rels } from '@foxy.io/sdk/backend'; export type TransactionPageHrefGetter = (href: string) => string | null; -export type Data = Resource & { - customer_id?: number | string; -}; +export type Data = Resource; diff --git a/src/elements/public/GiftCardCodesForm/GiftCardCodesForm.ts b/src/elements/public/GiftCardCodesForm/GiftCardCodesForm.ts index 0b3d0bec..afc91bc9 100644 --- a/src/elements/public/GiftCardCodesForm/GiftCardCodesForm.ts +++ b/src/elements/public/GiftCardCodesForm/GiftCardCodesForm.ts @@ -62,8 +62,7 @@ export class GiftCardCodesForm extends Base { } protected async _sendPost(edits: Partial): Promise { - const body = JSON.stringify(edits); - const data = await this._fetch(this.parent, { body, method: 'POST' }); + const data = await super._sendPost(edits, { mode: 'action' }); this.status = { key: 'success' }; return data; } diff --git a/src/elements/public/I18n/I18n.stories.mdx b/src/elements/public/I18n/I18n.stories.mdx index aac305bd..7c162cae 100644 --- a/src/elements/public/I18n/I18n.stories.mdx +++ b/src/elements/public/I18n/I18n.stories.mdx @@ -9,7 +9,7 @@ import { html } from 'lit-html'; ## Features -I18n element interface mirrors the [translation function](https://www.i18next.com/translation-function/essentials#overview-options) of i18next: `key` for accessing keys, `options` for providing context, count, default value and more, and 2 additional attributes/properties for ease of use: `ns` for namespaces and `lang` for language. It can do everything an i18next instance can and more: +I18n element interface mirrors the translation function of i18next: `key` for accessing keys, `options` for providing context, count, default value and more, and 2 additional attributes/properties for ease of use: `ns` for namespaces and `lang` for language. It can do everything an i18next instance can and more: 1. Load JSON translations on demand; 2. Add, edit or remove translations at any time; @@ -38,7 +38,7 @@ customElements .i18next.addResource('en', 'shared', 'foo', 'Same foo, but in English'); ``` -Since `shared` and `en` are set as both default and fallback values for namespace and language respectively, our element should now resolve "foo" key to the provided value and render "Same foo, but in English" instead (check out [this i18next guide](https://www.i18next.com/how-to/add-or-load-translations#add-after-init) for more options). Each localizable element from this package has its own namespace that you can add your translations to. +Since `shared` and `en` are set as both default and fallback values for namespace and language respectively, our element should now resolve "foo" key to the provided value and render "Same foo, but in English" instead (check out this i18next guide for more options). Each localizable element from this package has its own namespace that you can add your translations to. Still, while having a JS interface can be very useful at times, it's often more practical to write translations in JSON files and store them separately – for example, every element in this Storybook operates exactly that way. I18n element comes with a backend plugin that allows us to load translation files from a custom location on demand. All we need to do is to tell it where to get them from by intercepting the `fetch` events: @@ -61,7 +61,7 @@ Whenever I18n element needs to load translations for a language or a namespace, {() => html``} -Cool! Let's see what it looks like in Spanish by setting the [lang attribute](https://developer.mozilla.org/en/docs/Web/HTML/Global_attributes/lang): +Cool! Let's see what it looks like in Spanish by setting the lang attribute: {() => html``} @@ -111,7 +111,7 @@ In the example above `foxy-i18n` will load namespaces `foo` and `shared`. Then i In this example `foxy-i18n` will look for the value of `baz.bar.demo` in `foo`. If we omit `simplify-ns-loading` attribute on the form, `foxy-i18n` will download the translation file for `baz` as well. -Finally, let's pass some [options](https://www.i18next.com/translation-function/essentials#overview-options) to our I18n element. For example, to display a date: +Finally, let's pass some options to our I18n element. For example, to display a date: `} diff --git a/src/elements/public/ItemForm/ItemForm.test.ts b/src/elements/public/ItemForm/ItemForm.test.ts index 753b2af7..48e19263 100644 --- a/src/elements/public/ItemForm/ItemForm.test.ts +++ b/src/elements/public/ItemForm/ItemForm.test.ts @@ -206,6 +206,22 @@ describe('ItemForm', () => { expect(form.hiddenSelector.matches('attributes', true)).to.be.false; }); + it('hides downloadable purchase section when item has no downloadable purchase', async () => { + const router = createRouter(); + const element = await fixture( + html` + router.handleEvent(evt)} + > + + ` + ); + + await waitUntil(() => element.in({ idle: 'snapshot' })); + expect(element.hiddenSelector.matches('downloadable-purchase', true)).to.be.true; + }); + it('renders a form header', () => { const form = new ItemForm(); const renderHeaderMethod = stub(form, 'renderHeader'); @@ -213,6 +229,81 @@ describe('ItemForm', () => { expect(renderHeaderMethod).to.have.been.called; }); + it('does not render header actions when item has no downloadable purchase', async () => { + const router = createRouter(); + // Use item 1 which doesn't have downloadable purchase by default + const element = await fixture( + html` + router.handleEvent(evt)} + > + + ` + ); + + await waitUntil(() => element.in({ idle: 'snapshot' })); + + const downloadLink = element.renderRoot.querySelector('a[href="about:blank"]'); + const copyButton = element.renderRoot.querySelector( + 'foxy-copy-to-clipboard[infer="actions copy-download-link"]' + ); + + expect(downloadLink).to.not.exist; + expect(copyButton).to.not.exist; + }); + + it('renders download link in header actions when item has downloadable purchase', async () => { + const router = createRouter(); + // Item 0 has downloadable purchase by default + const element = await fixture( + html` + router.handleEvent(evt)} + > + + ` + ); + + await waitUntil(() => element.in({ idle: 'snapshot' })); + + const downloadLink = element.renderRoot.querySelector( + 'a[data-testid="download-link"]' + ); + + expect(downloadLink).to.exist; + expect(downloadLink).to.have.attribute('href', 'about:blank'); + expect(downloadLink?.querySelector('foxy-i18n')).to.have.attribute('infer', 'actions download'); + expect(downloadLink?.querySelector('foxy-i18n')).to.have.attribute('key', 'label'); + }); + + it('renders copy download link button in header actions when item has downloadable purchase', async () => { + const router = createRouter(); + // Item 0 has downloadable purchase by default + const element = await fixture( + html` + router.handleEvent(evt)} + > + + ` + ); + + await waitUntil(() => element.in({ idle: 'snapshot' })); + + const copyButton = element.renderRoot.querySelector( + 'foxy-copy-to-clipboard[infer="actions copy-download-link"]' + ); + + expect(copyButton).to.exist; + expect(copyButton).to.have.attribute('text', 'about:blank'); + expect(copyButton).to.have.attribute('layout', 'text'); + expect(copyButton).to.have.attribute('theme', 'tertiary-inline'); + expect(copyButton).to.have.attribute('infer', 'actions copy-download-link'); + }); + it('uses custom header subtitle options', async () => { const element = await fixture(html``); const data = await getTestData('./hapi/items/0'); @@ -233,6 +324,143 @@ describe('ItemForm', () => { expect(control).to.exist; }); + it('renders downloadable purchase summary control', async () => { + const router = createRouter(); + // Item 0 has downloadable purchase by default + const element = await fixture( + html` + router.handleEvent(evt)} + > + + ` + ); + + await waitUntil(() => element.in({ idle: 'snapshot' })); + + const control = element.renderRoot.querySelector( + 'foxy-internal-summary-control[infer="downloadable-purchase"]' + ); + + expect(control).to.exist; + expect(control).to.have.attribute('layout', 'details'); + }); + + it('renders number of downloads in downloadable purchase section', async () => { + const router = createRouter(); + // Item 0 has downloadable purchase by default + const element = await fixture( + html` + router.handleEvent(evt)} + > + + ` + ); + + await waitUntil(() => element.in({ idle: 'snapshot' })); + + const section = element.renderRoot.querySelector('[infer="downloadable-purchase"]'); + const label = section?.querySelector('foxy-i18n[infer="number-of-downloads"][key="label"]'); + + // Find the paragraph containing the number-of-downloads i18n element + const paragraphs = Array.from(section?.querySelectorAll('p') ?? []); + const value = paragraphs + .filter(p => p.querySelector('foxy-i18n[infer="number-of-downloads"]')) + .map(p => p.querySelector('span.text-secondary'))[0]; + + expect(label).to.exist; + expect(value).to.exist; + expect(value?.textContent).to.equal('4'); + }); + + it('renders first download time in downloadable purchase section', async () => { + const router = createRouter(); + // Item 0 has downloadable purchase by default + const element = await fixture( + html` + router.handleEvent(evt)} + > + + ` + ); + + await waitUntil(() => element.in({ idle: 'snapshot' })); + + const section = element.renderRoot.querySelector('[infer="downloadable-purchase"]'); + const timeLabel = section?.querySelector('foxy-i18n[infer="first-download-time"][key="label"]'); + const timeValue = section?.querySelector('foxy-i18n[infer="first-download-time"][key="value"]'); + + expect(timeLabel).to.exist; + expect(timeValue).to.exist; + expect(timeValue).to.have.attribute('options'); + + const options = JSON.parse(timeValue?.getAttribute('options') ?? '{}'); + expect(options).to.have.property('value', '2025-11-15T09:30:00-0800'); + }); + + it('renders reset usage button in downloadable purchase section', async () => { + const router = createRouter(); + // Item 0 has downloadable purchase by default + const element = await fixture( + html` + router.handleEvent(evt)} + > + + ` + ); + + await waitUntil(() => element.in({ idle: 'snapshot' })); + + const section = element.renderRoot.querySelector('[infer="downloadable-purchase"]'); + const resetButton = section?.querySelector( + 'foxy-internal-post-action-control[infer="reset-usage"]' + ); + + expect(resetButton).to.exist; + expect(resetButton).to.have.attribute('theme', 'tertiary-inline'); + expect(resetButton).to.have.attribute('href', 'https://demo.api/virtual/empty?status=200'); + }); + + it('renders empty state message when downloadable purchase has no downloads', async () => { + const router = createRouter(); + + // Override downloadable purchase data with zero downloads + await router.handleRequest( + new Request('https://demo.api/hapi/downloadable_purchases/0', { + method: 'PATCH', + body: JSON.stringify({ number_of_downloads: 0 }), + }) + )?.handlerPromise; + + const element = await fixture( + html` + router.handleEvent(evt)} + > + + ` + ); + + await waitUntil(() => element.in({ idle: 'snapshot' })); + + const section = element.renderRoot.querySelector('[infer="downloadable-purchase"]'); + const emptyMessage = section?.querySelector('foxy-i18n[key="no_stats_text"]'); + const resetButton = section?.querySelector( + 'foxy-internal-post-action-control[infer="reset-usage"]' + ); + + expect(emptyMessage).to.exist; + expect(resetButton).to.not.exist; + }); + it('renders name as a control inside of the General summary', async () => { const element = await fixture(html``); const control = element.renderRoot.querySelector( @@ -315,10 +543,10 @@ describe('ItemForm', () => { expect(control).to.have.attribute('layout', 'summary-item'); }); - it('renders Subscriptions summary control', async () => { + it('renders Subscription summary control', async () => { const element = await fixture(html``); const control = element.renderRoot.querySelector( - 'foxy-internal-summary-control[infer="subscriptions"]' + 'foxy-internal-summary-control[infer="subscription"]' ); expect(control).to.exist; @@ -327,7 +555,7 @@ describe('ItemForm', () => { it('renders subscription frequency as a control inside of the Subscriptions summary', async () => { const element = await fixture(html``); const control = element.renderRoot.querySelector( - '[infer="subscriptions"] foxy-internal-frequency-control[infer="subscription-frequency"]' + '[infer="subscription"] foxy-internal-frequency-control[infer="subscription-frequency"]' ); expect(control).to.exist; @@ -337,7 +565,7 @@ describe('ItemForm', () => { it('renders subscription start date as a control inside of the Subscriptions summary', async () => { const element = await fixture(html``); const control = element.renderRoot.querySelector( - '[infer="subscriptions"] foxy-internal-date-control[infer="subscription-start-date"]' + '[infer="subscription"] foxy-internal-date-control[infer="subscription-start-date"]' ); expect(control).to.exist; @@ -347,7 +575,7 @@ describe('ItemForm', () => { it('renders subscription end date as a control inside of the Subscriptions summary', async () => { const element = await fixture(html``); const control = element.renderRoot.querySelector( - '[infer="subscriptions"] foxy-internal-date-control[infer="subscription-end-date"]' + '[infer="subscription"] foxy-internal-date-control[infer="subscription-end-date"]' ); expect(control).to.exist; diff --git a/src/elements/public/ItemForm/ItemForm.ts b/src/elements/public/ItemForm/ItemForm.ts index 80043a75..ad7123de 100644 --- a/src/elements/public/ItemForm/ItemForm.ts +++ b/src/elements/public/ItemForm/ItemForm.ts @@ -57,6 +57,8 @@ export class ItemForm extends TranslatableMixin(InternalForm, 'item-form') /** Link to `fx:store` this item belongs to. */ store: string | null = null; + private __downloadablePurchase: Resource | null = null; + private __itemsLink = ''; get headerSubtitleOptions(): Record { @@ -74,6 +76,7 @@ export class ItemForm extends TranslatableMixin(InternalForm, 'item-form') if (!this.__storeLoader?.data?.features_multiship) alwaysMatch.unshift('general:shipto'); if (this.data && !this.data.subscription_frequency) alwaysMatch.unshift('subscriptions'); + if (!this.__downloadablePurchase) alwaysMatch.unshift('downloadable-purchase'); if (!this.form.discount_name) alwaysMatch.unshift('discount:discount-builder'); if (!this.href) { alwaysMatch.unshift('discount-details', 'coupon-details', 'item-options', 'attributes'); @@ -82,6 +85,31 @@ export class ItemForm extends TranslatableMixin(InternalForm, 'item-form') return new BooleanSelector(alwaysMatch.join(' ').trim()); } + renderHeaderActions(): TemplateResult { + if (!this.__downloadablePurchase) return html``; + const downloadUrl = this.__downloadablePurchase?._links['fx:download_url'].href; + + return html` + + + + + + + `; + } + renderBody(): TemplateResult { return html` ${this.renderHeader()} @@ -106,7 +134,7 @@ export class ItemForm extends TranslatableMixin(InternalForm, 'item-form') - + @@ -115,7 +143,10 @@ export class ItemForm extends TranslatableMixin(InternalForm, 'item-form') + ${this.__renderDownloadablePurchaseSection()} + > - + @@ -137,7 +168,7 @@ export class ItemForm extends TranslatableMixin(InternalForm, 'item-form') - + @@ -149,7 +180,7 @@ export class ItemForm extends TranslatableMixin(InternalForm, 'item-form') - + const item = await super._sendGet(); + if (item._links['fx:downloadable_purchase']) { + this.__downloadablePurchase = await super._fetch>( + item._links['fx:downloadable_purchase'].href + ); + } + if (item._links['fx:subscription']) { const subscriptionHref = item._links['fx:subscription'].href; const subscription = await super._fetch(subscriptionHref); @@ -257,6 +297,47 @@ export class ItemForm extends TranslatableMixin(InternalForm, 'item-form') return this.renderRoot.querySelector('#storeLoader'); } + private __renderDownloadablePurchaseSection(): TemplateResult { + const download = this.__downloadablePurchase; + + return html` + + ${download?.number_of_downloads + ? html` +

+ + ${download.number_of_downloads} +

+ +

+ + + +

+ +
+ + +
+ ` + : html` +

+ +

+ `} +
+ `; + } + private __handleDiscountBuilderChange(evt: CustomEvent) { const builder = evt.currentTarget as DiscountBuilder; const value = builder.parsedValue; diff --git a/src/elements/public/ItemForm/index.ts b/src/elements/public/ItemForm/index.ts index d3259741..e6192574 100644 --- a/src/elements/public/ItemForm/index.ts +++ b/src/elements/public/ItemForm/index.ts @@ -1,4 +1,5 @@ import '../../internal/InternalResourcePickerControl/index'; +import '../../internal/InternalPostActionControl/index'; import '../../internal/InternalAsyncListControl/index'; import '../../internal/InternalFrequencyControl/index'; import '../../internal/InternalSummaryControl/index'; @@ -10,12 +11,14 @@ import '../../internal/InternalForm/index'; import '../DiscountDetailCard/index'; import '../CouponDetailCard/index'; import '../ItemCategoryCard/index'; +import '../CopyToClipboard/index'; import '../DiscountBuilder/index'; import '../ItemOptionCard/index'; import '../ItemOptionForm/index'; import '../NucleonElement/index'; import '../AttributeCard/index'; import '../AttributeForm/index'; +import '../I18n/index'; import { ItemForm } from './ItemForm'; diff --git a/src/elements/public/NucleonElement/NucleonElement.stories.mdx b/src/elements/public/NucleonElement/NucleonElement.stories.mdx index 1677642b..e866c14b 100644 --- a/src/elements/public/NucleonElement/NucleonElement.stories.mdx +++ b/src/elements/public/NucleonElement/NucleonElement.stories.mdx @@ -67,7 +67,7 @@ addEventListener('fetch', (event: Event) => { }); ``` -And here's how we can serve mock data from `foxy://mocks/attribute` during development (replace `MY_MOCK_ATTRIBUTE` with an attribute resource like [this one](https://api.foxycart.com/rels/attribute) and `LATENCY` with a network latency you're comfortable or need to test your elements with). By the way, this is exactly how most of the demos in this Storybook work: +And here's how we can serve mock data from `foxy://mocks/attribute` during development (replace `MY_MOCK_ATTRIBUTE` with an attribute resource like this one and `LATENCY` with a network latency you're comfortable or need to test your elements with). By the way, this is exactly how most of the demos in this Storybook work: ```ts addEventListener('fetch', (event: Event) => { @@ -113,7 +113,7 @@ class MyAttributeElement extends NucleonElement { } ``` -You can see the full list of available states [in Nucleon docs](https://sdk.foxy.dev/modules/_core_index_._core_nucleon_index_.html#states). Now our element will show "Nothing to display" message by default, switch to "Loading..." while fetching data, render name and value once data becomes available or "Failed to load" if response is non-2XX. +You can see the full list of available states in Nucleon docs. Now our element will show "Nothing to display" message by default, switch to "Loading..." while fetching data, render name and value once data becomes available or "Failed to load" if response is non-2XX. ### Editing @@ -202,13 +202,13 @@ And just like that we have a form that can both create new attributes and edit e ``` -If NucleonElement has "href" attribute, it will always try to load the resource at that URL. Without "href" it will skip loading and initialize in `idle.template` state immediately. Setting both "href" and "parent" won't change the loading order, but will make resource sync with [Rumour](https://sdk.foxy.dev/classes/_core_index_.rumour.html) more accurate. +If NucleonElement has "href" attribute, it will always try to load the resource at that URL. Without "href" it will skip loading and initialize in `idle.template` state immediately. Setting both "href" and "parent" won't change the loading order, but will make resource sync with Rumour more accurate. ### Resource sync So we have our form, we have our mock API, but what if we also have another element on the page (like ``) linked to the same resource (`foxy://mocks/attribute`)? Or worse, what if there's a customer element that loads attributes embedded into the customer object? If we make changes in ``, how do we tell all other elements about them? Answer: we spread a little Rumour. -[Rumour](https://sdk.foxy.dev/classes/_core_index_.rumour.html) keeps every NucleonElement on the page connected by sharing HAL+JSON resource updates as soon as they become available – on load, on update, on creation and on deletion. Whether top-level or deeply embedded, Rumour can reach and update every local copy of a resource or trigger a reload if necessary. Non-Nucleon elements as well as Vue, React, Svelte and other components can tap into that stream using `NucleonElement.Rumour(group)` method. And the best part is, it requires zero effort to get going: +Rumour keeps every NucleonElement on the page connected by sharing HAL+JSON resource updates as soon as they become available – on load, on update, on creation and on deletion. Whether top-level or deeply embedded, Rumour can reach and update every local copy of a resource or trigger a reload if necessary. Non-Nucleon elements as well as Vue, React, Svelte and other components can tap into that stream using `NucleonElement.Rumour(group)` method. And the best part is, it requires zero effort to get going: ```html diff --git a/src/elements/public/NucleonElement/NucleonElement.ts b/src/elements/public/NucleonElement/NucleonElement.ts index 3a7c94e4..8009a11d 100644 --- a/src/elements/public/NucleonElement/NucleonElement.ts +++ b/src/elements/public/NucleonElement/NucleonElement.ts @@ -357,27 +357,37 @@ export class NucleonElement extends InferrableMix return response.json(); } - /** POSTs to `element.parent`, shares response with the Rumour group and returns parsed JSON. */ - protected async _sendPost(edits: Partial): Promise { + protected async _sendPost( + edits: Partial, + options?: { mode: 'collection' | 'action' } + ): Promise { this.__destroyRumour(); let data: TData; try { const body = JSON.stringify(edits); - const postData = await this._fetch(this.parent, { body, method: 'POST' }); - const newOwnURL = new URL(postData._links.self.href); - const parentURL = new URL(this.parent); - const zoom = parentURL.searchParams.get('zoom'); - - if (zoom) newOwnURL.searchParams.set('zoom', zoom); - const newHref = newOwnURL.toString(); - data = await this._fetch(newHref); - const rumour = NucleonElement.Rumour(this.group); - const related = [...this.related, this.parent]; - rumour.share({ data, related, source: data._links.self.href }); + const postData = await this._fetch(this.parent, { body, method: 'POST' }); - this.__href = newHref; + if (options?.mode === 'action') { + data = postData; + rumour.share({ related: this.related, source: 'https://nucleon.element/void', data: null }); + } else { + const newOwnURL = new URL(postData._links.self.href); + const parentURL = new URL(this.parent); + const zoom = parentURL.searchParams.get('zoom'); + + if (zoom) newOwnURL.searchParams.set('zoom', zoom); + const newHref = newOwnURL.toString(); + + data = await this._fetch(newHref); + this.__href = newHref; + rumour.share({ + related: [...this.related, this.parent], + source: data._links.self.href, + data, + }); + } } finally { this.__createRumour(); } diff --git a/src/elements/public/PasskeyForm/PasskeyForm.test.ts b/src/elements/public/PasskeyForm/PasskeyForm.test.ts index 03d1e0a3..2b3b493a 100644 --- a/src/elements/public/PasskeyForm/PasskeyForm.test.ts +++ b/src/elements/public/PasskeyForm/PasskeyForm.test.ts @@ -4,10 +4,11 @@ import { html, expect, fixture } from '@open-wc/testing'; import { PasskeyForm as Form } from './PasskeyForm'; import { getTestData } from '../../../testgen/getTestData'; import { stub } from 'sinon'; +import uainfer from 'uainfer/src/uainfer.js'; describe('passkeyForm', () => { - it('imports and defines foxy-internal-text-area-control', () => { - expect(customElements.get('foxy-internal-text-area-control')).to.exist; + it('imports and defines foxy-internal-summary-control', () => { + expect(customElements.get('foxy-internal-summary-control')).to.exist; }); it('imports and defines foxy-internal-text-control', () => { @@ -48,17 +49,25 @@ describe('passkeyForm', () => { element.data = await getTestData('./hapi/passkeys/0'); await element.requestUpdate(); - const control = element.renderRoot.querySelector('[infer="credential-id"]'); + const control = element.renderRoot.querySelector('[infer="settings"] [infer=credential-id]'); expect(control).to.be.instanceOf(customElements.get('foxy-internal-text-control')); }); - it('renders a foxy-internal-text-area-control for last login user agent', async () => { + it('renders a foxy-internal-text-control with human readable last login user agent', async () => { const element = await fixture(html``); + const analyzeStub = stub(uainfer, 'analyze').returns({ toString: () => 'Stub Agent' }); element.data = await getTestData('./hapi/passkeys/0'); await element.requestUpdate(); - const control = element.renderRoot.querySelector('[infer="last-login-ua"]'); - expect(control).to.be.instanceOf(customElements.get('foxy-internal-text-area-control')); + const control = element.renderRoot.querySelector( + '[infer="settings"] [infer=last-login-ua]' + ) as HTMLElement; + expect(control).to.be.instanceOf(customElements.get('foxy-internal-text-control')); + + const input = control.shadowRoot?.querySelector('input') as HTMLInputElement; + expect(input?.value).to.equal('Stub Agent'); + expect(analyzeStub).to.have.been.calledWithExactly(element.data?.last_login_ua); + analyzeStub.restore(); }); it('renders a spinner in empty state element when empty', async () => { @@ -69,18 +78,8 @@ describe('passkeyForm', () => { expect(spinner).to.have.attribute('state', 'empty'); }); - it('always marks credential id as readonly', async () => { - const element = await fixture(html``); - expect(element.readonlySelector.matches('credential-id', true)).to.be.true; - }); - - it('always marks last login date as readonly', async () => { - const element = await fixture(html``); - expect(element.readonlySelector.matches('last-login-date', true)).to.be.true; - }); - - it('always marks last login user agent as readonly', async () => { + it('always marks settings section as readonly', async () => { const element = await fixture(html``); - expect(element.readonlySelector.matches('last-login-ua', true)).to.be.true; + expect(element.readonlySelector.matches('settings', true)).to.be.true; }); }); diff --git a/src/elements/public/PasskeyForm/PasskeyForm.ts b/src/elements/public/PasskeyForm/PasskeyForm.ts index e1a6d6e1..8f1e4e2d 100644 --- a/src/elements/public/PasskeyForm/PasskeyForm.ts +++ b/src/elements/public/PasskeyForm/PasskeyForm.ts @@ -7,6 +7,8 @@ import { BooleanSelector } from '@foxy.io/sdk/core'; import { InternalForm } from '../../internal/InternalForm/InternalForm'; import { html } from 'lit-element'; +import uainfer from 'uainfer/src/uainfer.js'; + const NS = 'passkey-form'; const Base = ResponsiveMixin(TranslatableMixin(InternalForm, NS)); @@ -17,10 +19,12 @@ const Base = ResponsiveMixin(TranslatableMixin(InternalForm, NS)); * @since 1.24.0 */ export class PasskeyForm extends Base { + private readonly __lastLoginUaGetValue = () => { + return uainfer.analyze(this.data?.last_login_ua ?? '').toString(); + }; + get readonlySelector(): BooleanSelector { - return new BooleanSelector( - `credential-id last-login-date last-login-ua ${super.readonlySelector.toString()}` - ); + return new BooleanSelector(`settings ${super.readonlySelector.toString()}`); } renderBody(): TemplateResult { @@ -34,8 +38,20 @@ export class PasskeyForm extends Base { return html` ${this.renderHeader()} - - + + + + + + + ${super.renderBody()} `; } diff --git a/src/elements/public/PasskeyForm/index.ts b/src/elements/public/PasskeyForm/index.ts index d2989d92..6512937d 100644 --- a/src/elements/public/PasskeyForm/index.ts +++ b/src/elements/public/PasskeyForm/index.ts @@ -1,4 +1,4 @@ -import '../../internal/InternalTextAreaControl/index'; +import '../../internal/InternalSummaryControl/index'; import '../../internal/InternalTextControl/index'; import '../../internal/InternalForm/index'; import '../Spinner/index'; diff --git a/src/elements/public/ReportsTable/ReportsTable.test.ts b/src/elements/public/ReportsTable/ReportsTable.test.ts index 722ab518..e99771c8 100644 --- a/src/elements/public/ReportsTable/ReportsTable.test.ts +++ b/src/elements/public/ReportsTable/ReportsTable.test.ts @@ -53,8 +53,6 @@ describe('ReportsTable', () => { type Report = Data['_embedded']['fx:reports'][number]; const data = { ...(await getTestData('./hapi/reports/0')), name }; - // TODO remove ts-expect-error when SDK types are updated - // @ts-expect-error SDK types do not include "transactions" yet const layout = ReportsTable.nameColumn.cell!({ lang: 'es', ns: 'foo', data, html }); const cell = await fixture(layout as TemplateResult); diff --git a/src/elements/public/Table/Table.stories.mdx b/src/elements/public/Table/Table.stories.mdx index cc0f5dfb..f0a55ca2 100644 --- a/src/elements/public/Table/Table.stories.mdx +++ b/src/elements/public/Table/Table.stories.mdx @@ -59,7 +59,7 @@ The `columns` property is where the templates live. It's an array of objects, wh `column.header` is an optional function that receives the following context object and expects a template result (output of `context.html`) in return. If there's no `column.header`, table header row will have no cells. -- `html` - tag function from [lit-html](https://lit-html.polymer-project.org/guide#lit-html-templates); +- `html` - tag function from lit-html; - `data` - entire collection page, if available (`null` otherwise); - `lang` - same as `foxy-table[lang]`. @@ -67,7 +67,7 @@ The `columns` property is where the templates live. It's an array of objects, wh `cell` is an optional function that receives the following context object and expects a template result (output of `context.html`) in return. If there's no `column.cell`, table body rows will have no cells. -- `html` - tag function from [lit-html](https://lit-html.polymer-project.org/guide#lit-html-templates); +- `html` - tag function from lit-html; - `data` - collection page item; - `lang` - same as `foxy-table[lang]`. @@ -132,20 +132,20 @@ customElements.whenDefined('foxy-table').then(() => { This element supports the following hAPI resources. Any other HAL+JSON collection is allowed as well: -- [attributes](https://api.foxycart.com/rels/attributes) -- [users](https://api.foxycart.com/rels/users) -- [user_accesses](https://api.foxycart.com/rels/user_accesses) -- [customers](https://api.foxycart.com/rels/customers) -- [carts](https://api.foxycart.com/rels/carts) -- [items](https://api.foxycart.com/rels/items) -- [applied_coupon_codes](https://api.foxycart.com/rels/applied_coupon_codes) -- [transactions](https://api.foxycart.com/rels/transactions) -- [customer_addresses](https://api.foxycart.com/rels/customer_addresses) -- [coupon_details](https://api.foxycart.com/rels/coupon_details) -- [discount_details](https://api.foxycart.com/rels/discount_details) -- [item_options](https://api.foxycart.com/rels/item_options) -- [payments](https://api.foxycart.com/rels/payments) -- [applied_taxes](https://api.foxycart.com/rels/applied_taxes) +- attributes +- users +- user_accesses +- customers +- carts +- items +- applied_coupon_codes +- transactions +- customer_addresses +- coupon_details +- discount_details +- item_options +- payments +- applied_taxes - [custom_fields](https://api.foxycart.com/rels/custom_fields) - [discounts](https://api.foxycart.com/rels/discounts) - [shipments](https://api.foxycart.com/rels/shipments) diff --git a/src/elements/public/TemplateSetForm/TemplateSetForm.test.ts b/src/elements/public/TemplateSetForm/TemplateSetForm.test.ts index 167dcce2..763227ea 100644 --- a/src/elements/public/TemplateSetForm/TemplateSetForm.test.ts +++ b/src/elements/public/TemplateSetForm/TemplateSetForm.test.ts @@ -13,6 +13,7 @@ import { InternalTextControl } from '../../internal/InternalTextControl/Internal import { InternalSandbox } from '../../internal/InternalSandbox/InternalSandbox'; import { NucleonElement } from '../NucleonElement/NucleonElement'; import { InternalForm } from '../../internal/InternalForm/InternalForm'; +import { InternalSummaryControl } from '../../internal/InternalSummaryControl/InternalSummaryControl'; import { createRouter } from '../../../server'; import { getTestData } from '../../../testgen/getTestData'; import { getByTestId } from '../../../testgen/getByTestId'; @@ -56,6 +57,10 @@ describe('TemplateSetForm', () => { expect(customElements.get('foxy-nucleon')).to.equal(NucleonElement); }); + it('imports and registers foxy-internal-summary-control element', () => { + expect(customElements.get('foxy-internal-summary-control')).to.equal(InternalSummaryControl); + }); + it('imports and registers itself as foxy-template-set-form', () => { expect(customElements.get('foxy-template-set-form')).to.equal(Form); }); @@ -294,4 +299,108 @@ describe('TemplateSetForm', () => { expect(editor).to.not.exist; }); + + it('renders metadata summary control', async () => { + const router = createRouter(); + const element = await fixture(html` + router.handleEvent(evt)} + > + + `); + + await waitUntil(() => !!element.data, '', { timeout: 5000 }); + + const root = element.renderRoot; + const control = root.querySelector('[infer="metadata"]')!; + + expect(control).to.exist; + expect(control).to.be.instanceOf(InternalSummaryControl); + }); + + it('renders localization summary control', async () => { + const router = createRouter(); + const element = await fixture(html` + router.handleEvent(evt)} + > + + `); + + await waitUntil(() => !!element.data, '', { timeout: 5000 }); + + const root = element.renderRoot; + const control = root.querySelector('[infer="localization"]')!; + + expect(control).to.exist; + expect(control).to.be.instanceOf(InternalSummaryControl); + }); + + it('hides metadata section when code is DEFAULT', async () => { + const router = createRouter(); + const data = await getTestData('./hapi/template_sets/0', router); + const form = new Form(); + + form.data = { ...data, code: 'DEFAULT' }; + expect(form.hiddenSelector.matches('metadata', true)).to.be.true; + + form.data = { ...data, code: 'CUSTOM' }; + expect(form.hiddenSelector.matches('metadata', true)).to.be.false; + }); + + it('disables localization section when both language and locale code loaders have no data', async () => { + const router = createRouter(); + const element = await fixture(html` + router.handleEvent(evt)} + > + + `); + + await waitUntil(() => !!element.data, '', { timeout: 5000 }); + + // When both loaders don't have data, entire localization section should be disabled + expect(element.disabledSelector.matches('localization', true)).to.be.true; + }); + + it('disables only language when language loader has no data', async () => { + const router = createRouter(); + const element = await fixture(html` + router.handleEvent(evt)} + > + + `); + + await waitUntil(() => !!element.data, '', { timeout: 5000 }); + + // When only language loader is missing, only language field should be disabled + expect(element.disabledSelector.matches('localization:language', true)).to.be.true; + expect(element.disabledSelector.matches('localization', true)).to.be.false; + }); + + it('disables only locale code when locale code loader has no data', async () => { + const router = createRouter(); + const element = await fixture(html` + router.handleEvent(evt)} + > + + `); + + await waitUntil(() => !!element.data, '', { timeout: 5000 }); + + // When only locale code loader is missing, only locale-code field should be disabled + expect(element.disabledSelector.matches('localization:locale-code', true)).to.be.true; + expect(element.disabledSelector.matches('localization', true)).to.be.false; + }); }); diff --git a/src/elements/public/TemplateSetForm/TemplateSetForm.ts b/src/elements/public/TemplateSetForm/TemplateSetForm.ts index 7aca68ed..0beaf71d 100644 --- a/src/elements/public/TemplateSetForm/TemplateSetForm.ts +++ b/src/elements/public/TemplateSetForm/TemplateSetForm.ts @@ -69,14 +69,20 @@ export class TemplateSetForm extends Base { get disabledSelector(): BooleanSelector { const alwaysDisabled: string[] = []; - if (!this.__languagesLoader?.data) alwaysDisabled.push('language'); - if (!this.__localeCodesLoader?.data) alwaysDisabled.push('locale-code'); + const noLanguages = !this.__languagesLoader?.data; + const noLocaleCodes = !this.__localeCodesLoader?.data; + if (noLanguages && noLocaleCodes) { + alwaysDisabled.push('localization'); + } else { + if (noLanguages) alwaysDisabled.push('localization:language'); + if (noLocaleCodes) alwaysDisabled.push('localization:locale-code'); + } return new BooleanSelector(`${alwaysDisabled.join(' ')} ${super.disabledSelector}`); } get hiddenSelector(): BooleanSelector { const alwaysHidden: string[] = []; - if (this.data?.code === 'DEFAULT') alwaysHidden.push('delete', 'code', 'description'); + if (this.data?.code === 'DEFAULT') alwaysHidden.push('delete', 'metadata'); return new BooleanSelector(`${alwaysHidden.join(' ')} ${super.hiddenSelector}`); } @@ -90,17 +96,26 @@ export class TemplateSetForm extends Base { return html` ${this.renderHeader()} -
- + + - + + - + + - + -
+
{ const OriginalResizeObserver = window.ResizeObserver; @@ -861,6 +863,74 @@ describe('Transaction', () => { ); }); + it('renders transaction metadata when IP and user agent are present', async () => { + const router = createRouter(); + const element = await fixture(html` + router.handleEvent(evt)} + > + + `); + + await waitUntil(() => element.in({ idle: 'snapshot' })); + + const userAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'; + const analyzeStub = stub(uainfer, 'analyze').returns({ toString: () => 'Stub Agent' }); + + try { + element.data = { + ...element.data!, + customer_ip: '203.0.113.10', + ip_country: 'GB', + user_agent: userAgent, + }; + + await element.requestUpdate(); + + const metadata = element.renderRoot.querySelector('[infer="metadata"]'); + + expect(metadata).to.exist; + expect(metadata).to.have.property('localName', 'foxy-internal-summary-control'); + expect(metadata?.textContent).to.include('203.0.113.10 (GB)'); + expect(metadata?.textContent).to.include('Stub Agent'); + expect(analyzeStub).to.have.been.calledWith(userAgent); + } finally { + analyzeStub.restore(); + } + }); + + it('hides transaction metadata when IP or user agent is missing', async () => { + const router = createRouter(); + const element = await fixture(html` + router.handleEvent(evt)} + > + + `); + + await waitUntil(() => element.in({ idle: 'snapshot' })); + + element.data = { + ...element.data!, + customer_ip: '203.0.113.10', + user_agent: '', + }; + + await element.requestUpdate(); + expect(element.renderRoot.querySelector('[infer="metadata"]')).to.not.exist; + + element.data = { + ...element.data!, + customer_ip: '', + user_agent: 'Mozilla/5.0', + }; + + await element.requestUpdate(); + expect(element.renderRoot.querySelector('[infer="metadata"]')).to.not.exist; + }); + it('renders shipments as control', async () => { const router = createRouter(); const element = await fixture(html` diff --git a/src/elements/public/Transaction/Transaction.ts b/src/elements/public/Transaction/Transaction.ts index 11e17aff..7f04f307 100644 --- a/src/elements/public/Transaction/Transaction.ts +++ b/src/elements/public/Transaction/Transaction.ts @@ -12,6 +12,8 @@ import { InternalForm } from '../../internal/InternalForm/InternalForm'; import { ifDefined } from 'lit-html/directives/if-defined'; import { html, svg } from 'lit-element'; +import uainfer from 'uainfer/src/uainfer.js'; + const NS = 'transaction'; const Base = ResponsiveMixin(TranslatableMixin(InternalForm, NS)); @@ -354,6 +356,25 @@ export class Transaction extends Base { > + ${this.data && this.data.customer_ip && this.data.user_agent + ? html` + +

+ + + ${this.data.customer_ip} (${this.data.ip_country}) + +

+

+ + + ${uainfer.analyze(this.data.user_agent).toString()} + +

+
+ ` + : ''} + { - it('imports and defines foxy-internal-checkbox-group-control', () => { - expect(customElements.get('foxy-internal-checkbox-group-control')).to.exist; + it('imports and defines foxy-internal-summary-control', () => { + expect(customElements.get('foxy-internal-summary-control')).to.exist; + }); + + it('imports and defines foxy-internal-switch-control', () => { + expect(customElements.get('foxy-internal-switch-control')).to.exist; }); it('imports and defines foxy-internal-text-control', () => { @@ -154,26 +158,34 @@ describe('UserForm', () => { expect(control).to.exist; }); - it('renders a checkbox group control for role', async () => { + it('renders switch controls for roles', async () => { const element = await fixture(html``); - const control = element.renderRoot.querySelector( - 'foxy-internal-checkbox-group-control[infer=role]' - ) as InternalCheckboxGroupControl; - expect(control).to.exist; - expect(control).to.have.deep.property('options', [ - { label: 'option_merchant', value: 'merchant' }, - { label: 'option_backend_developer', value: 'backend_developer' }, - { label: 'option_frontend_developer', value: 'frontend_developer' }, - { label: 'option_designer', value: 'designer' }, - ]); + const merchantSwitch = element.renderRoot.querySelector( + 'foxy-internal-switch-control[infer=is-merchant]' + ) as InternalSwitchControl; + expect(merchantSwitch).to.exist; + + const backendSwitch = element.renderRoot.querySelector( + 'foxy-internal-switch-control[infer=is-programmer]' + ) as InternalSwitchControl; + expect(backendSwitch).to.exist; + + const frontendSwitch = element.renderRoot.querySelector( + 'foxy-internal-switch-control[infer=is-front-end-developer]' + ) as InternalSwitchControl; + expect(frontendSwitch).to.exist; - expect(control.getValue()).to.deep.equal([]); + const designerSwitch = element.renderRoot.querySelector( + 'foxy-internal-switch-control[infer=is-designer]' + ) as InternalSwitchControl; + expect(designerSwitch).to.exist; element.edit({ is_merchant: true, is_programmer: true }); - expect(control.getValue()).to.deep.equal(['merchant', 'backend_developer']); + expect(merchantSwitch.getValue()).to.equal(true); + expect(backendSwitch.getValue()).to.equal(true); - control.setValue(['frontend_developer']); - expect(element).to.have.nested.property('form.is_front_end_developer', true); + merchantSwitch.setValue(false); + expect(element).to.have.nested.property('form.is_merchant', false); }); }); diff --git a/src/elements/public/UserForm/UserForm.ts b/src/elements/public/UserForm/UserForm.ts index 28d59e89..05329040 100644 --- a/src/elements/public/UserForm/UserForm.ts +++ b/src/elements/public/UserForm/UserForm.ts @@ -30,33 +30,6 @@ export class UserForm extends Base { ]; } - private readonly __roleGetValue = () => { - const value: string[] = []; - - if (this.form.is_merchant) value.push('merchant'); - if (this.form.is_programmer) value.push('backend_developer'); - if (this.form.is_front_end_developer) value.push('frontend_developer'); - if (this.form.is_designer) value.push('designer'); - - return value; - }; - - private readonly __roleSetValue = (newValue: string[]) => { - this.edit({ - is_merchant: newValue.includes('merchant'), - is_programmer: newValue.includes('backend_developer'), - is_front_end_developer: newValue.includes('frontend_developer'), - is_designer: newValue.includes('designer'), - }); - }; - - private readonly __roleOptions = [ - { label: 'option_merchant', value: 'merchant' }, - { label: 'option_backend_developer', value: 'backend_developer' }, - { label: 'option_frontend_developer', value: 'frontend_developer' }, - { label: 'option_designer', value: 'designer' }, - ]; - get headerSubtitleOptions(): Record { return { ...super.headerSubtitleOptions, @@ -68,18 +41,26 @@ export class UserForm extends Base { return html` ${this.renderHeader()} - - - - + + + + + + + + + + + + + - - + + + + + + ${super.renderBody()} `; diff --git a/src/elements/public/UserForm/index.ts b/src/elements/public/UserForm/index.ts index cc8f6576..bd983ff7 100644 --- a/src/elements/public/UserForm/index.ts +++ b/src/elements/public/UserForm/index.ts @@ -1,4 +1,5 @@ -import '../../internal/InternalCheckboxGroupControl/index'; +import '../../internal/InternalSummaryControl/index'; +import '../../internal/InternalSwitchControl/index'; import '../../internal/InternalTextControl/index'; import '../../internal/InternalForm/index'; diff --git a/src/server/hapi/createDataset.ts b/src/server/hapi/createDataset.ts index db9076c9..662b1272 100644 --- a/src/server/hapi/createDataset.ts +++ b/src/server/hapi/createDataset.ts @@ -667,6 +667,7 @@ export const createDataset: () => Dataset = () => ({ store_id: 0, shipment_id: id > 4 ? 1 : 0, transaction_id: id > 4 ? 1 : 0, + downloadable_purchase_id: id === 0 ? 0 : null, subscription_id: 0, item_category_id: 0, item_category_uri: `https://demo.api/hapi/item_categories/${id}`, @@ -2207,4 +2208,20 @@ export const createDataset: () => Dataset = () => ({ date_modified: '2025-05-13T08:45:46-0700', }, ], + + downloadable_purchases: [ + { + id: 0, + store_id: 0, + customer_id: 0, + transaction_id: 0, + item_id: 0, + downloadable_id: 0, + number_of_downloads: 4, + first_download_time: '2025-11-15T09:30:00-0800', + download_passcode: 'QYLKcnhe9Q1nklCXRQJLFTpZxDLKgSX2HAG', + date_created: '2025-11-15T09:30:00-0800', + date_modified: '2025-11-15T09:30:00-0800', + }, + ], }); diff --git a/src/server/hapi/defaults.ts b/src/server/hapi/defaults.ts index 12427518..560e06cb 100644 --- a/src/server/hapi/defaults.ts +++ b/src/server/hapi/defaults.ts @@ -1117,4 +1117,18 @@ export const defaults: Defaults = { date_created: new Date().toISOString(), date_modified: new Date().toISOString(), }), + + downloadable_purchases: (query, dataset) => ({ + id: increment('downloadable_purchases', dataset), + store_id: parseInt(query.get('store_id') ?? '0'), + customer_id: parseInt(query.get('customer_id') ?? '0'), + transaction_id: parseInt(query.get('transaction_id') ?? '0'), + item_id: parseInt(query.get('item_id') ?? '0'), + downloadable_id: parseInt(query.get('downloadable_id') ?? '0'), + number_of_downloads: 0, + first_download_time: null, + download_passcode: 'QYLKcnhe9Q1nklCXRQJLFTpZxDLKgSX2HAG', + date_created: new Date().toISOString(), + date_modified: new Date().toISOString(), + }), }; diff --git a/src/server/hapi/links.ts b/src/server/hapi/links.ts index 355a37a5..55b7a86d 100644 --- a/src/server/hapi/links.ts +++ b/src/server/hapi/links.ts @@ -190,7 +190,15 @@ export const links: Links = { 'fx:process_subscription_webhook': { href: 'https://demo.api/virtual/empty?status=200' }, }), - items: ({ store_id, subscription_id, transaction_id, item_category_id, shipment_id, id }) => ({ + items: ({ + store_id, + subscription_id, + transaction_id, + item_category_id, + shipment_id, + downloadable_purchase_id: dp_id, + id, + }) => ({ 'fx:store': { href: `./stores/${store_id}` }, 'fx:shipment': { href: `./shipments/${shipment_id}` }, 'fx:attributes': { href: `./item_attributes?item_id=${id}` }, @@ -200,6 +208,10 @@ export const links: Links = { 'fx:coupon_details': { href: `./coupon_details?coupon_id=${id}` }, 'fx:discount_details': { href: `./discount_details?item_id=${id}` }, + ...(typeof dp_id === 'number' + ? { 'fx:downloadable_purchase': { href: `./downloadable_purchases/${dp_id}` } } + : {}), + ...(typeof subscription_id === 'number' ? { 'fx:subscription': { href: `./subscriptions/${subscription_id}` } } : {}), @@ -577,4 +589,20 @@ export const links: Links = { transaction_folders: ({ store_id }) => ({ 'fx:store': { href: `./stores/${store_id}` }, }), + + downloadable_purchases: ({ + store_id, + customer_id, + item_id, + transaction_id, + downloadable_id, + }) => ({ + 'fx:store': { href: `./stores/${store_id}` }, + 'fx:customer': { href: `./customers/${customer_id}` }, + 'fx:item': { href: `./items/${item_id}` }, + 'fx:transaction': { href: `./transactions/${transaction_id}` }, + 'fx:downloadable': { href: `./downloadables/${downloadable_id}` }, + 'fx:reset_usage': { href: 'https://demo.api/virtual/empty?status=200' }, + 'fx:download_url': { href: 'about:blank' }, + }), }; diff --git a/src/static/translations/admin-subscription-form/en.json b/src/static/translations/admin-subscription-form/en.json index 818a8318..afc11d5f 100644 --- a/src/static/translations/admin-subscription-form/en.json +++ b/src/static/translations/admin-subscription-form/en.json @@ -368,24 +368,24 @@ "transaction": { "header": { "title": "Transaction #{{ display_id }}", - "subtitle": "{{ transaction_date, date }} at {{ transaction_date, time }} • {{ ip_country }}", - "subtitle_customer_changed_payment_method": "{{ transaction_date, date }} at {{ transaction_date, time }} • {{ ip_country }} • Customer changed payment method", + "subtitle": "{{ transaction_date, date }} at {{ transaction_date, time }}", + "subtitle_customer_changed_payment_method": "{{ transaction_date, date }} at {{ transaction_date, time }} • Customer changed payment method", "subtitle_admin_changed_payment_method_with_uoe": "{{ transaction_date, date }} at {{ transaction_date, time }} • Store admin used UOE password to change payment method", "subtitle_integration_changed_payment_method": "{{ transaction_date, date }} at {{ transaction_date, time }} • Integration changed payment method", "subtitle_admin_changed_payment_method": "{{ transaction_date, date }} at {{ transaction_date, time }} • Store admin changed payment method", - "subtitle_customer_changed_subscription": "{{ transaction_date, date }} at {{ transaction_date, time }} • {{ ip_country }} • Customer made changes to subscription", + "subtitle_customer_changed_subscription": "{{ transaction_date, date }} at {{ transaction_date, time }} • Customer made changes to subscription", "subtitle_admin_changed_subscription_with_uoe": "{{ transaction_date, date }} at {{ transaction_date, time }} • Store admin used UOE password to make changes to subscription", "subtitle_integration_changed_subscription": "{{ transaction_date, date }} at {{ transaction_date, time }} • Integration made changes to subscription", "subtitle_admin_changed_subscription": "{{ transaction_date, date }} at {{ transaction_date, time }} • Store admin made changes to subscription", "subtitle_subscription_renewal_attempt": "{{ transaction_date, date }} at {{ transaction_date, time }} • Subscription renewal attempt", "subtitle_subscription_renewal_automated_reattempt": "{{ transaction_date, date }} at {{ transaction_date, time }} • Automated subscription renewal reattempt", "subtitle_subscription_renewal_manual_reattempt": "{{ transaction_date, date }} at {{ transaction_date, time }} • Store admin attempted to renew subscription", - "subtitle_customer_canceled_subscription": "{{ transaction_date, date }} at {{ transaction_date, time }} • {{ ip_country }} • Customer cancelled subscription", + "subtitle_customer_canceled_subscription": "{{ transaction_date, date }} at {{ transaction_date, time }} • Customer cancelled subscription", "subtitle_admin_canceled_subscription": "{{ transaction_date, date }} at {{ transaction_date, time }} • Store admin cancelled subscription", - "subtitle_customer_subscribed": "{{ transaction_date, date }} at {{ transaction_date, time }} • {{ ip_country }} • Customer bought a subscription", + "subtitle_customer_subscribed": "{{ transaction_date, date }} at {{ transaction_date, time }} • Customer bought a subscription", "subtitle_admin_subscribed_with_uoe": "{{ transaction_date, date }} at {{ transaction_date, time }} • Store admin used UOE password to buy a subscription", "subtitle_integration_subscribed": "{{ transaction_date, date }} at {{ transaction_date, time }} • Integration placed an order for customer", - "subtitle_customer_placed_order": "{{ transaction_date, date }} at {{ transaction_date, time }} • {{ ip_country }} • Customer placed an order", + "subtitle_customer_placed_order": "{{ transaction_date, date }} at {{ transaction_date, time }} • Customer placed an order", "subtitle_admin_placed_order_with_uoe": "{{ transaction_date, date }} at {{ transaction_date, time }} • Store admin used UOE password to place an order", "subtitle_integration_placed_order": "{{ transaction_date, date }} at {{ transaction_date, time }} • Integration bought a subscription", "alert_status_problem": "We were unable to complete this transaction because the amount that was sent to the payment gateway did not match the final total amount.", @@ -568,9 +568,9 @@ "placeholder": "Optional" } }, - "subscriptions": { + "subscription": { "label": "Subscription", - "helper_text": "To modify these value for an existing subscription, you must modify the subscription directly.", + "helper_text": "", "subscription-frequency": { "label": "Frequency", "helper_text": "", @@ -597,6 +597,49 @@ "placeholder": "None" } }, + "downloadable-purchase": { + "label": "Downloadable usage", + "helper_text": "", + "no_stats_text": "This item has not been downloaded yet. Download statistics will appear here once the item has been downloaded by a customer.", + "number-of-downloads": { + "label": "Number of downloads" + }, + "first-download-time": { + "label": "First download", + "value": "{{ value, date }} at {{ value, time }}" + }, + "download-link": { + "label": "Download file" + }, + "copy-download-link": { + "failed_to_copy": "Failed to copy", + "click_to_copy": "Copy URL", + "copying": "Copying...", + "done": "Copied to clipboard" + }, + "reset-usage": { + "message_idle": "This action will attempt to reset usage for this downloadable purchase. Would you like to proceed?", + "message_fail": "Failed to reset usage for this downloadable purchase. If you'd like to retry, close this dialog and click the reset usage button again.", + "message_done": "Usage for this downloadable purchase was reset successfully. You can close this dialog now.", + "button_close": "Close", + "button_confirm": "Reset usage", + "button_cancel": "Go back", + "loading_busy": "Processing", + "header": "Reset usage", + "button": "Reset usage" + } + }, + "actions": { + "download": { + "label": "Download file" + }, + "copy-download-link": { + "failed_to_copy": "Failed to copy", + "click_to_copy": "Copy download link", + "copying": "Copying...", + "done": "Copied to clipboard" + } + }, "dimensions": { "label": "Dimensions", "helper_text": "", @@ -625,13 +668,13 @@ "label": "Meta", "helper_text": "", "url": { - "label": "URL", - "helper_text": "Full URL for the customer to view this item on the store website.", + "label": "Website URL", + "helper_text": "", "placeholder": "None" }, "image": { - "label": "Image", - "helper_text": "URL of the image to display for this item in the cart and checkout.", + "label": "Image URL", + "helper_text": "", "placeholder": "None" }, "quantity-max": { @@ -645,8 +688,8 @@ "placeholder": "1" }, "expires": { - "label": "Expires", - "helper_text": "Date when this item will be removed from the cart.", + "label": "Expiry Date", + "helper_text": "", "display_value": "{{ value, date }}", "placeholder": "Optional" } @@ -678,6 +721,9 @@ }, "discount-details": { "label": "Applied discounts", + "helper_text": "", + "total_items": "{{ count }} discount", + "total_items_plural": "{{ count }} discounts", "pagination": { "first": "First", "last": "Last", @@ -698,6 +744,9 @@ }, "coupon-details": { "label": "Applied coupons", + "helper_text": "", + "total_items": "{{ count }} coupon", + "total_items_plural": "{{ count }} coupons", "pagination": { "first": "First", "last": "Last", @@ -718,6 +767,9 @@ }, "attributes": { "label": "Attributes", + "helper_text": "", + "total_items": "{{ count }} attribute", + "total_items_plural": "{{ count }} attributes", "delete_header": "Remove attribute?", "delete_message": "Please confirm that you'd like to remove this attribute from the item.", "delete_confirm": "Remove", @@ -821,6 +873,9 @@ }, "item-options": { "label": "Item options", + "helper_text": "", + "total_items": "{{ count }} option", + "total_items_plural": "{{ count }} options", "delete_header": "Remove item option?", "delete_message": "Please confirm that you'd like to remove this item option from the item.", "delete_confirm": "Remove", @@ -1160,6 +1215,12 @@ } } }, + "metadata": { + "label": "Metadata", + "helper_text": "", + "ip": { "label": "IP address" }, + "user-agent": { "label": "User agent" } + }, "webhooks": { "label": "Webhooks", "helper_text": "This list shows v2 webhooks only. Legacy v1 webhooks are available in Settings > Integrations and on admin.foxycart.com.", @@ -2139,6 +2200,10 @@ "archive": { "caption_archive": "Archive", "caption_unarchive": "Unarchive" + }, + "folder": { + "caption": "Move to folder", + "option_none": "None" } }, "spinner": { @@ -2166,4 +2231,4 @@ "loading_busy": "Loading", "loading_error": "Unknown error" } -} \ No newline at end of file +} diff --git a/src/static/translations/applied-coupon-code-form/en.json b/src/static/translations/applied-coupon-code-form/en.json index 3b662c4e..d4b7452c 100644 --- a/src/static/translations/applied-coupon-code-form/en.json +++ b/src/static/translations/applied-coupon-code-form/en.json @@ -24,9 +24,10 @@ "v8n_required": "Please enter a coupon code." }, "ignore-usage-limits": { - "label": "", + "label": "Ignore usage limits", "helper_text": "", - "option_checked": "Ignore usage limits" + "checked": "Yes", + "unchecked": "No" }, "delete": { "delete": "Remove", @@ -47,4 +48,4 @@ "loading_busy": "Loading", "loading_error": "Unknown error" } -} \ No newline at end of file +} diff --git a/src/static/translations/cart-form/en.json b/src/static/translations/cart-form/en.json index 0b13bf3e..a0112ebc 100644 --- a/src/static/translations/cart-form/en.json +++ b/src/static/translations/cart-form/en.json @@ -715,9 +715,9 @@ "placeholder": "Optional" } }, - "subscriptions": { + "subscription": { "label": "Subscription", - "helper_text": "To modify these value for an existing subscription, you must modify the subscription directly.", + "helper_text": "", "subscription-frequency": { "label": "Frequency", "helper_text": "", @@ -744,6 +744,49 @@ "placeholder": "None" } }, + "downloadable-purchase": { + "label": "Downloadable usage", + "helper_text": "", + "no_stats_text": "This item has not been downloaded yet. Download statistics will appear here once the item has been downloaded by a customer.", + "number-of-downloads": { + "label": "Number of downloads" + }, + "first-download-time": { + "label": "First download", + "value": "{{ value, date }} at {{ value, time }}" + }, + "download-link": { + "label": "Download file" + }, + "copy-download-link": { + "failed_to_copy": "Failed to copy", + "click_to_copy": "Copy URL", + "copying": "Copying...", + "done": "Copied to clipboard" + }, + "reset-usage": { + "message_idle": "This action will attempt to reset usage for this downloadable purchase. Would you like to proceed?", + "message_fail": "Failed to reset usage for this downloadable purchase. If you'd like to retry, close this dialog and click the reset usage button again.", + "message_done": "Usage for this downloadable purchase was reset successfully. You can close this dialog now.", + "button_close": "Close", + "button_confirm": "Reset usage", + "button_cancel": "Go back", + "loading_busy": "Processing", + "header": "Reset usage", + "button": "Reset usage" + } + }, + "actions": { + "download": { + "label": "Download file" + }, + "copy-download-link": { + "failed_to_copy": "Failed to copy", + "click_to_copy": "Copy download link", + "copying": "Copying...", + "done": "Copied to clipboard" + } + }, "dimensions": { "label": "Dimensions", "helper_text": "", @@ -772,13 +815,13 @@ "label": "Meta", "helper_text": "", "url": { - "label": "URL", - "helper_text": "Full URL for the customer to view this item on the store website.", + "label": "Website URL", + "helper_text": "", "placeholder": "None" }, "image": { - "label": "Image", - "helper_text": "URL of the image to display for this item in the cart and checkout.", + "label": "Image URL", + "helper_text": "", "placeholder": "None" }, "quantity-max": { @@ -792,8 +835,8 @@ "placeholder": "1" }, "expires": { - "label": "Expires", - "helper_text": "Date when this item will be removed from the cart.", + "label": "Expiry Date", + "helper_text": "", "display_value": "{{ value, date }}", "placeholder": "Optional" } @@ -825,6 +868,9 @@ }, "discount-details": { "label": "Applied discounts", + "helper_text": "", + "total_items": "{{ count }} discount", + "total_items_plural": "{{ count }} discounts", "pagination": { "first": "First", "last": "Last", @@ -845,6 +891,9 @@ }, "coupon-details": { "label": "Applied coupons", + "helper_text": "", + "total_items": "{{ count }} coupon", + "total_items_plural": "{{ count }} coupons", "pagination": { "first": "First", "last": "Last", @@ -865,6 +914,9 @@ }, "attributes": { "label": "Attributes", + "helper_text": "", + "total_items": "{{ count }} attribute", + "total_items_plural": "{{ count }} attributes", "delete_header": "Remove attribute?", "delete_message": "Please confirm that you'd like to remove this attribute from the item.", "delete_confirm": "Remove", @@ -968,6 +1020,9 @@ }, "item-options": { "label": "Item options", + "helper_text": "", + "total_items": "{{ count }} option", + "total_items_plural": "{{ count }} options", "delete_header": "Remove item option?", "delete_message": "Please confirm that you'd like to remove this item option from the item.", "delete_confirm": "Remove", @@ -1175,9 +1230,10 @@ "v8n_required": "Please enter a coupon code." }, "ignore-usage-limits": { - "label": "", + "label": "Ignore usage limits", "helper_text": "", - "option_checked": "Ignore usage limits" + "checked": "Yes", + "unchecked": "No" }, "delete": { "delete": "Remove", @@ -1436,4 +1492,4 @@ "loading_busy": "Loading", "loading_error": "Unknown error" } -} \ No newline at end of file +} diff --git a/src/static/translations/client-form/en.json b/src/static/translations/client-form/en.json index c65d4684..c440ca50 100644 --- a/src/static/translations/client-form/en.json +++ b/src/static/translations/client-form/en.json @@ -17,60 +17,76 @@ "done": "Copied to clipboard" } }, - "client-id": { - "label": "Client ID", - "placeholder": "", - "helper_text": "" - }, - "client-secret": { - "label": "Client secret", - "placeholder": "", - "helper_text": "" - }, - "project-name": { - "label": "Project name", - "placeholder": "", - "helper_text": "" - }, - "project-description": { - "label": "Project description", - "placeholder": "", - "helper_text": "" - }, - "redirect-uri": { - "label": "Redirect URI", - "placeholder": "", - "helper_text": "" - }, - "company-name": { - "label": "Company name", - "placeholder": "", - "helper_text": "" - }, - "company-url": { - "label": "Company url", - "placeholder": "", - "helper_text": "" - }, - "company-logo": { - "label": "Company logo", - "placeholder": "", - "helper_text": "" + "general": { + "label": "", + "helper_text": "", + "client-id": { + "label": "Client ID", + "placeholder": "", + "helper_text": "" + }, + "client-secret": { + "label": "Client secret", + "placeholder": "", + "helper_text": "" + }, + "redirect-uri": { + "label": "Redirect URI", + "placeholder": "", + "helper_text": "" + } }, - "contact-name": { - "label": "Contact name", - "placeholder": "", - "helper_text": "" + "project": { + "label": "Project", + "helper_text": "", + "project-name": { + "label": "Name", + "placeholder": "", + "helper_text": "" + }, + "project-description": { + "label": "Description", + "placeholder": "", + "helper_text": "" + } }, - "contact-email": { - "label": "Contact email", - "placeholder": "", - "helper_text": "" + "company": { + "label": "Company", + "helper_text": "", + "company-name": { + "label": "Name", + "placeholder": "", + "helper_text": "" + }, + "company-url": { + "label": "URL", + "placeholder": "", + "helper_text": "" + }, + "company-logo": { + "label": "Logo", + "placeholder": "", + "helper_text": "" + } }, - "contact-phone": { - "label": "Contact phone", - "placeholder": "", - "helper_text": "" + "contact": { + "label": "Contact", + "helper_text": "", + "contact-name": { + "label": "Name", + "placeholder": "", + "helper_text": "" + }, + "contact-email": { + "label": "Email", + "placeholder": "", + "helper_text": "" + }, + "contact-phone": { + "label": "Phone", + "placeholder": "", + "helper_text": "" + } }, "timestamps": { "date_created": "Created on", @@ -96,4 +112,4 @@ "loading_busy": "Loading", "loading_error": "Unknown error" } -} \ No newline at end of file +} diff --git a/src/static/translations/coupon-form/en.json b/src/static/translations/coupon-form/en.json index 5a1d9e60..e402481e 100644 --- a/src/static/translations/coupon-form/en.json +++ b/src/static/translations/coupon-form/en.json @@ -72,25 +72,29 @@ "error": { "invalid_form": "These parameters won't generate any codes. Please make sure that the length of the code is greater than the length of the prefix, does not exceed 50 characters in total and that the code itself does not contain spaces." }, - "length": { - "label": "Code length", - "placeholder": "6", - "helper_text": "" - }, - "number-of-codes": { - "label": "Number of codes", - "placeholder": "10", - "helper_text": "" - }, - "prefix": { - "label": "Prefix", - "placeholder": "Optional", - "helper_text": "The length value is inclusive of this prefix." - }, - "current-balance": { - "label": "Initial balance", - "placeholder": "0", - "helper_text": "" + "parameters": { + "label": "", + "helper_text": "", + "prefix": { + "label": "Prefix", + "placeholder": "Optional", + "helper_text": "The length value is inclusive of this prefix." + }, + "length": { + "label": "Code length", + "placeholder": "6", + "helper_text": "" + }, + "number-of-codes": { + "label": "Number of codes", + "placeholder": "10", + "helper_text": "" + }, + "current-balance": { + "label": "Initial balance", + "placeholder": "0", + "helper_text": "" + } }, "example": { "label": "Examples", @@ -689,4 +693,4 @@ "loading_busy": "Loading", "loading_error": "Unknown error" } -} \ No newline at end of file +} diff --git a/src/static/translations/customer-portal-settings-form/en.json b/src/static/translations/customer-portal-settings-form/en.json index 35685e09..a23d3234 100644 --- a/src/static/translations/customer-portal-settings-form/en.json +++ b/src/static/translations/customer-portal-settings-form/en.json @@ -10,19 +10,6 @@ "done": "Copied to clipboard" } }, - "session-lifespan-in-minutes": { - "label": "Session lifespan", - "option_minute": "Minute", - "option_minute_plural": "Minutes", - "option_hour": "Hour", - "option_hour_plural": "Hours", - "option_day": "Day", - "option_day_plural": "Days", - "option_week": "Week", - "option_week_plural": "Weeks", - "helper_text": "The maximum time a customer can be logged in without activity.", - "v8n_too_long": "We limit session lifespan to 4 weeks for security reasons. Please select a smaller value." - }, "allowed-origins": { "label": "Allowed origins", "helper_text": "The list of origins that are allowed to access the Customer Portal API. Must be https unless it's localhost. You can add up to 10 origins in this section.", @@ -30,33 +17,66 @@ "v8n_invalid": "Some of the origins you've entered are invalid. Please check the items marked with ⚠️ and correct them.", "v8n_too_many": "We support up to 10 origins. Please remove some of them." }, - "jwt-shared-secret": { - "label": "JWT shared secret", - "placeholder": "Leave empty to generate automatically", - "helper_text": "We use this key to sign customer JWTs. Changing it will drop all active sessions.", - "v8n_invalid": "Only letters (a-z), numbers (0-9) and dashes (-) are allowed in this field.", - "v8n_too_short": "This value must be at least 40 characters long.", - "v8n_too_long": "This value can't exceed 100 characters." - }, "features": { "label": "Features", - "option_sso": "SSO (single sign-on)", - "option_sign_up": "Customer registration", - "option_frequency_modification": "Frequency changes for subscriptions", - "option_next_date_modification": "Next payment date changes for subscriptions", - "helper_text": "" + "helper_text": "", + "sso": { + "label": "SSO (single sign-on)", + "helper_text": "" + }, + "sign-up": { + "label": "Customer registration", + "helper_text": "" + }, + "frequency-modification": { + "label": "Frequency changes for subscriptions", + "helper_text": "" + }, + "next-date-modification": { + "label": "Next payment date changes for subscriptions", + "helper_text": "" + } }, - "sign-up-verification-hcaptcha-site-key": { - "label": "hCaptcha site key", - "placeholder": "Provided by Foxy", - "helper_text": "By default, we use our own hCaptcha site key for all portal installs. If you have a hCaptcha account and would like to use your own key, enter it here.", - "v8n_too_long": "This value can't exceed 100 characters." + "hcaptcha": { + "label": "hCaptcha settings", + "helper_text": "By default, we use our own hCaptcha account for all portal installs. If you have a hCaptcha account and would like to use it, enter your keys here.", + "sign-up-verification-hcaptcha-site-key": { + "label": "Site key", + "placeholder": "Provided by Foxy", + "helper_text": "", + "v8n_too_long": "This value can't exceed 100 characters." + }, + "sign-up-verification-hcaptcha-secret-key": { + "label": "Secret key", + "placeholder": "Provided by Foxy", + "helper_text": "", + "v8n_too_long": "This value can't exceed 100 characters." + } }, - "sign-up-verification-hcaptcha-secret-key": { - "label": "hCaptcha secret key", - "placeholder": "Provided by Foxy", - "helper_text": "By default, we use our own hCaptcha secret key for all portal installs. If you have a hCaptcha account and would like to use your own key, enter it here.", - "v8n_too_long": "This value can't exceed 100 characters." + "security": { + "label": "Security settings", + "helper_text": "", + "session-lifespan-in-minutes": { + "label": "Session lifespan", + "option_minute": "Minute", + "option_minute_plural": "Minutes", + "option_hour": "Hour", + "option_hour_plural": "Hours", + "option_day": "Day", + "option_day_plural": "Days", + "option_week": "Week", + "option_week_plural": "Weeks", + "helper_text": "The maximum time a customer can be logged in without activity.", + "v8n_too_long": "We limit session lifespan to 4 weeks for security reasons. Please select a smaller value." + }, + "jwt-shared-secret": { + "label": "JWT shared secret", + "placeholder": "Generate automatically", + "helper_text": "We use this key to sign customer JWTs. Changing it will drop all active sessions.", + "v8n_invalid": "Only letters (a-z), numbers (0-9) and dashes (-) are allowed in this field.", + "v8n_too_short": "This value must be at least 40 characters long.", + "v8n_too_long": "This value can't exceed 100 characters." + } }, "subscriptions-allow-frequency-modification": { "label": "Frequency options", @@ -332,4 +352,4 @@ "loading_busy": "Loading", "loading_error": "Unknown error" } -} \ No newline at end of file +} diff --git a/src/static/translations/generate-codes-form/en.json b/src/static/translations/generate-codes-form/en.json index b8c1d598..b40276f4 100644 --- a/src/static/translations/generate-codes-form/en.json +++ b/src/static/translations/generate-codes-form/en.json @@ -5,25 +5,29 @@ "error": { "invalid_form": "These parameters won't generate any codes. Please make sure that the length of the code is greater than the length of the prefix, does not exceed 50 characters in total and that the code itself does not contain spaces." }, - "length": { - "label": "Code length", - "placeholder": "6", - "helper_text": "" - }, - "number-of-codes": { - "label": "Number of codes", - "placeholder": "10", - "helper_text": "" - }, - "prefix": { - "label": "Prefix", - "placeholder": "Optional", - "helper_text": "The length value is inclusive of this prefix." - }, - "current-balance": { - "label": "Initial balance", - "placeholder": "0", - "helper_text": "" + "parameters": { + "label": "", + "helper_text": "", + "prefix": { + "label": "Prefix", + "placeholder": "Optional", + "helper_text": "The length value is inclusive of this prefix." + }, + "length": { + "label": "Code length", + "placeholder": "6", + "helper_text": "" + }, + "number-of-codes": { + "label": "Number of codes", + "placeholder": "10", + "helper_text": "" + }, + "current-balance": { + "label": "Initial balance", + "placeholder": "0", + "helper_text": "" + } }, "example": { "label": "Examples", @@ -44,4 +48,4 @@ "loading_busy": "Loading", "loading_error": "Unknown error" } -} \ No newline at end of file +} diff --git a/src/static/translations/gift-card-code-form/en.json b/src/static/translations/gift-card-code-form/en.json index 63b2cbbb..22a33ab9 100644 --- a/src/static/translations/gift-card-code-form/en.json +++ b/src/static/translations/gift-card-code-form/en.json @@ -43,6 +43,7 @@ "customer": { "label": "Customer", "helper_text": "", + "clear": "Clear", "dialog": { "cancel": "Cancel", "close": "Close", diff --git a/src/static/translations/gift-card-form/en.json b/src/static/translations/gift-card-form/en.json index 98883ed2..dd50c6bd 100644 --- a/src/static/translations/gift-card-form/en.json +++ b/src/static/translations/gift-card-form/en.json @@ -78,25 +78,29 @@ "error": { "invalid_form": "These parameters won't generate any codes. Please make sure that the length of the code is greater than the length of the prefix, does not exceed 50 characters in total and that the code itself does not contain spaces." }, - "length": { - "label": "Code length", - "placeholder": "6", - "helper_text": "" - }, - "number-of-codes": { - "label": "Number of codes", - "placeholder": "10", - "helper_text": "" - }, - "prefix": { - "label": "Prefix", - "placeholder": "Optional", - "helper_text": "The length value is inclusive of this prefix." - }, - "current-balance": { - "label": "Initial balance", - "placeholder": "0", - "helper_text": "" + "parameters": { + "label": "", + "helper_text": "", + "prefix": { + "label": "Prefix", + "placeholder": "Optional", + "helper_text": "The length value is inclusive of this prefix." + }, + "length": { + "label": "Code length", + "placeholder": "6", + "helper_text": "" + }, + "number-of-codes": { + "label": "Number of codes", + "placeholder": "10", + "helper_text": "" + }, + "current-balance": { + "label": "Initial balance", + "placeholder": "0", + "helper_text": "" + } }, "example": { "label": "Examples", @@ -410,6 +414,7 @@ "customer": { "label": "Customer", "helper_text": "", + "clear": "Clear", "dialog": { "cancel": "Cancel", "close": "Close", @@ -796,4 +801,4 @@ "loading_busy": "Loading", "loading_error": "Unknown error" } -} \ No newline at end of file +} diff --git a/src/static/translations/item-form/en.json b/src/static/translations/item-form/en.json index 069c995e..8d290e70 100644 --- a/src/static/translations/item-form/en.json +++ b/src/static/translations/item-form/en.json @@ -98,9 +98,9 @@ "placeholder": "Optional" } }, - "subscriptions": { + "subscription": { "label": "Subscription", - "helper_text": "To modify these value for an existing subscription, you must modify the subscription directly.", + "helper_text": "", "subscription-frequency": { "label": "Frequency", "helper_text": "", @@ -127,6 +127,49 @@ "placeholder": "None" } }, + "downloadable-purchase": { + "label": "Downloadable usage", + "helper_text": "", + "no_stats_text": "This item has not been downloaded yet. Download statistics will appear here once the item has been downloaded by a customer.", + "number-of-downloads": { + "label": "Number of downloads" + }, + "first-download-time": { + "label": "First download", + "value": "{{ value, date }} at {{ value, time }}" + }, + "download-link": { + "label": "Download file" + }, + "copy-download-link": { + "failed_to_copy": "Failed to copy", + "click_to_copy": "Copy URL", + "copying": "Copying...", + "done": "Copied to clipboard" + }, + "reset-usage": { + "message_idle": "This action will attempt to reset usage for this downloadable purchase. Would you like to proceed?", + "message_fail": "Failed to reset usage for this downloadable purchase. If you'd like to retry, close this dialog and click the reset usage button again.", + "message_done": "Usage for this downloadable purchase was reset successfully. You can close this dialog now.", + "button_close": "Close", + "button_confirm": "Reset usage", + "button_cancel": "Go back", + "loading_busy": "Processing", + "header": "Reset usage", + "button": "Reset usage" + } + }, + "actions": { + "download": { + "label": "Download file" + }, + "copy-download-link": { + "failed_to_copy": "Failed to copy", + "click_to_copy": "Copy download link", + "copying": "Copying...", + "done": "Copied to clipboard" + } + }, "dimensions": { "label": "Dimensions", "helper_text": "", @@ -155,13 +198,13 @@ "label": "Meta", "helper_text": "", "url": { - "label": "URL", - "helper_text": "Full URL for the customer to view this item on the store website.", + "label": "Website URL", + "helper_text": "", "placeholder": "None" }, "image": { - "label": "Image", - "helper_text": "URL of the image to display for this item in the cart and checkout.", + "label": "Image URL", + "helper_text": "", "placeholder": "None" }, "quantity-max": { @@ -175,8 +218,8 @@ "placeholder": "1" }, "expires": { - "label": "Expires", - "helper_text": "Date when this item will be removed from the cart.", + "label": "Expiry Date", + "helper_text": "", "display_value": "{{ value, date }}", "placeholder": "Optional" } @@ -208,6 +251,9 @@ }, "discount-details": { "label": "Applied discounts", + "helper_text": "", + "total_items": "{{ count }} discount", + "total_items_plural": "{{ count }} discounts", "pagination": { "first": "First", "last": "Last", @@ -228,6 +274,9 @@ }, "coupon-details": { "label": "Applied coupons", + "helper_text": "", + "total_items": "{{ count }} coupon", + "total_items_plural": "{{ count }} coupons", "pagination": { "first": "First", "last": "Last", @@ -248,6 +297,9 @@ }, "attributes": { "label": "Attributes", + "helper_text": "", + "total_items": "{{ count }} attribute", + "total_items_plural": "{{ count }} attributes", "delete_header": "Remove attribute?", "delete_message": "Please confirm that you'd like to remove this attribute from the item.", "delete_confirm": "Remove", @@ -351,6 +403,9 @@ }, "item-options": { "label": "Item options", + "helper_text": "", + "total_items": "{{ count }} option", + "total_items_plural": "{{ count }} options", "delete_header": "Remove item option?", "delete_message": "Please confirm that you'd like to remove this item option from the item.", "delete_confirm": "Remove", @@ -485,4 +540,4 @@ "loading_busy": "Loading", "loading_error": "Unknown error" } -} \ No newline at end of file +} diff --git a/src/static/translations/passkey-form/en.json b/src/static/translations/passkey-form/en.json index 8cc63830..beaa97bc 100644 --- a/src/static/translations/passkey-form/en.json +++ b/src/static/translations/passkey-form/en.json @@ -16,15 +16,19 @@ "done": "Copied to clipboard" } }, - "credential-id": { - "label": "Credential ID", - "placeholder": "", - "helper_text": "Unique identifier of your passkey. You might be able to find this passkey by Credential ID in your password manager." - }, - "last-login-ua": { - "label": "Last browser", - "placeholder": "", - "helper_text": "User agent string of the browser that was last used to sign in to your Foxy Account with this passkey." + "settings": { + "label": "", + "helper_text": "", + "credential-id": { + "label": "ID", + "placeholder": "", + "helper_text": "" + }, + "last-login-ua": { + "label": "Last used in", + "placeholder": "", + "helper_text": "" + } }, "timestamps": { "date_created": "Created on", @@ -50,4 +54,4 @@ "loading_busy": "Loading", "loading_error": "Unknown error" } -} \ No newline at end of file +} diff --git a/src/static/translations/template-set-form/en.json b/src/static/translations/template-set-form/en.json index 208573b1..12218d7e 100644 --- a/src/static/translations/template-set-form/en.json +++ b/src/static/translations/template-set-form/en.json @@ -17,34 +17,44 @@ "done": "Copied to clipboard" } }, - "description": { - "label": "Description", - "placeholder": "Required - e.g. My Template Set", - "helper_text": "Any label that will help you identify this template set in the admin. We won't show this text to the customers.", - "v8n_required": "Please enter a description for this set", - "v8n_too_long": "Please reduce this description to 100 characters or less" - }, - "code": { - "label": "Code", - "placeholder": "Required - e.g. MY-TEMPLATE-SET", - "helper_text": "The template set code for applying this template set to the cart. Your customers might see this code in the URL.", - "v8n_required": "Please enter a code for this set", - "v8n_too_long": "Please reduce this code to 50 characters or less" - }, - "language": { - "label": "Language", - "placeholder": "Select language", - "helper_text": "We'll apply this language to our cart, checkout and receipt pages.", - "v8n_required": "Please select a language for this set" + "metadata": { + "label": "", + "helper_text": "", + "description": { + "label": "Description", + "placeholder": "Required", + "helper_text": "", + "v8n_required": "Please enter a description for this set", + "v8n_too_long": "Please reduce this description to 100 characters or less" + }, + "code": { + "label": "Code", + "placeholder": "Required", + "helper_text": "", + "v8n_required": "Please enter a code for this set", + "v8n_too_long": "Please reduce this code to 50 characters or less" + } }, - "locale-code": { - "label": "Locale", - "placeholder": "Select locale", - "helper_text": "We'll use this locale to format prices and dates for your customers.", - "v8n_required": "Please select a locale for this set" + "localization": { + "label": "", + "helper_text": "", + "language": { + "label": "Language", + "placeholder": "Select", + "helper_text": "", + "v8n_required": "Please select a language for this set" + }, + "locale-code": { + "label": "Locale", + "placeholder": "Select", + "helper_text": "", + "v8n_required": "Please select a locale for this set" + } }, "payment-method-set-uri": { "label": "Payment method set", + "helper_text": "", + "clear": "Clear", "dialog": { "cancel": "Cancel", "close": "Close", @@ -81,8 +91,7 @@ "loading_empty": "Select a payment method set", "loading_error": "Unknown error" } - }, - "helper_text": "With this template set applied, your customers will see payment methods from the selected set." + } }, "language-overrides": { "delete_button_title": "Restore default value", @@ -119,4 +128,4 @@ "loading_busy": "Loading", "loading_error": "Unknown error" } -} \ No newline at end of file +} diff --git a/src/static/translations/transaction/en.json b/src/static/translations/transaction/en.json index 2f471734..955e4fa1 100644 --- a/src/static/translations/transaction/en.json +++ b/src/static/translations/transaction/en.json @@ -1,24 +1,24 @@ { "header": { "title": "Transaction #{{ display_id }}", - "subtitle": "{{ transaction_date, date }} at {{ transaction_date, time }} • {{ ip_country }}", - "subtitle_customer_changed_payment_method": "{{ transaction_date, date }} at {{ transaction_date, time }} • {{ ip_country }} • Customer changed payment method", + "subtitle": "{{ transaction_date, date }} at {{ transaction_date, time }}", + "subtitle_customer_changed_payment_method": "{{ transaction_date, date }} at {{ transaction_date, time }} • Customer changed payment method", "subtitle_admin_changed_payment_method_with_uoe": "{{ transaction_date, date }} at {{ transaction_date, time }} • Store admin used UOE password to change payment method", "subtitle_integration_changed_payment_method": "{{ transaction_date, date }} at {{ transaction_date, time }} • Integration changed payment method", "subtitle_admin_changed_payment_method": "{{ transaction_date, date }} at {{ transaction_date, time }} • Store admin changed payment method", - "subtitle_customer_changed_subscription": "{{ transaction_date, date }} at {{ transaction_date, time }} • {{ ip_country }} • Customer made changes to subscription", + "subtitle_customer_changed_subscription": "{{ transaction_date, date }} at {{ transaction_date, time }} • Customer made changes to subscription", "subtitle_admin_changed_subscription_with_uoe": "{{ transaction_date, date }} at {{ transaction_date, time }} • Store admin used UOE password to make changes to subscription", "subtitle_integration_changed_subscription": "{{ transaction_date, date }} at {{ transaction_date, time }} • Integration made changes to subscription", "subtitle_admin_changed_subscription": "{{ transaction_date, date }} at {{ transaction_date, time }} • Store admin made changes to subscription", "subtitle_subscription_renewal_attempt": "{{ transaction_date, date }} at {{ transaction_date, time }} • Subscription renewal attempt", "subtitle_subscription_renewal_automated_reattempt": "{{ transaction_date, date }} at {{ transaction_date, time }} • Automated subscription renewal reattempt", "subtitle_subscription_renewal_manual_reattempt": "{{ transaction_date, date }} at {{ transaction_date, time }} • Store admin attempted to renew subscription", - "subtitle_customer_canceled_subscription": "{{ transaction_date, date }} at {{ transaction_date, time }} • {{ ip_country }} • Customer cancelled subscription", + "subtitle_customer_canceled_subscription": "{{ transaction_date, date }} at {{ transaction_date, time }} • Customer cancelled subscription", "subtitle_admin_canceled_subscription": "{{ transaction_date, date }} at {{ transaction_date, time }} • Store admin cancelled subscription", - "subtitle_customer_subscribed": "{{ transaction_date, date }} at {{ transaction_date, time }} • {{ ip_country }} • Customer bought a subscription", + "subtitle_customer_subscribed": "{{ transaction_date, date }} at {{ transaction_date, time }} • Customer bought a subscription", "subtitle_admin_subscribed_with_uoe": "{{ transaction_date, date }} at {{ transaction_date, time }} • Store admin used UOE password to buy a subscription", "subtitle_integration_subscribed": "{{ transaction_date, date }} at {{ transaction_date, time }} • Integration placed an order for customer", - "subtitle_customer_placed_order": "{{ transaction_date, date }} at {{ transaction_date, time }} • {{ ip_country }} • Customer placed an order", + "subtitle_customer_placed_order": "{{ transaction_date, date }} at {{ transaction_date, time }} • Customer placed an order", "subtitle_admin_placed_order_with_uoe": "{{ transaction_date, date }} at {{ transaction_date, time }} • Store admin used UOE password to place an order", "subtitle_integration_placed_order": "{{ transaction_date, date }} at {{ transaction_date, time }} • Integration bought a subscription", "alert_status_problem": "We were unable to complete this transaction because the amount that was sent to the payment gateway did not match the final total amount.", @@ -201,9 +201,9 @@ "placeholder": "Optional" } }, - "subscriptions": { + "subscription": { "label": "Subscription", - "helper_text": "To modify these value for an existing subscription, you must modify the subscription directly.", + "helper_text": "", "subscription-frequency": { "label": "Frequency", "helper_text": "", @@ -230,6 +230,49 @@ "placeholder": "None" } }, + "downloadable-purchase": { + "label": "Downloadable usage", + "helper_text": "", + "no_stats_text": "This item has not been downloaded yet. Download statistics will appear here once the item has been downloaded by a customer.", + "number-of-downloads": { + "label": "Number of downloads" + }, + "first-download-time": { + "label": "First download", + "value": "{{ value, date }} at {{ value, time }}" + }, + "download-link": { + "label": "Download file" + }, + "copy-download-link": { + "failed_to_copy": "Failed to copy", + "click_to_copy": "Copy URL", + "copying": "Copying...", + "done": "Copied to clipboard" + }, + "reset-usage": { + "message_idle": "This action will attempt to reset usage for this downloadable purchase. Would you like to proceed?", + "message_fail": "Failed to reset usage for this downloadable purchase. If you'd like to retry, close this dialog and click the reset usage button again.", + "message_done": "Usage for this downloadable purchase was reset successfully. You can close this dialog now.", + "button_close": "Close", + "button_confirm": "Reset usage", + "button_cancel": "Go back", + "loading_busy": "Processing", + "header": "Reset usage", + "button": "Reset usage" + } + }, + "actions": { + "download": { + "label": "Download file" + }, + "copy-download-link": { + "failed_to_copy": "Failed to copy", + "click_to_copy": "Copy download link", + "copying": "Copying...", + "done": "Copied to clipboard" + } + }, "dimensions": { "label": "Dimensions", "helper_text": "", @@ -258,13 +301,13 @@ "label": "Meta", "helper_text": "", "url": { - "label": "URL", - "helper_text": "Full URL for the customer to view this item on the store website.", + "label": "Website URL", + "helper_text": "", "placeholder": "None" }, "image": { - "label": "Image", - "helper_text": "URL of the image to display for this item in the cart and checkout.", + "label": "Image URL", + "helper_text": "", "placeholder": "None" }, "quantity-max": { @@ -278,8 +321,8 @@ "placeholder": "1" }, "expires": { - "label": "Expires", - "helper_text": "Date when this item will be removed from the cart.", + "label": "Expiry Date", + "helper_text": "", "display_value": "{{ value, date }}", "placeholder": "Optional" } @@ -311,6 +354,9 @@ }, "discount-details": { "label": "Applied discounts", + "helper_text": "", + "total_items": "{{ count }} discount", + "total_items_plural": "{{ count }} discounts", "pagination": { "first": "First", "last": "Last", @@ -331,6 +377,9 @@ }, "coupon-details": { "label": "Applied coupons", + "helper_text": "", + "total_items": "{{ count }} coupon", + "total_items_plural": "{{ count }} coupons", "pagination": { "first": "First", "last": "Last", @@ -351,6 +400,9 @@ }, "attributes": { "label": "Attributes", + "helper_text": "", + "total_items": "{{ count }} attribute", + "total_items_plural": "{{ count }} attributes", "delete_header": "Remove attribute?", "delete_message": "Please confirm that you'd like to remove this attribute from the item.", "delete_confirm": "Remove", @@ -454,6 +506,9 @@ }, "item-options": { "label": "Item options", + "helper_text": "", + "total_items": "{{ count }} option", + "total_items_plural": "{{ count }} options", "delete_header": "Remove item option?", "delete_message": "Please confirm that you'd like to remove this item option from the item.", "delete_confirm": "Remove", @@ -793,6 +848,12 @@ } } }, + "metadata": { + "label": "Metadata", + "helper_text": "", + "ip": { "label": "IP address" }, + "user-agent": { "label": "User agent" } + }, "webhooks": { "label": "Webhooks", "helper_text": "This list shows v2 webhooks only. Legacy v1 webhooks are available in Settings > Integrations and on admin.foxycart.com.", @@ -1784,4 +1845,4 @@ "loading_empty": "No data", "loading_error": "Unknown error" } -} \ No newline at end of file +} diff --git a/src/static/translations/user-form/en.json b/src/static/translations/user-form/en.json index c346a78f..bf8f06fb 100644 --- a/src/static/translations/user-form/en.json +++ b/src/static/translations/user-form/en.json @@ -17,47 +17,67 @@ "done": "Copied to clipboard" } }, - "first-name": { - "label": "First name", - "placeholder": "Required", + "general": { + "label": "", "helper_text": "", - "v8n_required": "Please enter your first name", - "v8n_too_long": "Unfortunately we can't store first names longer than 50 characters" + "first-name": { + "label": "First name", + "placeholder": "Required", + "helper_text": "", + "v8n_required": "Please enter your first name", + "v8n_too_long": "Unfortunately we can't store first names longer than 50 characters" + }, + "last-name": { + "label": "Last name", + "placeholder": "Required", + "helper_text": "", + "v8n_required": "Please enter your last name. If you don't have a last name, put any character in this field.", + "v8n_too_long": "Unfortunately we can't store last names longer than 50 characters" + } }, - "last-name": { - "label": "Last name", - "placeholder": "Required", + "contact-info": { + "label": "Contact", "helper_text": "", - "v8n_required": "Please enter your last name. If you don't have a last name, put any character in this field.", - "v8n_too_long": "Unfortunately we can't store last names longer than 50 characters" - }, - "email": { - "label": "Email", - "placeholder": "Required", - "helper_text": "This email is your login to Foxy Admin. We'll also send important notifications to this address.", - "v8n_required": "Please enter your email address", - "v8n_invalid_email": "Please enter a valid email address", - "v8n_too_long": "Unfortunately we can't store email addresses longer than 100 characters" + "email": { + "label": "Email", + "placeholder": "Required", + "helper_text": "", + "v8n_required": "Please enter your email address", + "v8n_invalid_email": "Please enter a valid email address", + "v8n_too_long": "Unfortunately we can't store email addresses longer than 100 characters" + }, + "phone": { + "label": "Phone", + "placeholder": "Optional", + "helper_text": "", + "v8n_too_long": "This phone number appears to be too long. Please make sure you entered it correctly." + } }, - "phone": { - "label": "Phone", - "placeholder": "Optional", - "helper_text": "We'll only use this number to contact you about your account.", - "v8n_too_long": "This phone number appears to be too long. Please make sure you entered it correctly." + "role": { + "label": "Role", + "helper_text": "", + "is-merchant": { + "label": "Merchant", + "helper_text": "" + }, + "is-programmer": { + "label": "Backend Developer", + "helper_text": "" + }, + "is-front-end-developer": { + "label": "Frontend Developer", + "helper_text": "" + }, + "is-designer": { + "label": "Designer", + "helper_text": "" + } }, "affiliate-id": { "label": "Affiliate ID", "placeholder": "Optional", "helper_text": "This value can only be set during user creation. Contact us if you need this value changed later." }, - "role": { - "label": "Role", - "helper_text": "If you contact us for help, your role will help us understand how to best assist you.", - "option_merchant": "Merchant", - "option_backend_developer": "Backend Developer", - "option_frontend_developer": "Frontend Developer", - "option_designer": "Designer" - }, "timestamps": { "date_created": "Created on", "date_modified": "Last updated on", @@ -82,4 +102,4 @@ "loading_busy": "Loading", "loading_error": "Unknown error" } -} \ No newline at end of file +} diff --git a/web-test-runner.config.js b/web-test-runner.config.js index 83e4ebf9..3cb42386 100644 --- a/web-test-runner.config.js +++ b/web-test-runner.config.js @@ -6,7 +6,14 @@ import webServerConfig from './web-dev-server.config.js'; export default Object.assign({}, webServerConfig, { browserLogs: false, - browsers: [puppeteerLauncher()], + browsers: [ + puppeteerLauncher({ + launchOptions: { + executablePath: '/usr/bin/chromium', + args: ['--no-sandbox', '--disable-setuid-sandbox'], + }, + }), + ], groups,