[web] Add cancel-loss-messaging split test (#36363)

* [web] Add cancel-loss-messaging split test

Populate the cancel confirmation step with plan-specific losses when the
cancel-loss-messaging split test is enabled: "Are you sure you want to
cancel?" heading, the subscription end date, and a card listing what the
user loses moving to the Free plan (compile timeout, AI, collaborators,
history), with the lost benefit struck through next to its Free-plan
replacement.

Shown only to non-trial Student/Standard/Pro individual subscribers with
no other retention offer (trial extension or downgrade upsell) — those
keep the existing confirmation.

Copy matches the mockups in the ticket. No new analytics events; the
existing subscription-page-cancel-button-click / subscription-cancelled
funnel is used.

Part of #35765

* [web] Consolidate ActiveSubscription stories, add cancel confirmation story

Replace the 13 fixture-per-story exports with a single Dashboard story
driven by controls (subscription fixture select, flexible licensing,
cancel-loss-messaging variant), using the CSF factories pattern. Add a
CancelConfirmation story that renders the cancel confirmation step
directly so the cancel-loss-messaging variant can be previewed without
clicking through, with a Professional fixture available to see the Pro
loss rows.

* [web] Use withSplitTests for the ActiveSubscription stories

Register cancel-loss-messaging in defaultSplitTestsArgTypes and drive the
stories' variant from the shared withSplitTests decorator (radio control,
SplitTestContext provided from args) instead of hand-rolled meta tags and
SplitTestProvider. Generalize withSplitTests' typing so it composes with
CSF factories (preview.meta) as well as legacy meta objects.

* [web] Fix Storybook indexing of the ActiveSubscription stories

Storybook's static CSF indexer requires preview.meta() to receive an
object literal, so spread withSplitTests' argTypes/decorators into the
meta literal instead of wrapping the whole config in the helper call.

* [web] Compose ActiveSubscription stories from orthogonal controls

- Wrap the stories in #subscription-dashboard-root so the dashboard's
  scoped styles (e.g. the cancel-loss strikethrough) apply in Storybook
- Replace the single fixture dropdown with independent controls (group,
  plan, period, currency, trial, status, pending plan change, add-ons,
  coupons, pending license change), building the subscription from the
  fixtures' base + patches
- Guard impossible mixes with argTypes `if` conditions: individual-only
  controls hide when group is on, and pendingLicenseChange only shows
  for groups

* [web] Add student plan to the ActiveSubscription story controls

The cancel-loss-messaging variant has student-specific rows (AI
Assistant / 10 collaborators), so the plan control needs a student
option. Groups have no student plans, so for group subscriptions the
student choice maps to the Standard group fixture.

* [web] Provide ol-plans in the ActiveSubscription stories

The cancel confirmation's downgrade branch (monthly Standard is
isEligibleForDowngradeUpsell) looks up paid-personal in ol-plans and
spins forever when the meta tag is missing. Provide ol-plans and
ol-groupPlans from the fixtures, matching the test render helper.

GitOrigin-RevId: e8491c8d39ed059e42ea3442e8d70c3ef304b009
This commit is contained in:
Antoine Clausse
2026-09-17 11:52:02 +00:00
committed by Copybot
parent dac6254831
commit 1daed4efe4
7 changed files with 312 additions and 0 deletions
@@ -125,6 +125,8 @@
"ai_assist_unavailable": "",
"ai_assistance": "",
"ai_assistant": "",
"ai_assistant_and_higher_ai_allowance": "",
"ai_assistant_and_max_ai_allowance": "",
"ai_assistant_disabled": "",
"ai_can_make_mistakes": "",
"ai_features": "",
@@ -183,6 +185,7 @@
"are_you_affiliated_with_an_institution": "",
"are_you_getting_an_undefined_control_sequence_error": "",
"are_you_still_at": "",
"are_you_sure_you_want_to_cancel": "",
"are_you_sure_you_want_to_cancel_add_on": "",
"as_email": "",
"ask_proj_owner_to_unlink_from_current_github": "",
@@ -213,6 +216,7 @@
"back_to_subscription": "",
"back_to_your_projects": "",
"basic_compile_time": "",
"basic_compile_timeout_for_small_projects": "",
"been_offline_changes_saved_in_browser": "",
"before_you_use_error_assistant": "",
"beta_program_already_participating": "",
@@ -256,6 +260,7 @@
"cancel_personal_subscription_first": "",
"cancel_subscription": "",
"cancel_your_subscription": "",
"cancelling_will_end_your_subscription_on": "",
"cannot_invite_non_user": "",
"cannot_invite_self": "",
"cannot_verify_user_not_robot": "",
@@ -774,6 +779,7 @@
"from_your_library": "",
"from_your_project": "",
"full_doc_history": "",
"full_project_history": "",
"full_width": "",
"future_payments": "",
"generate_from_text": "",
@@ -903,6 +909,7 @@
"hide_outline": "",
"higher_ai_allowance": "",
"history": "",
"history_24_hours_only": "",
"history_add_label": "",
"history_are_you_sure_delete_label": "",
"history_compare_from_this_version": "",
@@ -1080,6 +1087,7 @@
"kb_suggestions_enquiry": "",
"keep_current_plan": "",
"keep_personal_projects_separate": "",
"keep_subscription": "",
"keep_your_account_safe_add_another_email": "",
"keybindings": "",
"keyboard_shortcuts": "",
@@ -1324,6 +1332,7 @@
"next_suggestion": "",
"no_actions": "",
"no_add_on_purchase_while_paused": "",
"no_ai_assistant": "",
"no_audit_logs_found": "",
"no_borders": "",
"no_caption": "",
@@ -1382,6 +1391,7 @@
"ok_continue_to_project": "",
"ok_join_project": "",
"on": "",
"one_collaborator_per_project": "",
"one_step_away_from_professional_features": "",
"only_add_people_who_dont_yet_have_access": "",
"only_group_admin_or_managers_can_delete_your_account_1": "",
@@ -1483,6 +1493,7 @@
"personal_library": "",
"pick_up_where_you_left_off": "",
"plan": "",
"plan_24x_longer_compile_timeout": "",
"plan_tooltip": "",
"please_ask_the_project_owner_to_upgrade_more_collaborators": "",
"please_ask_the_project_owner_to_upgrade_to_track_changes": "",
@@ -1919,6 +1930,7 @@
"size": "",
"skip": "",
"skip_welcome_animation": "",
"small_ai_allowance_no_assistant": "",
"some_dois_could_not_be_resolved": "",
"something_not_right": "",
"something_went_wrong": "",
@@ -2093,6 +2105,7 @@
"template_title_taken_from_project_title": "",
"templates": "",
"temporarily_hides_the_preview": "",
"ten_collaborators_per_project": "",
"terminated": "",
"test": "",
"test_configuration": "",
@@ -2559,6 +2572,7 @@
"you_re_offline": "",
"you_unpaused_your_subscription": "",
"you_will_be_able_to_reassign_subscription": "",
"youll_be_moved_to_the_free_plan": "",
"youll_get_best_results_in_visual_but_can_be_used_in_source": "",
"youll_need_to_ask_the_github_repository_owner": "",
"youll_no_longer_need_to_remember_credentials": "",
@@ -0,0 +1,122 @@
import { useTranslation } from 'react-i18next'
import { TFunction } from 'i18next'
import MaterialIcon from '@/shared/components/material-icon'
import OLButton from '@/shared/components/ol/ol-button'
import { useSubscriptionDashboardContext } from '../../../../../context/subscription-dashboard-context'
export type LossMessagingPlanType = 'student' | 'collaborator' | 'professional'
export function getLossMessagingPlanType(
planCode: string
): LossMessagingPlanType | null {
if (planCode.startsWith('professional')) return 'professional'
if (planCode.startsWith('collaborator')) return 'collaborator'
if (planCode.startsWith('student')) return 'student'
return null
}
type LossRow = { lost: string; replacement: string }
function getAiLossRow(planType: LossMessagingPlanType, t: TFunction): LossRow {
if (planType === 'student') {
return { lost: t('ai_assistant'), replacement: t('no_ai_assistant') }
}
if (planType === 'collaborator') {
return {
lost: t('ai_assistant_and_higher_ai_allowance'),
replacement: t('small_ai_allowance_no_assistant'),
}
}
return {
lost: t('ai_assistant_and_max_ai_allowance'),
replacement: t('small_ai_allowance_no_assistant'),
}
}
function getCollaboratorsLossRow(
planType: LossMessagingPlanType,
t: TFunction
): LossRow {
return {
lost:
planType === 'professional'
? t('unlimited_collaborators_per_project')
: t('ten_collaborators_per_project'),
replacement: t('one_collaborator_per_project'),
}
}
function getLossRows(planType: LossMessagingPlanType, t: TFunction): LossRow[] {
return [
{
lost: t('plan_24x_longer_compile_timeout'),
replacement: t('basic_compile_timeout_for_small_projects'),
},
getAiLossRow(planType, t),
getCollaboratorsLossRow(planType, t),
{
lost: t('full_project_history'),
replacement: t('history_24_hours_only'),
},
]
}
export function CancelSubscriptionLossMessaging({
planType,
terminationDate,
onCancelSubscription,
isButtonDisabled,
isCancelLoading,
}: {
planType: LossMessagingPlanType
terminationDate: string
onCancelSubscription: () => void
isButtonDisabled: boolean
isCancelLoading: boolean
}) {
const { t } = useTranslation()
const { setShowCancellation } = useSubscriptionDashboardContext()
return (
<div className="cancel-loss-messaging">
<h2>{t('are_you_sure_you_want_to_cancel')}</h2>
<p>
{t('cancelling_will_end_your_subscription_on', { terminationDate })}
</p>
<div className="cancel-loss-card">
<h3 className="cancel-loss-card-heading">
{t('youll_be_moved_to_the_free_plan')}
</h3>
<ul className="cancel-loss-list">
{getLossRows(planType, t).map(row => (
<li key={row.lost} className="cancel-loss-list-item">
<MaterialIcon type="cancel" className="cancel-loss-icon" />
<span>
<del className="cancel-loss-lost">{row.lost}</del>{' '}
{row.replacement}
</span>
</li>
))}
</ul>
</div>
<div className="cancel-loss-actions">
<OLButton
variant="secondary"
disabled={isButtonDisabled}
onClick={() => setShowCancellation(false)}
>
{t('keep_subscription')}
</OLButton>
<OLButton
variant="danger-ghost"
disabled={isButtonDisabled}
isLoading={isCancelLoading}
loadingLabel={t('processing_uppercase') + '…'}
onClick={onCancelSubscription}
>
{t('cancel_subscription')}
</OLButton>
</div>
</div>
)
}
@@ -14,6 +14,12 @@ import ExtendTrialButton from './extend-trial-button'
import { useLocation } from '../../../../../../../shared/hooks/use-location'
import { debugConsole } from '@/utils/debugging'
import OLButton from '@/shared/components/ol/ol-button'
import { useFeatureFlag } from '@/shared/context/split-test-context'
import isInFreeTrial from '../../../../../util/is-in-free-trial'
import {
CancelSubscriptionLossMessaging,
getLossMessagingPlanType,
} from './cancel-subscription-loss-messaging'
const planCodeToDowngradeTo = 'paid-personal'
@@ -136,6 +142,7 @@ export function CancelSubscription() {
const location = useLocation()
const { personalSubscription, plans, userCanExtendTrial } =
useSubscriptionDashboardContext()
const lossMessagingEnabled = useFeatureFlag('cancel-loss-messaging')
const {
isLoading: isLoadingCancel,
isError: isErrorCancel,
@@ -176,6 +183,34 @@ export function CancelSubscription() {
const showExtendFreeTrial = userCanExtendTrial
// `cancel-loss-messaging` split test: show plan-specific losses instead of
// the bare confirmation, for non-trial individual subscribers with no other
// retention offer (trial extension or downgrade)
const lossMessagingPlanType = getLossMessagingPlanType(
personalSubscription.planCode
)
const showLossMessaging =
lossMessagingEnabled &&
!showExtendFreeTrial &&
!showDowngrade &&
lossMessagingPlanType !== null &&
!isInFreeTrial(personalSubscription.payment.trialEndsAt)
if (showLossMessaging) {
return (
<>
{isErrorCancel && <GenericErrorAlert />}
<CancelSubscriptionLossMessaging
planType={lossMessagingPlanType}
terminationDate={personalSubscription.payment.nextPaymentDueDate}
onCancelSubscription={handleCancelSubscription}
isButtonDisabled={isButtonDisabled}
isCancelLoading={isSuccessCancel || isLoadingCancel}
/>
</>
)
}
return (
<>
<div className="text-center">
@@ -31,6 +31,50 @@
}
}
}
.cancel-loss-card {
background-color: var(--bg-light-secondary);
border-radius: var(--border-radius-base);
padding: var(--spacing-06);
margin-bottom: var(--spacing-06);
.cancel-loss-card-heading {
@include body-base;
font-weight: 600;
margin-bottom: var(--spacing-04);
}
}
.cancel-loss-list {
list-style: none;
margin: 0;
padding: 0;
.cancel-loss-list-item {
display: flex;
align-items: flex-start;
gap: var(--spacing-04);
& + .cancel-loss-list-item {
margin-top: var(--spacing-04);
}
}
.cancel-loss-icon {
color: var(--content-danger);
}
.cancel-loss-lost {
color: var(--content-danger);
text-decoration: line-through;
}
}
.cancel-loss-actions {
display: flex;
gap: var(--spacing-04);
}
}
/**