From 85cd9ce61e746b06363d2a7d26a2db9d1278c0d5 Mon Sep 17 00:00:00 2001 From: Daniil Bratukhin Date: Thu, 23 Oct 2025 13:53:04 -0300 Subject: [PATCH 1/9] fix(foxy-payments-api-payment-method-form): update gateway alphabetical ordering to use the gateway name --- .../PaymentsApiPaymentMethodForm.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/elements/public/PaymentsApiPaymentMethodForm/PaymentsApiPaymentMethodForm.ts b/src/elements/public/PaymentsApiPaymentMethodForm/PaymentsApiPaymentMethodForm.ts index b7a6ca54..a722f3d8 100644 --- a/src/elements/public/PaymentsApiPaymentMethodForm/PaymentsApiPaymentMethodForm.ts +++ b/src/elements/public/PaymentsApiPaymentMethodForm/PaymentsApiPaymentMethodForm.ts @@ -249,11 +249,11 @@ export class PaymentsApiPaymentMethodForm extends Base { : allMethods; return filteredMethods - .sort((a, b) => a[0].localeCompare(b[0], 'en')) + .sort((a, b) => a[1].name.localeCompare(b[1].name, 'en')) .reduce((groups, [type, helper]) => { if (helper.is_deprecated) return groups; - const firstChar = type.charAt(0).toUpperCase(); + const firstChar = helper.name.charAt(0).toUpperCase(); const isSpecialCharacter = !/\w/.test(firstChar); const name = isSpecialCharacter ? '#' : firstChar; const group = groups.find(group => group.name === name); From 05be8238c2524d2a3bfd5fec3d874eb7285fd9ea Mon Sep 17 00:00:00 2001 From: Daniil Bratukhin Date: Thu, 23 Oct 2025 14:39:06 -0300 Subject: [PATCH 2/9] fix(foxy-admin-subscription-form): account for future dated subscriptions in cancellation modal --- ...inSubscriptionFormStatusActionForm.test.ts | 79 +++++++++++++++++++ ...alAdminSubscriptionFormStatusActionForm.ts | 39 +++++++-- .../admin-subscription-form/en.json | 4 + 3 files changed, 114 insertions(+), 8 deletions(-) diff --git a/src/elements/public/AdminSubscriptionForm/internal/InternalAdminSubscriptionFormStatusActionForm/InternalAdminSubscriptionFormStatusActionForm.test.ts b/src/elements/public/AdminSubscriptionForm/internal/InternalAdminSubscriptionFormStatusActionForm/InternalAdminSubscriptionFormStatusActionForm.test.ts index 3209b493..9cada296 100644 --- a/src/elements/public/AdminSubscriptionForm/internal/InternalAdminSubscriptionFormStatusActionForm/InternalAdminSubscriptionFormStatusActionForm.test.ts +++ b/src/elements/public/AdminSubscriptionForm/internal/InternalAdminSubscriptionFormStatusActionForm/InternalAdminSubscriptionFormStatusActionForm.test.ts @@ -102,6 +102,37 @@ describe('AdminSubscriptionForm', () => { expect($('foxy-i18n[infer=""][key="cancel_how_to_reactivate_text"]')).to.exist; }); + it('renders text content for the Cancel state for future subscriptions', async () => { + const $ = (selector: string) => form.renderRoot.querySelector(selector); + const router = createRouter(); + const form = await fixture
(html` + router.handleEvent(evt)} + > + + `); + + await waitUntil(() => !!form.data, undefined, { timeout: 5000 }); + + form.data = { + ...form.data!, + is_active: true, + start_date: serializeDate(new Date(Date.now() + 86400000)), + }; + + await form.requestUpdate(); + + expect($('foxy-i18n[infer=""][key="cancel_title"]')).to.exist; + expect($('foxy-i18n[infer=""][key="cancel_subtitle_future"]')).to.exist; + expect($('foxy-i18n[infer=""][key="cancel_why_not_today_title"]')).to.exist; + expect($('foxy-i18n[infer=""][key="cancel_why_not_today_text_future"]')).to.exist; + expect($('foxy-i18n[infer=""][key="cancel_whats_next_title"]')).to.exist; + expect($('foxy-i18n[infer=""][key="cancel_whats_next_text_future"]')).to.exist; + expect($('foxy-i18n[infer=""][key="cancel_how_to_reactivate_title"]')).to.exist; + expect($('foxy-i18n[infer=""][key="cancel_how_to_reactivate_text"]')).to.exist; + }); + it('renders text content for the Reactivate state', async () => { const $ = (selector: string) => form.renderRoot.querySelector(selector); const router = createRouter(); @@ -170,6 +201,54 @@ describe('AdminSubscriptionForm', () => { expect(form.form.end_date).to.equal(''); }); + it('renders end date presets in the Cancel state for future subscriptions', async () => { + const router = createRouter(); + const form = await fixture(html` + router.handleEvent(evt)} + > + + `); + + await waitUntil(() => !!form.data, undefined, { timeout: 5000 }); + + form.data = { + ...form.data!, + is_active: true, + start_date: serializeDate(new Date(Date.now() + 86400000)), + }; + + await form.requestUpdate(); + + const select = form.renderRoot.querySelector( + 'foxy-internal-select-control[infer="end-date-preset"]' + ); + + expect(select).to.exist; + expect(select).to.have.attribute( + 'options', + JSON.stringify([ + { value: 'start_date', label: 'option_start_date' }, + { value: 'next_transaction_date', label: 'option_next_transaction_date' }, + { value: 'custom_date', label: 'option_custom_date' }, + ]) + ); + + expect(select?.getValue()).to.equal('next_transaction_date'); + form.edit({ end_date_preset: 'start_date' }); + expect(select?.getValue()).to.equal('start_date'); + + select?.setValue('next_transaction_date'); + expect(form.form.end_date).to.equal(form.form.next_transaction_date); + + select?.setValue('start_date'); + expect(form.form.end_date).to.equal(form.data!.start_date); + + select?.setValue('custom_date'); + expect(form.form.end_date).to.equal(''); + }); + it('renders end date field in the Cancel state', async () => { const router = createRouter(); const form = await fixture(html` diff --git a/src/elements/public/AdminSubscriptionForm/internal/InternalAdminSubscriptionFormStatusActionForm/InternalAdminSubscriptionFormStatusActionForm.ts b/src/elements/public/AdminSubscriptionForm/internal/InternalAdminSubscriptionFormStatusActionForm/InternalAdminSubscriptionFormStatusActionForm.ts index f34dc92c..ecfe4b26 100644 --- a/src/elements/public/AdminSubscriptionForm/internal/InternalAdminSubscriptionFormStatusActionForm/InternalAdminSubscriptionFormStatusActionForm.ts +++ b/src/elements/public/AdminSubscriptionForm/internal/InternalAdminSubscriptionFormStatusActionForm/InternalAdminSubscriptionFormStatusActionForm.ts @@ -29,6 +29,12 @@ export class InternalAdminSubscriptionFormStatusActionForm extends InternalForm< { value: 'custom_date', label: 'option_custom_date' }, ]); + private readonly __endDatePresetOptionsForFutureSubs = JSON.stringify([ + { value: 'start_date', label: 'option_start_date' }, + { value: 'next_transaction_date', label: 'option_next_transaction_date' }, + { value: 'custom_date', label: 'option_custom_date' }, + ]); + private readonly __endDatePresetGetValue = () => this.form.end_date_preset ?? 'next_transaction_date'; @@ -40,6 +46,8 @@ export class InternalAdminSubscriptionFormStatusActionForm extends InternalForm< tomorrowDate.setDate(tomorrowDate.getDate() + 1); tomorrowDate.setHours(0, 0, 0, 0); this.edit({ end_date: serializeDate(tomorrowDate) }); + } else if (newValue === 'start_date') { + this.edit({ end_date: this.data?.start_date ?? '' }); } else if (newValue === 'next_transaction_date') { this.edit({ end_date: this.form.next_transaction_date }); } else { @@ -83,6 +91,12 @@ export class InternalAdminSubscriptionFormStatusActionForm extends InternalForm< ? svg`` : svg``; + const startDate = this.data ? parseDate(this.data.start_date) : null; + const isFutureSubscription = !!startDate && startDate > new Date(); + const datePickerMin = serializeDate( + new Date(Math.max(tomorrowDate.getTime(), startDate?.getTime() ?? tomorrowDate.getTime())) + ); + return html`
@@ -91,7 +105,8 @@ export class InternalAdminSubscriptionFormStatusActionForm extends InternalForm<

- + +

@@ -100,7 +115,9 @@ export class InternalAdminSubscriptionFormStatusActionForm extends InternalForm< ${isActive ? html` @@ -121,7 +138,7 @@ export class InternalAdminSubscriptionFormStatusActionForm extends InternalForm< @@ -134,11 +151,13 @@ export class InternalAdminSubscriptionFormStatusActionForm extends InternalForm< ? [ this.__renderFaq( 'cancel_why_not_today', - svg`` + svg``, + isFutureSubscription ? '_future' : '' ), this.__renderFaq( 'cancel_whats_next', - svg`` + svg``, + isFutureSubscription ? '_future' : '' ), this.__renderFaq( 'cancel_how_to_reactivate', @@ -189,7 +208,7 @@ export class InternalAdminSubscriptionFormStatusActionForm extends InternalForm< return super._sendPatch(edits); } - private __renderFaq(prefix: string, icon: SVGTemplateResult) { + private __renderFaq(prefix: string, icon: SVGTemplateResult, textSuffix = '') { return html`
- +

diff --git a/src/static/translations/admin-subscription-form/en.json b/src/static/translations/admin-subscription-form/en.json index f753587b..36acb063 100644 --- a/src/static/translations/admin-subscription-form/en.json +++ b/src/static/translations/admin-subscription-form/en.json @@ -37,10 +37,13 @@ "cancel": "Close", "cancel_title": "Cancel this subscription", "cancel_subtitle": "Cancelling subscriptions in Foxy works by setting the end date. You can select any date in the future, starting from tomorrow.", + "cancel_subtitle_future": "Cancelling subscriptions in Foxy works by setting the end date. You can select any date in the future, beginning from the start date.", "cancel_why_not_today_title": "Why can't I select today as the end date?", "cancel_why_not_today_text": "Same-day cancellations may disrupt billing due to ongoing payment processing. Advance notice ensures that all transactions are completed before cancellation.", + "cancel_why_not_today_text_future": "Subscriptions that haven't started yet won't be processed until their start date. Therefore, the earliest end date you can select is the start date.", "cancel_whats_next_title": "What happens next?", "cancel_whats_next_text": "The subscription will remain active and all payments will be processed as usual until the end date. On the end date, the subscription will be cancelled and no further payments will be taken. If the end date is the same as a normal renewal date, no payments will be taken, but the subscription will simply be cancelled.", + "cancel_whats_next_text_future": "The subscription will not be processed until the start date. On the end date, the subscription will be cancelled and no payments will be taken. If the end date is the same as the start date, no payments will be taken, but the subscription will simply be cancelled.", "cancel_how_to_reactivate_title": "Can I reactivate this subscription later?", "cancel_how_to_reactivate_text": "Yes, you will be able to clear the pending cancellation before the end date, or reactivate the subscription after the end date on this page.", "cancel_submit": "Cancel this subscription", @@ -55,6 +58,7 @@ "label": "Cancel this subscription", "helper_text": "", "placeholder": "Select", + "option_start_date": "On start date", "option_tomorrow": "Tomorrow", "option_next_transaction_date": "On next renewal date", "option_custom_date": "On specific date" From cd2843f92ebbb1275c2c2aa9cd2b5bed73b419c4 Mon Sep 17 00:00:00 2001 From: Daniil Bratukhin Date: Thu, 23 Oct 2025 14:42:52 -0300 Subject: [PATCH 3/9] fix(foxy-coupon-form): support spaces between list items in product code restrictions csv --- src/elements/public/CouponForm/CouponForm.test.ts | 2 +- src/elements/public/CouponForm/CouponForm.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/elements/public/CouponForm/CouponForm.test.ts b/src/elements/public/CouponForm/CouponForm.test.ts index 5c2fc0b1..04c2d0cc 100644 --- a/src/elements/public/CouponForm/CouponForm.test.ts +++ b/src/elements/public/CouponForm/CouponForm.test.ts @@ -447,7 +447,7 @@ describe('CouponForm', () => { control.setValue([{ value: 'a' }, { unit: 'block', value: 'b' }]); expect(element).to.have.deep.nested.property('form.product_code_restrictions', 'a,-b'); - element.edit({ product_code_restrictions: '-foo,bar' }); + element.edit({ product_code_restrictions: ' -foo , bar ' }); expect(control.getValue()).to.deep.equal([ { label: 'product-code-restrictions.label_block', value: '-foo' }, { label: 'product-code-restrictions.label_allow', value: 'bar' }, diff --git a/src/elements/public/CouponForm/CouponForm.ts b/src/elements/public/CouponForm/CouponForm.ts index 58d9e0f0..d791b35d 100644 --- a/src/elements/public/CouponForm/CouponForm.ts +++ b/src/elements/public/CouponForm/CouponForm.ts @@ -148,7 +148,8 @@ export class CouponForm extends Base { private readonly __productCodeRestrictionsGetValue = () => { return this.form.product_code_restrictions ?.split(',') - .filter(v => !!v.trim()) + .map(v => v.trim()) + .filter(v => v.length > 0) .map(value => ({ value, label: value.startsWith('-') From 0a08ad33f02b6d2988f3154189cb26f70bfb4b5c Mon Sep 17 00:00:00 2001 From: Daniil Bratukhin Date: Thu, 23 Oct 2025 14:53:24 -0300 Subject: [PATCH 4/9] feat: automatically add new entry on input blur in editable lists --- .../InternalEditableListControl.test.ts | 20 +++++++++++++++++++ .../InternalEditableListControl.ts | 1 + 2 files changed, 21 insertions(+) diff --git a/src/elements/internal/InternalEditableListControl/InternalEditableListControl.test.ts b/src/elements/internal/InternalEditableListControl/InternalEditableListControl.test.ts index d61edeed..b1915362 100644 --- a/src/elements/internal/InternalEditableListControl/InternalEditableListControl.test.ts +++ b/src/elements/internal/InternalEditableListControl/InternalEditableListControl.test.ts @@ -163,6 +163,26 @@ describe('InternalEditableListControl', () => { expect(value).to.deep.equal([{ value: 'foo,bar', unit: '' }]); }); + it('can add items on blur (non-range only)', async () => { + const layout = html``; + const element = await fixture(layout); + + let value: unknown = []; + element.getValue = () => value; + element.setValue = newValue => (value = newValue); + await element.requestUpdate(); + + const input = element.renderRoot.querySelector('input') as HTMLInputElement; + const whenChangeEmitted = oneEvent(element, 'change'); + + input.value = 'foo'; + input.dispatchEvent(new InputEvent('input')); + input.dispatchEvent(new FocusEvent('blur')); + + expect(await whenChangeEmitted).to.be.instanceOf(CustomEvent); + expect(value).to.deep.equal([{ value: 'foo', unit: '' }]); + }); + it('can add items on paste (multiline text - split into multiple entries)', async () => { const element = await fixture(html` diff --git a/src/elements/internal/InternalEditableListControl/InternalEditableListControl.ts b/src/elements/internal/InternalEditableListControl/InternalEditableListControl.ts index aaae1a40..66dd0a1d 100644 --- a/src/elements/internal/InternalEditableListControl/InternalEditableListControl.ts +++ b/src/elements/internal/InternalEditableListControl/InternalEditableListControl.ts @@ -272,6 +272,7 @@ export class InternalEditableListControl extends InternalEditableControl { }} @blur=${() => { this.__isErrorVisible = true; + addItem(); }} /> `} From 3ef97e78eb1a9e82411eed0ae573d88df06976cd Mon Sep 17 00:00:00 2001 From: Daniil Bratukhin Date: Thu, 23 Oct 2025 17:38:39 -0300 Subject: [PATCH 5/9] fix: fix date inputs not accepting typed entry consistently --- .../InternalCalendar/InternalCalendar.ts | 7 ++-- .../InternalDateControl.ts | 3 +- src/elements/public/ReportForm/utils.ts | 32 ++++++++++--------- src/utils/parse-date.ts | 4 ++- src/utils/safe-date.ts | 8 +++++ 5 files changed, 34 insertions(+), 20 deletions(-) create mode 100644 src/utils/safe-date.ts diff --git a/src/elements/internal/InternalCalendar/InternalCalendar.ts b/src/elements/internal/InternalCalendar/InternalCalendar.ts index 36bcb2d8..ba80a462 100644 --- a/src/elements/internal/InternalCalendar/InternalCalendar.ts +++ b/src/elements/internal/InternalCalendar/InternalCalendar.ts @@ -3,6 +3,7 @@ import { LitElement, PropertyDeclarations, TemplateResult, html } from 'lit-elem import { ThemeableMixin } from '../../../mixins/themeable'; import { classMap } from '../../../utils/class-map'; import { parseDate } from '../../../utils/parse-date'; +import { safeDate } from '../../../utils/safe-date'; import { serializeDate } from '../../../utils/serialize-date'; export class InternalCalendar extends ThemeableMixin(LitElement) { @@ -97,7 +98,7 @@ export class InternalCalendar extends ThemeableMixin(LitElement) { private __renderMonth(month: number, year: number) { const lang = this.lang || navigator.language; - const date = new Date(year, month, 1, 0, 0, 0, 0); + const date = safeDate(year, month, 1); const items: TemplateResult[] = []; for (let i = 0; i < 7; ++i) { @@ -145,7 +146,7 @@ export class InternalCalendar extends ThemeableMixin(LitElement) { private __renderDate(date: number, month: number, year: number, checked = false) { const disabled = - this.disabled || this.readonly || !this.checkAvailability(new Date(year, month, date)); + this.disabled || this.readonly || !this.checkAvailability(safeDate(year, month, date)); return html`