polish(payments-next): Adjust styling and server action for terms

Because:

* A few code style and css-style tweaks were needed as a follow to the terms page release

This commit:

* addresses some of the outcomes from style conversations

Closes #N/A
This commit is contained in:
Davey Alvarez
2025-12-03 13:01:17 -08:00
parent 0ab32c016b
commit 8494d581d3
15 changed files with 239 additions and 126 deletions

View File

@@ -2,9 +2,8 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { Breadcrumbs, Header } from '@fxa/payments/ui';
import { Header } from '@fxa/payments/ui';
import { auth } from 'apps/payments/next/auth';
import { config } from 'apps/payments/next/config';
export const dynamic = 'force-dynamic';
@@ -16,19 +15,13 @@ export default async function ChurnLayout({
const session = await auth();
return (
<div className="min-h-[calc(100vh_-_4rem)] bg-white tablet:bg-grey-10 flex flex-col justify-start">
<>
<Header
auth={{
user: session?.user,
}}
/>
<Breadcrumbs
contentServerUrl={config.contentServerUrl}
paymentsNextUrl={config.paymentsNextHostedUrl}
/>
<div className="flex flex-col tablet:pt-20 items-center flex-1">
<div className="flex justify-center max-w-lg">{children}</div>
</div>
</div>
<>{children}</>
</>
);
}

View File

@@ -1,3 +1,7 @@
loyalty-discount-terms-heading = Terms and Restrictions
loyalty-discount-terms-heading = Terms and restrictions
loyalty-discount-terms-support = Contact Support
loyalty-discount-terms-support-aria = Contact Support
# $productName (String) - The name of the product to create subscription, e.g. Mozilla VPN
loyalty-discount-terms-contact-support-product-aria = Contact Support for { $productName }
not-found-page-title-terms = Page not found
not-found-page-description-terms = The page youre looking for does not exist.
not-found-page-button-terms-manage-subscriptions = Manage subscriptions

View File

@@ -0,0 +1,30 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { headers } from 'next/headers';
import { PageNotFound } from '@fxa/payments/ui';
import { getApp } from '@fxa/payments/ui/server';
export enum PaymentsPage {
Subscriptions = 'subscriptions',
}
export default function NotFound() {
const acceptLanguage = headers().get('accept-language');
const l10n = getApp().getL10n(acceptLanguage);
return (
<PageNotFound
header={l10n.getString('not-found-page-title-terms', 'Page not found')}
description={l10n.getString(
'not-found-page-description-terms',
'The page youre looking for does not exist.'
)}
button={l10n.getString(
'not-found-page-button-terms-manage-subscriptions',
'Manage subscriptions'
)}
paymentsPage={PaymentsPage.Subscriptions}
/>
);
}

View File

@@ -8,7 +8,7 @@ import { getApp } from '@fxa/payments/ui/server';
import { headers } from 'next/headers';
import { URLSearchParams } from 'url';
import { SubplatInterval } from '@fxa/payments/customer';
import { BaseButton, ButtonVariant } from '@fxa/payments/ui';
import { notFound } from 'next/navigation';
export default async function ChurnTerms({
params,
@@ -37,45 +37,61 @@ export default async function ChurnTerms({
selectedLanguage: locale,
});
const content = churnIntervention.churnInterventions.at(0);
if (
!content ||
!content.termsHeading ||
!Array.isArray(content.termsDetails) ||
content.termsDetails.length === 0
) {
notFound();
}
return (
<div className="flex flex-col tablet:bg-white p-8 tablet:rounded-lg tablet:shadow-lg">
<h1 className="font-bold text-lg my-1">
{l10n.getString(
'loyalty-discount-terms-heading',
'Terms and Restrictions'
)}
</h1>
<h1 className="font-bold text-lg my-1">
{`${churnIntervention.churnInterventions[0].termsHeading}`}
</h1>
<ul className="list-disc ml-5 my-2 marker:text-xs text-sm font-light [&_li]:leading-5">
{churnIntervention.churnInterventions[0].termsDetails.map(
(term, index) => (
<li key={index}>{term}</li>
)
)}
</ul>
<div className="flex justify-start">
<LinkExternal
href={`${churnIntervention.churnInterventions[0].supportUrl}${searchParamsString}`}
aria-label={l10n.getString(
'loyalty-discount-terms-support-aria',
'Contact Support'
)}
<section
className="flex tablet:items-center justify-center min-h-[calc(100vh_-_4rem)] tablet:min-h-[calc(100vh_-_5rem)]"
aria-labelledby="loyalty-discount-terms"
>
<div className="max-w-xl flex flex-col p-6 pt-10 tablet:bg-white tablet:border tablet:border-grey-200 tablet:opacity-100 tablet:p-8 tablet:rounded-xl tablet:shadow-[0_0px_10px_rgba(0,0,0,0.08)]">
<h1
id="loyalty-discount-terms"
className="font-semibold text-xl leading-8"
>
<BaseButton
variant={ButtonVariant.SubscriptionManagementSecondary}
className="w-40 mt-4"
{l10n.getString(
'loyalty-discount-terms-heading',
'Terms and restrictions'
)}
</h1>
<h2 className="font-semibold text-xl leading-8">
{`${content.termsHeading}`}
</h2>
<div className="mt-3 mx-6 mb-6 tablet:mx-10">
<ul className="font-light leading-6 list-disc marker:text-sm marker:leading-normal">
{content.termsDetails.map((term, index) => (
<li key={index}>{term}</li>
))}
</ul>
</div>
<div className="flex">
<LinkExternal
className="border box-border font-header rounded text-center py-2 px-5 border-grey-200 w-auto bg-grey-10 font-semibold hover:bg-grey-50 text-grey-700"
href={`${content.supportUrl}${searchParamsString}`}
aria-label={l10n.getString(
'loyalty-discount-terms-contact-support-product-aria',
{
productName: content.productName,
},
`Contact support for ${content.productName}`
)}
>
<span>
{l10n.getString(
'loyalty-discount-terms-support',
'Contact Support'
)}
</span>
</BaseButton>
</LinkExternal>
{l10n.getString(
'loyalty-discount-terms-support',
'Contact Support'
)}
</LinkExternal>
</div>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,26 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { BaseError } from '@fxa/shared/error';
/**
* ChurnInterventionError is not intended for direct use, except for type-checking errors.
* When throwing a new ChurnInterventionError, create a unique extension of the class.
*/
export class ChurnInterventionError extends BaseError {
constructor(message: string, info: Record<string, any>, cause?: Error) {
super(message, { info, cause });
this.name = 'ChurnInterventionError';
}
}
export class ChurnInterventionProductIdentifierMissingError extends ChurnInterventionError {
constructor() {
super(
'Either stripeProductId or offeringApiIdentifier must be provided',
{}
);
this.name = 'ChurnInterventionProductIdentifierMissingError';
}
}

View File

@@ -5,13 +5,14 @@
import { Inject, Injectable, Logger, type LoggerService } from '@nestjs/common';
import { ChurnInterventionManager } from '@fxa/payments/cart';
import {
ChurnInterventionByProductIdResultUtil,
ProductConfigurationManager,
} from '@fxa/shared/cms';
import { SubscriptionManagementService } from './subscriptionManagement.service';
import {
StatsDService,
} from '@fxa/shared/metrics/statsd';
import { StatsDService } from '@fxa/shared/metrics/statsd';
import { StatsD } from 'hot-shots';
import { SubplatInterval } from '@fxa/payments/customer';
import { ChurnInterventionProductIdentifierMissingError } from './churn-intervention.error';
@Injectable()
export class ChurnInterventionService {
@@ -20,10 +21,9 @@ export class ChurnInterventionService {
private churnInterventionManager: ChurnInterventionManager,
private subscriptionManagementService: SubscriptionManagementService,
@Inject(StatsDService) private statsd: StatsD,
@Inject(Logger) private log: LoggerService,
@Inject(Logger) private log: LoggerService
) {}
async getChurnInterventionForCustomerId(
customerId: string,
churnInterventionId: string
@@ -34,11 +34,47 @@ export class ChurnInterventionService {
);
}
async getChurnInterventionForProduct(
interval: SubplatInterval,
churnType: 'cancel' | 'stay_subscribed',
stripeProductId?: string,
offeringApiIdentifier?: string,
acceptLanguage?: string,
selectedLanguage?: string
) {
let util: ChurnInterventionByProductIdResultUtil;
if (stripeProductId) {
util = await this.productConfigurationManager.getChurnIntervention(
interval,
churnType,
stripeProductId,
null,
acceptLanguage,
selectedLanguage
);
} else if (offeringApiIdentifier) {
util = await this.productConfigurationManager.getChurnIntervention(
interval,
churnType,
null,
offeringApiIdentifier,
acceptLanguage,
selectedLanguage
);
} else {
throw new ChurnInterventionProductIdentifierMissingError();
}
return {
churnInterventions: util.getTransformedChurnInterventionByProductId(),
};
}
async determineStaySubscribedEligibility(
uid: string,
subscriptionId: string,
acceptLanguage?: string | null,
selectedLanguage?: string,
selectedLanguage?: string
) {
try {
const cmsChurnResult =
@@ -49,7 +85,8 @@ export class ChurnInterventionService {
selectedLanguage
);
const cmsChurnInterventionEntries = cmsChurnResult.getTransformedChurnInterventionByProductId();
const cmsChurnInterventionEntries =
cmsChurnResult.getTransformedChurnInterventionByProductId();
if (!cmsChurnInterventionEntries.length) {
this.statsd.increment('stay_subscribed_eligibility', {
eligibility: 'ineligible',
@@ -59,16 +96,20 @@ export class ChurnInterventionService {
isEligible: false,
reason: 'no_churn_intervention_found',
cmsChurnInterventionEntry: null,
}
};
}
const cmsChurnInterventionEntry = cmsChurnInterventionEntries[0];
const redemptionCount = await this.churnInterventionManager.getRedemptionCountForUid(
uid,
cmsChurnInterventionEntry.churnInterventionId
);
const redemptionCount =
await this.churnInterventionManager.getRedemptionCountForUid(
uid,
cmsChurnInterventionEntry.churnInterventionId
);
if (cmsChurnInterventionEntry.redemptionLimit && redemptionCount >= cmsChurnInterventionEntry.redemptionLimit) {
if (
cmsChurnInterventionEntry.redemptionLimit &&
redemptionCount >= cmsChurnInterventionEntry.redemptionLimit
) {
this.statsd.increment('stay_subscribed_eligibility', {
eligibility: 'ineligible',
reason: 'discount_already_applied',
@@ -77,13 +118,14 @@ export class ChurnInterventionService {
isEligible: false,
reason: 'discount_already_applied',
cmsChurnInterventionEntry: null,
}
};
}
const subscriptionStatus = await this.subscriptionManagementService.getSubscriptionStatus(
uid,
subscriptionId
);
const subscriptionStatus =
await this.subscriptionManagementService.getSubscriptionStatus(
uid,
subscriptionId
);
if (!subscriptionStatus.active) {
this.statsd.increment('stay_subscribed_eligibility', {
eligibility: 'ineligible',
@@ -93,7 +135,7 @@ export class ChurnInterventionService {
isEligible: false,
reason: 'subscription_not_active',
cmsChurnInterventionEntry: null,
}
};
}
if (!subscriptionStatus.cancelAtPeriodEnd) {
this.statsd.increment('stay_subscribed_eligibility', {
@@ -114,14 +156,14 @@ export class ChurnInterventionService {
isEligible: true,
reason: 'eligible',
cmsChurnInterventionEntry,
}
};
} catch (error) {
this.log.error(error);
return {
isEligible: false,
reason: 'general_error',
cmsChurnInterventionEntry: null,
}
};
}
}
@@ -129,7 +171,7 @@ export class ChurnInterventionService {
uid: string,
subscriptionId: string,
acceptLanguage?: string | null,
selectedLanguage?: string,
selectedLanguage?: string
) {
const eligibilityResult = await this.determineStaySubscribedEligibility(
uid,
@@ -138,7 +180,10 @@ export class ChurnInterventionService {
selectedLanguage
);
if (!eligibilityResult.isEligible || !eligibilityResult.cmsChurnInterventionEntry) {
if (
!eligibilityResult.isEligible ||
!eligibilityResult.cmsChurnInterventionEntry
) {
return {
redeemed: false,
reason: eligibilityResult.reason,
@@ -148,18 +193,26 @@ export class ChurnInterventionService {
}
try {
const updatedSubscription = await this.subscriptionManagementService.applyStripeCouponToSubscription({
uid,
subscriptionId,
stripeCouponId: eligibilityResult.cmsChurnInterventionEntry.stripeCouponId,
setCancelAtPeriodEnd: true,
});
if (!updatedSubscription || updatedSubscription.cancel_at_period_end !== true) {
const updatedSubscription =
await this.subscriptionManagementService.applyStripeCouponToSubscription(
{
uid,
subscriptionId,
stripeCouponId:
eligibilityResult.cmsChurnInterventionEntry.stripeCouponId,
setCancelAtPeriodEnd: true,
}
);
if (
!updatedSubscription ||
updatedSubscription.cancel_at_period_end !== true
) {
return {
redeemed: false,
reason: 'stripe_subscription_update_failed',
updatedChurnInterventionEntryData: null,
cmsChurnInterventionEntry: eligibilityResult.cmsChurnInterventionEntry,
cmsChurnInterventionEntry:
eligibilityResult.cmsChurnInterventionEntry,
};
}

View File

@@ -14,13 +14,15 @@ import {
} from '@fxa/payments/cart';
import { ContentServerManager } from '@fxa/payments/content-server';
import { CurrencyManager } from '@fxa/payments/currency';
import { SubscriptionManagementService, ChurnInterventionService } from '@fxa/payments/management';
import {
SubscriptionManagementService,
ChurnInterventionService,
} from '@fxa/payments/management';
import {
CheckoutTokenManager,
PaypalBillingAgreementManager,
} from '@fxa/payments/paypal';
import {
ChurnInterventionByProductIdResultUtil,
ProductConfigError,
ProductConfigurationManager,
} from '@fxa/shared/cms';
@@ -279,15 +281,12 @@ export class NextJSActionsService {
args.uid,
args.subscriptionId,
args.acceptLanguage,
args.selectedLanguage,
args.selectedLanguage
);
}
@SanitizeExceptions()
@NextIOValidator(
RedeemChurnCouponActionArgs,
RedeemChurnCouponActionResult
)
@NextIOValidator(RedeemChurnCouponActionArgs, RedeemChurnCouponActionResult)
@WithTypeCachableAsyncLocalStorage()
@CaptureTimingWithStatsD()
async redeemChurnCoupon(args: {
@@ -300,7 +299,7 @@ export class NextJSActionsService {
args.uid,
args.subscriptionId,
args.acceptLanguage,
args.selectedLanguage,
args.selectedLanguage
);
}
@@ -903,36 +902,13 @@ export class NextJSActionsService {
acceptLanguage?: string;
selectedLanguage?: string;
}) {
let util: ChurnInterventionByProductIdResultUtil;
if (args.stripeProductId) {
util = await this.productConfigurationManager.getChurnIntervention(
args.interval,
args.churnType,
args.stripeProductId,
null,
args.acceptLanguage,
args.selectedLanguage
);
} else if (args.offeringApiIdentifier) {
util = await this.productConfigurationManager.getChurnIntervention(
args.interval,
args.churnType,
null,
args.offeringApiIdentifier,
args.acceptLanguage,
args.selectedLanguage
);
} else {
throw new Error(
'Either stripeProductId or offeringApiIdentifier must be provided'
);
}
const churnInterventions =
util.getTransformedChurnInterventionByProductId();
return {
churnInterventions,
};
return await this.churnInterventionService.getChurnInterventionForProduct(
args.interval,
args.churnType,
args.stripeProductId,
args.offeringApiIdentifier,
args.acceptLanguage,
args.selectedLanguage
);
}
}

View File

@@ -16,7 +16,7 @@ import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/
type Documents = {
"\n query CancelInterstitialOffer(\n $offeringApiIdentifier: String!\n $currentInterval: String!\n $upgradeInterval: String!\n $locale: String!\n ) {\n cancelInterstitialOffers(\n filters: {\n offeringApiIdentifier: { eq: $offeringApiIdentifier }\n currentInterval: { eq: $currentInterval }\n upgradeInterval: { eq: $upgradeInterval }\n }\n ) {\n offeringApiIdentifier\n currentInterval\n upgradeInterval\n advertisedSavings\n ctaMessage\n modalHeading1\n modalHeading2\n modalMessage\n productPageUrl\n upgradeButtonLabel\n upgradeButtonUrl\n localizations(filters: { locale: { eq: $locale } }) {\n ctaMessage\n modalHeading1\n modalHeading2\n modalMessage\n productPageUrl\n upgradeButtonLabel\n upgradeButtonUrl\n }\n offering {\n stripeProductId\n defaultPurchase {\n purchaseDetails {\n webIcon\n localizations(filters: { locale: { eq: $locale } }) {\n webIcon\n }\n }\n }\n }\n }\n }\n": typeof types.CancelInterstitialOfferDocument,
"\n query CapabilityServiceByPlanIds($stripePlanIds: [String]!) {\n purchases(\n filters: {\n or: [\n { stripePlanChoices: { stripePlanChoice: { in: $stripePlanIds } } }\n {\n offering: {\n stripeLegacyPlans: { stripeLegacyPlan: { in: $stripePlanIds } }\n }\n }\n ]\n }\n pagination: { limit: 200 }\n ) {\n stripePlanChoices {\n stripePlanChoice\n }\n offering {\n stripeLegacyPlans(pagination: { limit: 200 }) {\n stripeLegacyPlan\n }\n capabilities {\n slug\n services {\n oauthClientId\n }\n }\n }\n }\n }\n": typeof types.CapabilityServiceByPlanIdsDocument,
"\n query ChurnInterventionByProductId(\n $offeringApiIdentifier: String\n $stripeProductId: String\n $interval: String!\n $locale: String!\n $churnType: String!\n ) {\n offerings(\n filters: {\n or: [\n { stripeProductId: { eq: $stripeProductId } }\n { apiIdentifier: { eq: $offeringApiIdentifier } }\n ]\n }\n pagination: { limit: 200 }\n ) {\n defaultPurchase {\n purchaseDetails {\n webIcon\n localizations(filters: { locale: { eq: $locale } }) {\n webIcon\n }\n }\n }\n commonContent {\n supportUrl\n }\n churnInterventions(\n filters: { interval: { eq: $interval }, churnType: { eq: $churnType } }\n pagination: { limit: 200 }\n ) {\n localizations(filters: { locale: { eq: $locale } }) {\n churnInterventionId\n churnType\n redemptionLimit\n stripeCouponId\n interval\n discountAmount\n ctaMessage\n modalHeading\n modalMessage\n productPageUrl\n termsHeading\n termsDetails\n }\n churnInterventionId\n churnType\n redemptionLimit\n stripeCouponId\n interval\n discountAmount\n ctaMessage\n modalHeading\n modalMessage\n productPageUrl\n termsHeading\n termsDetails\n }\n }\n }\n": typeof types.ChurnInterventionByProductIdDocument,
"\n query ChurnInterventionByProductId(\n $offeringApiIdentifier: String\n $stripeProductId: String\n $interval: String!\n $locale: String!\n $churnType: String!\n ) {\n offerings(\n filters: {\n or: [\n { stripeProductId: { eq: $stripeProductId } }\n { apiIdentifier: { eq: $offeringApiIdentifier } }\n ]\n }\n pagination: { limit: 200 }\n ) {\n defaultPurchase {\n purchaseDetails {\n productName\n webIcon\n localizations(filters: { locale: { eq: $locale } }) {\n productName\n webIcon\n }\n }\n }\n commonContent {\n supportUrl\n }\n churnInterventions(\n filters: { interval: { eq: $interval }, churnType: { eq: $churnType } }\n pagination: { limit: 200 }\n ) {\n localizations(filters: { locale: { eq: $locale } }) {\n churnInterventionId\n churnType\n redemptionLimit\n stripeCouponId\n interval\n discountAmount\n ctaMessage\n modalHeading\n modalMessage\n productPageUrl\n termsHeading\n termsDetails\n }\n churnInterventionId\n churnType\n redemptionLimit\n stripeCouponId\n interval\n discountAmount\n ctaMessage\n modalHeading\n modalMessage\n productPageUrl\n termsHeading\n termsDetails\n }\n }\n }\n": typeof types.ChurnInterventionByProductIdDocument,
"\n query EligibilityContentByOffering($apiIdentifier: String!) {\n offerings(\n filters: { apiIdentifier: { eq: $apiIdentifier } }\n pagination: { limit: 200 }\n ) {\n apiIdentifier\n stripeProductId\n defaultPurchase {\n stripePlanChoices {\n stripePlanChoice\n }\n }\n subGroups {\n groupName\n offerings {\n apiIdentifier\n stripeProductId\n defaultPurchase {\n stripePlanChoices {\n stripePlanChoice\n }\n }\n }\n }\n }\n }\n": typeof types.EligibilityContentByOfferingDocument,
"\n query EligibilityContentByPlanIds($stripePlanIds: [String]!) {\n purchases(\n filters: {\n or: [\n { stripePlanChoices: { stripePlanChoice: { in: $stripePlanIds } } }\n {\n offering: {\n stripeLegacyPlans: { stripeLegacyPlan: { in: $stripePlanIds } }\n }\n }\n ]\n }\n pagination: { limit: 200 }\n ) {\n stripePlanChoices {\n stripePlanChoice\n }\n offering {\n apiIdentifier\n stripeProductId\n stripeLegacyPlans(pagination: { limit: 200 }) {\n stripeLegacyPlan\n }\n countries\n subGroups {\n groupName\n offerings {\n apiIdentifier\n stripeProductId\n stripeLegacyPlans(pagination: { limit: 200 }) {\n stripeLegacyPlan\n }\n countries\n }\n }\n }\n }\n }\n": typeof types.EligibilityContentByPlanIdsDocument,
"\n query IapOfferingsByStoreIDs($locale: String!, $storeIDs: [String!]!) {\n iaps(filters: { storeID: { in: $storeIDs } }) {\n storeID\n interval\n offering {\n apiIdentifier\n commonContent {\n supportUrl\n localizations(filters: { locale: { eq: $locale } }) {\n supportUrl\n }\n }\n defaultPurchase {\n stripePlanChoices {\n stripePlanChoice\n }\n purchaseDetails {\n productName\n webIcon\n localizations(filters: { locale: { eq: $locale } }) {\n productName\n webIcon\n }\n }\n }\n subGroups {\n groupName\n offerings {\n apiIdentifier\n }\n }\n }\n }\n }\n": typeof types.IapOfferingsByStoreIDsDocument,
@@ -31,7 +31,7 @@ type Documents = {
const documents: Documents = {
"\n query CancelInterstitialOffer(\n $offeringApiIdentifier: String!\n $currentInterval: String!\n $upgradeInterval: String!\n $locale: String!\n ) {\n cancelInterstitialOffers(\n filters: {\n offeringApiIdentifier: { eq: $offeringApiIdentifier }\n currentInterval: { eq: $currentInterval }\n upgradeInterval: { eq: $upgradeInterval }\n }\n ) {\n offeringApiIdentifier\n currentInterval\n upgradeInterval\n advertisedSavings\n ctaMessage\n modalHeading1\n modalHeading2\n modalMessage\n productPageUrl\n upgradeButtonLabel\n upgradeButtonUrl\n localizations(filters: { locale: { eq: $locale } }) {\n ctaMessage\n modalHeading1\n modalHeading2\n modalMessage\n productPageUrl\n upgradeButtonLabel\n upgradeButtonUrl\n }\n offering {\n stripeProductId\n defaultPurchase {\n purchaseDetails {\n webIcon\n localizations(filters: { locale: { eq: $locale } }) {\n webIcon\n }\n }\n }\n }\n }\n }\n": types.CancelInterstitialOfferDocument,
"\n query CapabilityServiceByPlanIds($stripePlanIds: [String]!) {\n purchases(\n filters: {\n or: [\n { stripePlanChoices: { stripePlanChoice: { in: $stripePlanIds } } }\n {\n offering: {\n stripeLegacyPlans: { stripeLegacyPlan: { in: $stripePlanIds } }\n }\n }\n ]\n }\n pagination: { limit: 200 }\n ) {\n stripePlanChoices {\n stripePlanChoice\n }\n offering {\n stripeLegacyPlans(pagination: { limit: 200 }) {\n stripeLegacyPlan\n }\n capabilities {\n slug\n services {\n oauthClientId\n }\n }\n }\n }\n }\n": types.CapabilityServiceByPlanIdsDocument,
"\n query ChurnInterventionByProductId(\n $offeringApiIdentifier: String\n $stripeProductId: String\n $interval: String!\n $locale: String!\n $churnType: String!\n ) {\n offerings(\n filters: {\n or: [\n { stripeProductId: { eq: $stripeProductId } }\n { apiIdentifier: { eq: $offeringApiIdentifier } }\n ]\n }\n pagination: { limit: 200 }\n ) {\n defaultPurchase {\n purchaseDetails {\n webIcon\n localizations(filters: { locale: { eq: $locale } }) {\n webIcon\n }\n }\n }\n commonContent {\n supportUrl\n }\n churnInterventions(\n filters: { interval: { eq: $interval }, churnType: { eq: $churnType } }\n pagination: { limit: 200 }\n ) {\n localizations(filters: { locale: { eq: $locale } }) {\n churnInterventionId\n churnType\n redemptionLimit\n stripeCouponId\n interval\n discountAmount\n ctaMessage\n modalHeading\n modalMessage\n productPageUrl\n termsHeading\n termsDetails\n }\n churnInterventionId\n churnType\n redemptionLimit\n stripeCouponId\n interval\n discountAmount\n ctaMessage\n modalHeading\n modalMessage\n productPageUrl\n termsHeading\n termsDetails\n }\n }\n }\n": types.ChurnInterventionByProductIdDocument,
"\n query ChurnInterventionByProductId(\n $offeringApiIdentifier: String\n $stripeProductId: String\n $interval: String!\n $locale: String!\n $churnType: String!\n ) {\n offerings(\n filters: {\n or: [\n { stripeProductId: { eq: $stripeProductId } }\n { apiIdentifier: { eq: $offeringApiIdentifier } }\n ]\n }\n pagination: { limit: 200 }\n ) {\n defaultPurchase {\n purchaseDetails {\n productName\n webIcon\n localizations(filters: { locale: { eq: $locale } }) {\n productName\n webIcon\n }\n }\n }\n commonContent {\n supportUrl\n }\n churnInterventions(\n filters: { interval: { eq: $interval }, churnType: { eq: $churnType } }\n pagination: { limit: 200 }\n ) {\n localizations(filters: { locale: { eq: $locale } }) {\n churnInterventionId\n churnType\n redemptionLimit\n stripeCouponId\n interval\n discountAmount\n ctaMessage\n modalHeading\n modalMessage\n productPageUrl\n termsHeading\n termsDetails\n }\n churnInterventionId\n churnType\n redemptionLimit\n stripeCouponId\n interval\n discountAmount\n ctaMessage\n modalHeading\n modalMessage\n productPageUrl\n termsHeading\n termsDetails\n }\n }\n }\n": types.ChurnInterventionByProductIdDocument,
"\n query EligibilityContentByOffering($apiIdentifier: String!) {\n offerings(\n filters: { apiIdentifier: { eq: $apiIdentifier } }\n pagination: { limit: 200 }\n ) {\n apiIdentifier\n stripeProductId\n defaultPurchase {\n stripePlanChoices {\n stripePlanChoice\n }\n }\n subGroups {\n groupName\n offerings {\n apiIdentifier\n stripeProductId\n defaultPurchase {\n stripePlanChoices {\n stripePlanChoice\n }\n }\n }\n }\n }\n }\n": types.EligibilityContentByOfferingDocument,
"\n query EligibilityContentByPlanIds($stripePlanIds: [String]!) {\n purchases(\n filters: {\n or: [\n { stripePlanChoices: { stripePlanChoice: { in: $stripePlanIds } } }\n {\n offering: {\n stripeLegacyPlans: { stripeLegacyPlan: { in: $stripePlanIds } }\n }\n }\n ]\n }\n pagination: { limit: 200 }\n ) {\n stripePlanChoices {\n stripePlanChoice\n }\n offering {\n apiIdentifier\n stripeProductId\n stripeLegacyPlans(pagination: { limit: 200 }) {\n stripeLegacyPlan\n }\n countries\n subGroups {\n groupName\n offerings {\n apiIdentifier\n stripeProductId\n stripeLegacyPlans(pagination: { limit: 200 }) {\n stripeLegacyPlan\n }\n countries\n }\n }\n }\n }\n }\n": types.EligibilityContentByPlanIdsDocument,
"\n query IapOfferingsByStoreIDs($locale: String!, $storeIDs: [String!]!) {\n iaps(filters: { storeID: { in: $storeIDs } }) {\n storeID\n interval\n offering {\n apiIdentifier\n commonContent {\n supportUrl\n localizations(filters: { locale: { eq: $locale } }) {\n supportUrl\n }\n }\n defaultPurchase {\n stripePlanChoices {\n stripePlanChoice\n }\n purchaseDetails {\n productName\n webIcon\n localizations(filters: { locale: { eq: $locale } }) {\n productName\n webIcon\n }\n }\n }\n subGroups {\n groupName\n offerings {\n apiIdentifier\n }\n }\n }\n }\n }\n": types.IapOfferingsByStoreIDsDocument,
@@ -69,7 +69,7 @@ export function graphql(source: "\n query CapabilityServiceByPlanIds($stripePla
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n query ChurnInterventionByProductId(\n $offeringApiIdentifier: String\n $stripeProductId: String\n $interval: String!\n $locale: String!\n $churnType: String!\n ) {\n offerings(\n filters: {\n or: [\n { stripeProductId: { eq: $stripeProductId } }\n { apiIdentifier: { eq: $offeringApiIdentifier } }\n ]\n }\n pagination: { limit: 200 }\n ) {\n defaultPurchase {\n purchaseDetails {\n webIcon\n localizations(filters: { locale: { eq: $locale } }) {\n webIcon\n }\n }\n }\n commonContent {\n supportUrl\n }\n churnInterventions(\n filters: { interval: { eq: $interval }, churnType: { eq: $churnType } }\n pagination: { limit: 200 }\n ) {\n localizations(filters: { locale: { eq: $locale } }) {\n churnInterventionId\n churnType\n redemptionLimit\n stripeCouponId\n interval\n discountAmount\n ctaMessage\n modalHeading\n modalMessage\n productPageUrl\n termsHeading\n termsDetails\n }\n churnInterventionId\n churnType\n redemptionLimit\n stripeCouponId\n interval\n discountAmount\n ctaMessage\n modalHeading\n modalMessage\n productPageUrl\n termsHeading\n termsDetails\n }\n }\n }\n"): (typeof documents)["\n query ChurnInterventionByProductId(\n $offeringApiIdentifier: String\n $stripeProductId: String\n $interval: String!\n $locale: String!\n $churnType: String!\n ) {\n offerings(\n filters: {\n or: [\n { stripeProductId: { eq: $stripeProductId } }\n { apiIdentifier: { eq: $offeringApiIdentifier } }\n ]\n }\n pagination: { limit: 200 }\n ) {\n defaultPurchase {\n purchaseDetails {\n webIcon\n localizations(filters: { locale: { eq: $locale } }) {\n webIcon\n }\n }\n }\n commonContent {\n supportUrl\n }\n churnInterventions(\n filters: { interval: { eq: $interval }, churnType: { eq: $churnType } }\n pagination: { limit: 200 }\n ) {\n localizations(filters: { locale: { eq: $locale } }) {\n churnInterventionId\n churnType\n redemptionLimit\n stripeCouponId\n interval\n discountAmount\n ctaMessage\n modalHeading\n modalMessage\n productPageUrl\n termsHeading\n termsDetails\n }\n churnInterventionId\n churnType\n redemptionLimit\n stripeCouponId\n interval\n discountAmount\n ctaMessage\n modalHeading\n modalMessage\n productPageUrl\n termsHeading\n termsDetails\n }\n }\n }\n"];
export function graphql(source: "\n query ChurnInterventionByProductId(\n $offeringApiIdentifier: String\n $stripeProductId: String\n $interval: String!\n $locale: String!\n $churnType: String!\n ) {\n offerings(\n filters: {\n or: [\n { stripeProductId: { eq: $stripeProductId } }\n { apiIdentifier: { eq: $offeringApiIdentifier } }\n ]\n }\n pagination: { limit: 200 }\n ) {\n defaultPurchase {\n purchaseDetails {\n productName\n webIcon\n localizations(filters: { locale: { eq: $locale } }) {\n productName\n webIcon\n }\n }\n }\n commonContent {\n supportUrl\n }\n churnInterventions(\n filters: { interval: { eq: $interval }, churnType: { eq: $churnType } }\n pagination: { limit: 200 }\n ) {\n localizations(filters: { locale: { eq: $locale } }) {\n churnInterventionId\n churnType\n redemptionLimit\n stripeCouponId\n interval\n discountAmount\n ctaMessage\n modalHeading\n modalMessage\n productPageUrl\n termsHeading\n termsDetails\n }\n churnInterventionId\n churnType\n redemptionLimit\n stripeCouponId\n interval\n discountAmount\n ctaMessage\n modalHeading\n modalMessage\n productPageUrl\n termsHeading\n termsDetails\n }\n }\n }\n"): (typeof documents)["\n query ChurnInterventionByProductId(\n $offeringApiIdentifier: String\n $stripeProductId: String\n $interval: String!\n $locale: String!\n $churnType: String!\n ) {\n offerings(\n filters: {\n or: [\n { stripeProductId: { eq: $stripeProductId } }\n { apiIdentifier: { eq: $offeringApiIdentifier } }\n ]\n }\n pagination: { limit: 200 }\n ) {\n defaultPurchase {\n purchaseDetails {\n productName\n webIcon\n localizations(filters: { locale: { eq: $locale } }) {\n productName\n webIcon\n }\n }\n }\n commonContent {\n supportUrl\n }\n churnInterventions(\n filters: { interval: { eq: $interval }, churnType: { eq: $churnType } }\n pagination: { limit: 200 }\n ) {\n localizations(filters: { locale: { eq: $locale } }) {\n churnInterventionId\n churnType\n redemptionLimit\n stripeCouponId\n interval\n discountAmount\n ctaMessage\n modalHeading\n modalMessage\n productPageUrl\n termsHeading\n termsDetails\n }\n churnInterventionId\n churnType\n redemptionLimit\n stripeCouponId\n interval\n discountAmount\n ctaMessage\n modalHeading\n modalMessage\n productPageUrl\n termsHeading\n termsDetails\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/

File diff suppressed because one or more lines are too long

View File

@@ -40,9 +40,11 @@ export const ChurnInterventionByProductIdOfferingsResultFactory = (
): ChurnInterventionByProductIdOfferingResult => ({
defaultPurchase: {
purchaseDetails: {
productName: faker.string.sample(),
webIcon: faker.image.urlLoremFlickr(),
localizations: [
{
productName: faker.string.sample(),
webIcon: faker.image.urlLoremFlickr(),
},
],
@@ -77,6 +79,7 @@ export const ChurnInterventionByProductIdRawResultFactory = (
export const ChurnInterventionByProductIdResultFactory = (
override?: Partial<ChurnInterventionByProductIdResult>
): ChurnInterventionByProductIdResult => ({
productName: faker.string.sample(),
webIcon: faker.image.urlLoremFlickr(),
churnInterventionId: faker.string.uuid(),
churnType: faker.helpers.enumValue(Enum_Churnintervention_Churntype),

View File

@@ -23,8 +23,10 @@ export const churnInterventionByProductIdQuery = graphql(`
) {
defaultPurchase {
purchaseDetails {
productName
webIcon
localizations(filters: { locale: { eq: $locale } }) {
productName
webIcon
}
}

View File

@@ -25,8 +25,9 @@ export interface ChurnInterventionByProductIdChurnInterventionsResult {
export interface ChurnInterventionByProductIdOfferingResult {
defaultPurchase: {
purchaseDetails: {
productName: string;
webIcon: string;
localizations: { webIcon: string }[];
localizations: { productName: string; webIcon: string }[];
};
};
commonContent: {
@@ -42,6 +43,7 @@ export interface ChurnInterventionByProductIdRawResult {
}
export interface ChurnInterventionByProductIdResult {
productName: string;
webIcon: string;
churnInterventionId: string;
churnType: Enum_Churnintervention_Churntype;

View File

@@ -36,6 +36,7 @@ describe('ChurnInterventionByProductIdResultUtil', () => {
it('should transform churn intervention by offering', () => {
const transformed = util.getTransformedChurnInterventionByProductId()[0];
expect(transformed).toBeDefined();
expect(transformed?.productName).toBeDefined();
expect(transformed?.webIcon).toBeDefined();
expect(transformed?.supportUrl).toBeDefined();
expect(transformed?.churnInterventionId).toBeDefined();

View File

@@ -30,6 +30,10 @@ export class ChurnInterventionByProductIdResultUtil {
defaultPurchase.purchaseDetails.localizations.length > 0
? defaultPurchase.purchaseDetails.localizations[0].webIcon
: defaultPurchase.purchaseDetails.webIcon,
productName:
defaultPurchase.purchaseDetails.localizations.length > 0
? defaultPurchase.purchaseDetails.localizations[0].productName
: defaultPurchase.purchaseDetails.productName,
supportUrl: commonContent.supportUrl,
ctaMessage:
churnIntervention.localizations.at(0)?.ctaMessage ??

View File

@@ -15,6 +15,7 @@ interface LinkExternalProps {
rel?: 'noopener noreferrer' | 'author';
tabIndex?: number;
onClick?: () => void;
'aria-label'?: string;
}
export const LinkExternal = ({
@@ -26,6 +27,7 @@ export const LinkExternal = ({
rel = 'noopener noreferrer',
tabIndex,
onClick,
'aria-label': ariaLabel,
}: LinkExternalProps) => (
<a
data-testid={testid}
@@ -37,6 +39,7 @@ export const LinkExternal = ({
rel,
tabIndex,
onClick,
'aria-label': ariaLabel,
}}
>
{children}