Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export const ButtonSelect: React.FC<{type: OfferType, checked: boolean, onClick:
<div className={`mt-0.5 flex size-4 items-center justify-center rounded-full ${checkboxClass}`}>
{checked ? <Icon className='w-[7px] stroke-[4]' name='check' size='custom' /> : null}
</div>
<div className='flex flex-col'>
<div className='-mt-px flex flex-col'>
<span>{type.title}</span>
<span className='text-sm'>{type.description}</span>
</div>
Expand Down Expand Up @@ -258,9 +258,11 @@ const Sidebar: React.FC<SidebarProps> = ({tierOptions,
/>

{
overrides.duration === 'repeating' && <TextField title='Duration in months' type='number' onChange={(e) => {
handleDurationInMonthsInput(e);
}} />
overrides.duration === 'repeating' && <div className='-mt-4'>
<TextField data-testid='duration-months-input' rightPlaceholder={`${overrides.durationInMonths === 1 ? 'month' : 'months'}`} type='number' value={overrides.durationInMonths === 0 ? '' : String(overrides.durationInMonths)} onChange={(e) => {
handleDurationInMonthsInput(e);
}} />
</div>
Comment on lines +261 to +265
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add validation for durationInMonths when duration is repeating.

Clearing this field stores 0 (via Number('')), and onValidate has no guard for it — duration_in_months: 0 would be submitted to the API. Now that the field defaults to 1 and is more visible, this gap is easier to trigger.

🛡️ Proposed fix — add guard in `onValidate`
 if (formState.type === 'percent' && (formState.percentAmount < 0 || formState.percentAmount > 100)) {
     newErrors.amount = 'Amount must be between 0 and 100%.';
 }

+if (formState.duration === 'repeating' && (!formState.durationInMonths || formState.durationInMonths < 1)) {
+    newErrors.durationInMonths = 'Please enter a duration of at least 1 month.';
+}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/admin-x-settings/src/components/settings/growth/offers/add-offer-modal.tsx`
around lines 261 - 265, When duration === 'repeating' the durationInMonths field
can be cleared and coerced to 0 causing duration_in_months: 0 to be submitted;
update the validation in onValidate to explicitly reject falsy/zero values for
overrides.durationInMonths when overrides.duration === 'repeating' (e.g. check
overrides.durationInMonths > 0) and return a validation error message so the API
never receives 0, and ensure TextField/handleDurationInMonthsInput behavior
remains unchanged so the UI shows the validation state.

}
</>
}
Expand Down Expand Up @@ -352,7 +354,7 @@ const AddOfferModal = () => {
cadence: selectedTier?.dataset?.period || '',
amount: 0,
duration: 'once',
durationInMonths: 0,
durationInMonths: 1,
currency: selectedTier?.dataset?.currency || 'USD',
status: 'active',
tierId: selectedTier?.dataset?.id || '',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ const Sidebar: React.FC<{
</div>
</div>
</section>
<section className='mt-4'>
<section className='mt-2'>
<h2 className='mb-4 text-lg'>General</h2>
<div className='flex flex-col gap-6'>
<TextField
Expand Down Expand Up @@ -255,6 +255,16 @@ const EditOfferModal: React.FC<{id: string}> = ({id}) => {
portalParent='offers'
/>;

const goBack = () => {
if (sessionStorage.getItem('editOfferPageSource') === 'offers') {
sessionStorage.removeItem('editOfferPageSource');
updateRoute('offers');
} else {
sessionStorage.removeItem('editOfferPageSource');
updateRoute('offers/edit');
}
};
Comment on lines +258 to +266
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Wrong default route when editOfferPageSource is absent.

sessionStorage.getItem('editOfferPageSource') returns null when the key was never written (direct navigation, fresh tab, cleared storage). null !== 'offers', so the else branch fires and sends the user to 'offers/edit' instead of 'offers'. The safer and more intuitive default should be 'offers'.

🐛 Proposed fix
 const goBack = () => {
+    sessionStorage.removeItem('editOfferPageSource');
     if (sessionStorage.getItem('editOfferPageSource') === 'offers') {
-        sessionStorage.removeItem('editOfferPageSource');
         updateRoute('offers');
     } else {
-        sessionStorage.removeItem('editOfferPageSource');
-        updateRoute('offers/edit');
+        updateRoute('offers');
     }
 };

If 'offers/edit' as a fallback is intentional for a specific source value, introduce an explicit check for that case instead of relying on the else-branch default.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const goBack = () => {
if (sessionStorage.getItem('editOfferPageSource') === 'offers') {
sessionStorage.removeItem('editOfferPageSource');
updateRoute('offers');
} else {
sessionStorage.removeItem('editOfferPageSource');
updateRoute('offers/edit');
}
};
const goBack = () => {
const source = sessionStorage.getItem('editOfferPageSource');
sessionStorage.removeItem('editOfferPageSource');
if (source === 'offers') {
updateRoute('offers');
} else {
updateRoute('offers');
}
};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/admin-x-settings/src/components/settings/growth/offers/edit-offer-modal.tsx`
around lines 258 - 266, The goBack function currently treats any non-'offers'
value (including null) as an instruction to navigate to 'offers/edit'; change it
to read const src = sessionStorage.getItem('editOfferPageSource'), remove the
item, then: if (src === 'offers') call updateRoute('offers'), else if (src ===
'offers/edit') call updateRoute('offers/edit'), otherwise call
updateRoute('offers') as the safe default; keep the sessionStorage.removeItem
call in the same flow. This uses the existing goBack,
sessionStorage.getItem('editOfferPageSource') and updateRoute(...) symbols to
make the default behavior explicit.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

rg -n "editOfferPageSource" --type=ts --type=tsx -A 2 -B 2

Repository: TryGhost/Ghost

Length of output: 85


🏁 Script executed:

rg -n "editOfferPageSource" -A 2 -B 2

Repository: TryGhost/Ghost

Length of output: 2905


🏁 Script executed:

rg "offers/edit" --type-not test -A 1 -B 1

Repository: TryGhost/Ghost

Length of output: 86


🏁 Script executed:

rg "offers/edit" -A 1 -B 1 | head -80

Repository: TryGhost/Ghost

Length of output: 7373


Unguarded navigation paths to offers/edit cause unreliable fallback behavior in goBack().

Multiple code paths navigate to offers/edit without first setting editOfferPageSource in sessionStorage (e.g., openOfferListModal() in offers.tsx, OfferSuccess modal navigation, and tab switches in offers-index-retention.tsx). When goBack() reads a missing or stale sessionStorage value, it falls through to the else clause and calls updateRoute('offers/edit') again, which can cause navigation loops or unexpected behavior.

Replace sessionStorage-based routing state with route query parameters or a scoped React context to eliminate the coupling between multiple entry points and the goBack handler.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/admin-x-settings/src/components/settings/growth/offers/edit-offer-modal.tsx`
around lines 258 - 266, The goBack() handler relies on
sessionStorage.getItem('editOfferPageSource') and falls back to
updateRoute('offers/edit'), causing unreliable behavior when other code paths
(e.g., openOfferListModal(), OfferSuccess navigation, tab switches in
offers-index-retention.tsx) don't set that key; replace this fragile
sessionStorage coupling by passing a scoped route query param (e.g.,
?source=offers or ?source=offers-edit) or by introducing a small React
context/provider that stores the originating page and is consumed by goBack();
update all entry points that currently call updateRoute('offers/edit') or open
the edit modal (openOfferListModal, OfferSuccess navigation, tab switch
handlers) to set the query param or context value when navigating/opening,
remove reads/writes to sessionStorage from edit-offer-modal.tsx (goBack) and
instead read the query param via the router or the context to decide whether to
navigate back to 'offers' or 'offers/edit'.


return offerById ? <PreviewModalContent
afterClose={() => {
updateRoute('offers');
Expand All @@ -267,21 +277,17 @@ const EditOfferModal: React.FC<{id: string}> = ({id}) => {
okColor={okProps.color}
okLabel={okProps.label || 'Save'}
preview={iframe}
previewToolbar={false}
previewToolbarBreadcrumbs={[
{label: 'Offers', onClick: goBack},
{label: formState?.name || 'Offer'}
]}
sidebar={sidebar}
size='lg'
testId='offer-update-modal'
title='Offer'
width={1140}
onCancel={() => {
if (sessionStorage.getItem('editOfferPageSource') === 'offers') {
sessionStorage.removeItem('editOfferPageSource');
updateRoute('offers');
} else {
sessionStorage.removeItem('editOfferPageSource');
updateRoute('offers/edit');
}
}}
onBreadcrumbsBack={goBack}
onCancel={goBack}
onOk={async () => {
try {
if (await handleSave({force: true})) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,9 @@ const RetentionOfferSidebar: React.FC<{
</div>
</div>
</section>
<section className='mt-4'>
<section className='mt-2'>
<Toggle
align='center'
checked={formState.enabled}
direction='rtl'
hint={cadence === 'monthly' ? 'Applied to monthly plans' : 'Applied to annual plans'}
Expand All @@ -79,7 +80,7 @@ const RetentionOfferSidebar: React.FC<{
</section>
{formState.enabled && (
<>
<section className='mt-4'>
<section className='mt-2'>
<h2 className='mb-4 text-lg'>General</h2>
<div className='flex flex-col gap-6'>
<TextField
Expand Down Expand Up @@ -141,15 +142,16 @@ const RetentionOfferSidebar: React.FC<{
}}
/>
{formState.duration === 'repeating' && (
<TextField
rightPlaceholder={`${formState.durationInMonths === 1 ? 'month' : 'months'}`}
title='Duration in months'
type='number'
value={formState.durationInMonths === 0 ? '' : String(formState.durationInMonths)}
onChange={(e) => {
updateForm(state => ({...state, durationInMonths: Number(e.target.value)}));
}}
/>
<div className='-mt-4'>
<TextField
rightPlaceholder={`${formState.durationInMonths === 1 ? 'month' : 'months'}`}
type='number'
value={formState.durationInMonths === 0 ? '' : String(formState.durationInMonths)}
onChange={(e) => {
updateForm(state => ({...state, durationInMonths: Number(e.target.value)}));
}}
/>
</div>
)}
</>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,13 +113,11 @@ const OffersFilterPopover: React.FC<{
<Popover
position='end'
trigger={
<button className='flex cursor-pointer items-center justify-center rounded p-1 hover:bg-grey-100 dark:hover:bg-grey-800' type='button'>
<LucideIcon.ListFilter className='text-grey-700' size={16} strokeWidth={1.5} />
</button>
<Button className='flex cursor-pointer items-center justify-center rounded p-1 hover:bg-grey-100 dark:hover:bg-grey-800 dark:[&_svg]:hover:text-white' label={<LucideIcon.ListFilter className='text-grey-700 dark:text-grey-500' size={16} strokeWidth={1.5} />} unstyled={true} />
}
>
<div className='flex min-w-[220px] flex-col'>
<div className='cursor-default select-none border-b border-b-grey-200 p-2 pl-3 text-xs font-semibold uppercase tracking-wide text-grey-700 dark:border-b-grey-800'>Status</div>
<div className='flex min-w-[160px] flex-col'>
<div className='cursor-default select-none pl-3 pt-2 text-sm font-semibold text-grey-700'>Status</div>
<div className='flex flex-col py-1'>
{(['active', 'archived'] as const).map(status => (
<button
Expand All @@ -133,7 +131,7 @@ const OffersFilterPopover: React.FC<{
</button>
))}
</div>
<div className='cursor-default select-none border-y border-y-grey-200 p-2 pl-3 text-xs font-semibold uppercase tracking-wide text-grey-700 dark:border-grey-800'>Sort by</div>
<div className='cursor-default select-none border-t border-t-grey-200 pl-3 pt-2 text-sm font-semibold text-grey-700 dark:border-t-grey-800'>Sort by</div>
<div className='flex flex-col py-1'>
{[
{id: 'date-added', label: 'Date added'},
Expand Down Expand Up @@ -247,7 +245,7 @@ export const OffersIndexModal: React.FC<{defaultTab?: string}> = ({defaultTab})
const {discountOffer, originalPriceWithCurrency, updatedPriceWithCurrency} = getOfferDiscount(offer.type, offer.amount, offer.cadence, offer.currency || 'USD', offerTier);

return (
<tr className={`group relative scale-100 border-b border-b-grey-200 dark:border-grey-800`} data-testid="offer-item">
<tr className={`group relative scale-100 border-b border-b-grey-100 dark:border-b-grey-950`} data-testid="offer-item">
<td className={`${isTierArchived ? 'opacity-50' : ''} p-0`}><a className={`block ${isTierArchived ? 'cursor-default select-none' : 'cursor-pointer'} p-5 pl-0`} onClick={!isTierArchived ? () => handleOfferEdit(offer?.id ? offer.id : '') : () => {}}><span className='font-semibold'>{offer?.name}</span><br /><span className='text-sm text-grey-700'>{offerTier.name} {getOfferCadence(offer.cadence)}</span></a></td>
<td className={`${isTierArchived ? 'opacity-50' : ''} whitespace-nowrap p-0 text-sm`}><a className={`block ${isTierArchived ? 'cursor-default select-none' : 'cursor-pointer'} p-5`} onClick={!isTierArchived ? () => handleOfferEdit(offer?.id ? offer.id : '') : () => {}}><span className='text-[1.3rem] font-medium uppercase'>{discountOffer}</span><br /><span className='text-grey-700'>{offer.type !== 'trial' ? getOfferDuration(offer.duration) : 'Trial period'}</span></a></td>
<td className={`${isTierArchived ? 'opacity-50' : ''} whitespace-nowrap p-0 text-sm`}><a className={`block ${isTierArchived ? 'cursor-default select-none' : 'cursor-pointer'} p-5`} onClick={!isTierArchived ? () => handleOfferEdit(offer?.id ? offer.id : '') : () => {}}><span className='font-medium'>{updatedPriceWithCurrency}</span> {offer.type !== 'trial' ? <span className='relative text-xs text-grey-700 before:absolute before:-inset-x-0.5 before:top-1/2 before:rotate-[-20deg] before:border-t before:content-[""]'>{originalPriceWithCurrency}</span> : null}</a></td>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ export const OffersIndexModal = () => {
const {discountOffer, originalPriceWithCurrency, updatedPriceWithCurrency} = getOfferDiscount(offer.type, offer.amount, offer.cadence, offer.currency || 'USD', offerTier);

return (
<tr className={`group relative scale-100 border-b border-b-grey-200 dark:border-grey-800`} data-testid="offer-item">
<tr className={`group relative scale-100 border-b border-b-grey-100 dark:border-b-grey-950`} data-testid="offer-item">
<td className={`${isTierArchived ? 'opacity-50' : ''} p-0`}><a className={`block ${isTierArchived ? 'cursor-default select-none' : 'cursor-pointer'} p-5 pl-0`} onClick={!isTierArchived ? () => handleOfferEdit(offer?.id ? offer.id : '') : () => {}}><span className='font-semibold'>{offer?.name}</span><br /><span className='text-sm text-grey-700'>{offerTier.name} {getOfferCadence(offer.cadence)}</span></a></td>
<td className={`${isTierArchived ? 'opacity-50' : ''} whitespace-nowrap p-0 text-sm`}><a className={`block ${isTierArchived ? 'cursor-default select-none' : 'cursor-pointer'} p-5`} onClick={!isTierArchived ? () => handleOfferEdit(offer?.id ? offer.id : '') : () => {}}><span className='text-[1.3rem] font-medium uppercase'>{discountOffer}</span><br /><span className='text-grey-700'>{offer.type !== 'trial' ? getOfferDuration(offer.duration) : 'Trial period'}</span></a></td>
<td className={`${isTierArchived ? 'opacity-50' : ''} whitespace-nowrap p-0 text-sm`}><a className={`block ${isTierArchived ? 'cursor-default select-none' : 'cursor-pointer'} p-5`} onClick={!isTierArchived ? () => handleOfferEdit(offer?.id ? offer.id : '') : () => {}}><span className='font-medium'>{updatedPriceWithCurrency}</span> {offer.type !== 'trial' ? <span className='relative text-xs text-grey-700 before:absolute before:-inset-x-0.5 before:top-1/2 before:rotate-[-20deg] before:border-t before:content-[""]'>{originalPriceWithCurrency}</span> : null}</a></td>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ const OffersRetention: React.FC = () => {
<col className='w-[80px]' />
</colgroup>
{placeholderRetentionOffers.map(offer => (
<tr key={offer.id} className='group relative scale-100 border-b border-b-grey-200 dark:border-grey-800' data-testid='retention-offer-item'>
<tr key={offer.id} className='group relative scale-100 border-b border-b-grey-100 dark:border-b-grey-950' data-testid='retention-offer-item'>
<td className='p-0'>
<a className='block cursor-pointer p-5 pl-0' onClick={() => handleRetentionOfferClick(offer.id)}>
<span className='font-semibold'>{offer.name}</span><br />
Expand All @@ -78,9 +78,9 @@ const OffersRetention: React.FC = () => {
<td className='whitespace-nowrap p-0 text-sm'>
<a className='block cursor-pointer p-5' onClick={() => handleRetentionOfferClick(offer.id)}>
{offer.status === 'active' ? (
<span className='text-sm font-semibold text-green'>Active</span>
<span className='inline-flex items-center rounded-full bg-[rgba(48,207,67,0.15)] px-1.5 py-0.5 text-2xs font-semibold uppercase tracking-wide text-green'>Active</span>
) : (
<span className='text-sm text-grey-700'>Inactive</span>
<span className='inline-flex items-center rounded-full bg-grey-200 px-1.5 py-0.5 text-2xs font-semibold uppercase tracking-wide text-grey-700 dark:bg-grey-900 dark:text-grey-500'>Inactive</span>
Comment on lines +81 to +83
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# Find Tailwind config and design-system files
find . -type f -name 'tailwind.config.*' -o -name 'theme*' -o -name 'config*' | grep -E '(tailwind|theme|config)' | head -20

Repository: TryGhost/Ghost

Length of output: 1224


🏁 Script executed:

# Search for text-2xs definition in the codebase
rg 'text-2xs|"2xs"' --type ts --type tsx --type js --type json -B 2 -A 2 | head -100

Repository: TryGhost/Ghost

Length of output: 85


🏁 Script executed:

# Check the actual file to confirm the code snippet
cat -n apps/admin-x-settings/src/components/settings/growth/offers/offers-retention.tsx | sed -n '75,90p'

Repository: TryGhost/Ghost

Length of output: 1236


🏁 Script executed:

# Look for similar badge patterns in the codebase
rg 'className.*inline-flex.*rounded-full' --type tsx --type ts -B 1 -A 1 | head -60

Repository: TryGhost/Ghost

Length of output: 85


🏁 Script executed:

# Search for text-2xs definition without file type specification
rg 'text-2xs|"2xs"' -B 2 -A 2 | head -150

Repository: TryGhost/Ghost

Length of output: 5076


🏁 Script executed:

# Check the admin-x-settings Tailwind config
cat apps/admin-x-settings/tailwind.config.cjs

Repository: TryGhost/Ghost

Length of output: 346


🏁 Script executed:

# Check admin-x-design-system Tailwind config
cat apps/admin-x-design-system/tailwind.config.cjs

Repository: TryGhost/Ghost

Length of output: 11646


🏁 Script executed:

# Search for fontSize or theme config with 2xs
rg 'fontSize|2xs' apps/admin-x-design-system/ -A 2 -B 2 | head -100

Repository: TryGhost/Ghost

Length of output: 3096


Active badge missing dark-mode background and text adjustments

The Active badge on line 81 lacks dark-mode color overrides, while the Inactive badge (line 83) includes them. In dark mode, the semi-transparent green background bg-[rgba(48,207,67,0.15)] may render poorly or become barely visible against a dark canvas. Add dark-mode variants to match the pattern used elsewhere.

🎨 Suggested fix
-<span className='inline-flex items-center rounded-full bg-[rgba(48,207,67,0.15)] px-1.5 py-0.5 text-2xs font-semibold uppercase tracking-wide text-green'>Active</span>
+<span className='inline-flex items-center rounded-full bg-[rgba(48,207,67,0.15)] dark:bg-[rgba(48,207,67,0.08)] px-1.5 py-0.5 text-2xs font-semibold uppercase tracking-wide text-green dark:text-green-400'>Active</span>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<span className='inline-flex items-center rounded-full bg-[rgba(48,207,67,0.15)] px-1.5 py-0.5 text-2xs font-semibold uppercase tracking-wide text-green'>Active</span>
) : (
<span className='text-sm text-grey-700'>Inactive</span>
<span className='inline-flex items-center rounded-full bg-grey-200 px-1.5 py-0.5 text-2xs font-semibold uppercase tracking-wide text-grey-700 dark:bg-grey-900 dark:text-grey-500'>Inactive</span>
<span className='inline-flex items-center rounded-full bg-[rgba(48,207,67,0.15)] dark:bg-[rgba(48,207,67,0.08)] px-1.5 py-0.5 text-2xs font-semibold uppercase tracking-wide text-green dark:text-green-400'>Active</span>
) : (
<span className='inline-flex items-center rounded-full bg-grey-200 px-1.5 py-0.5 text-2xs font-semibold uppercase tracking-wide text-grey-700 dark:bg-grey-900 dark:text-grey-500'>Inactive</span>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/admin-x-settings/src/components/settings/growth/offers/offers-retention.tsx`
around lines 81 - 83, The Active badge in offers-retention.tsx (the JSX <span>
rendering "Active") is missing dark-mode classes and should mirror the Inactive
badge's pattern; update the Active <span> (the one using
bg-[rgba(48,207,67,0.15)] and text-green) to include appropriate dark variants
such as a dark:bg-... and dark:text-... (matching contrast used for the Inactive
badge) so the green pill remains visible in dark mode while keeping the existing
light-mode rgba background and text-green classes.

)}
</a>
</td>
Expand Down
5 changes: 2 additions & 3 deletions ghost/core/test/e2e-browser/utils/e2e-browser-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -298,9 +298,8 @@ const createOffer = async (page, {name, tierName, offerType, amount, discountTyp
await page.getByLabel('Amount off').fill(`${amount}`);
if (discountType === 'multiple-months') {
await chooseOptionInSelect(page.getByTestId('duration-select-offers'), `Multiple-months`);
await page.getByLabel('Duration in months').fill(discountDuration.toString());
// await page.locator('[data-test-select="offer-duration"]').selectOption('repeating');
// await page.locator('input#duration-months').fill(discountDuration.toString());
const durationInput = page.getByTestId('duration-months-input');
await durationInput.fill(discountDuration.toString());
}

if (discountType === 'forever') {
Expand Down