diff --git a/cdk/lib/support-workers.ts b/cdk/lib/support-workers.ts index 5a2fa9a9ea..11fc8e866a 100644 --- a/cdk/lib/support-workers.ts +++ b/cdk/lib/support-workers.ts @@ -28,7 +28,12 @@ import { import { LambdaInvoke } from "aws-cdk-lib/aws-stepfunctions-tasks"; type PaymentProvider = "Stripe" | "DirectDebit" | "PayPal"; -type ProductType = "Contribution" | "Paper" | "GuardianWeekly" | "SupporterPlus" | "TierThree"; +type ProductType = + | "Contribution" + | "Paper" + | "GuardianWeekly" + | "SupporterPlus" + | "TierThree"; interface SupportWorkersProps extends GuStackProps { promotionsDynamoTables: string[]; @@ -36,6 +41,7 @@ interface SupportWorkersProps extends GuStackProps { supporterProductDataTables: string[]; eventBusArns: string[]; } + export class SupportWorkers extends GuStack { constructor(scope: App, id: string, props: SupportWorkersProps) { super(scope, id, props); @@ -321,7 +327,11 @@ export class SupportWorkers extends GuStack { actionsEnabled: isProd, snsTopicName: `alarms-handler-topic-${this.stage}`, alarmName: `support-workers ${this.stage} No successful recurring paypal contributions recently.`, - metric: this.buildPaymentSuccessMetric("PayPal", "Contribution", Duration.seconds(3600)), + metric: this.buildPaymentSuccessMetric( + "PayPal", + "Contribution", + Duration.seconds(3600) + ), comparisonOperator: ComparisonOperator.LESS_THAN_OR_EQUAL_TO_THRESHOLD, evaluationPeriods: 4, treatMissingData: TreatMissingData.BREACHING, @@ -333,7 +343,11 @@ export class SupportWorkers extends GuStack { actionsEnabled: isProd, snsTopicName: `alarms-handler-topic-${this.stage}`, alarmName: `support-workers ${this.stage} No successful recurring stripe contributions recently.`, - metric: this.buildPaymentSuccessMetric("Stripe", "Contribution", Duration.seconds(3600)), + metric: this.buildPaymentSuccessMetric( + "Stripe", + "Contribution", + Duration.seconds(3600) + ), comparisonOperator: ComparisonOperator.LESS_THAN_OR_EQUAL_TO_THRESHOLD, evaluationPeriods: 3, treatMissingData: TreatMissingData.BREACHING, @@ -345,7 +359,11 @@ export class SupportWorkers extends GuStack { actionsEnabled: isProd, snsTopicName: `alarms-handler-topic-${this.stage}`, alarmName: `support-workers ${this.stage} No successful recurring gocardless contributions recently.`, - metric: this.buildPaymentSuccessMetric("DirectDebit", "Contribution", Duration.seconds(3600)), + metric: this.buildPaymentSuccessMetric( + "DirectDebit", + "Contribution", + Duration.seconds(3600) + ), comparisonOperator: ComparisonOperator.LESS_THAN_OR_EQUAL_TO_THRESHOLD, evaluationPeriods: 18, treatMissingData: TreatMissingData.BREACHING, @@ -357,7 +375,11 @@ export class SupportWorkers extends GuStack { actionsEnabled: isProd, snsTopicName: `alarms-handler-topic-${this.stage}`, alarmName: `support-workers ${this.stage} No successful recurring paypal supporter plus contributions recently.`, - metric: this.buildPaymentSuccessMetric("PayPal", "SupporterPlus", Duration.seconds(3600)), + metric: this.buildPaymentSuccessMetric( + "PayPal", + "SupporterPlus", + Duration.seconds(3600) + ), comparisonOperator: ComparisonOperator.LESS_THAN_OR_EQUAL_TO_THRESHOLD, evaluationPeriods: 6, treatMissingData: TreatMissingData.BREACHING, @@ -369,7 +391,11 @@ export class SupportWorkers extends GuStack { actionsEnabled: isProd, snsTopicName: `alarms-handler-topic-${this.stage}`, alarmName: `support-workers ${this.stage} No successful recurring stripe supporter plus contributions recently.`, - metric: this.buildPaymentSuccessMetric("Stripe", "SupporterPlus", Duration.seconds(3600)), + metric: this.buildPaymentSuccessMetric( + "Stripe", + "SupporterPlus", + Duration.seconds(3600) + ), comparisonOperator: ComparisonOperator.LESS_THAN_OR_EQUAL_TO_THRESHOLD, evaluationPeriods: 3, treatMissingData: TreatMissingData.BREACHING, @@ -381,7 +407,11 @@ export class SupportWorkers extends GuStack { actionsEnabled: isProd, snsTopicName: `alarms-handler-topic-${this.stage}`, alarmName: `support-workers ${this.stage} No successful recurring gocardless supporter plus contributions recently.`, - metric: this.buildPaymentSuccessMetric("DirectDebit", "SupporterPlus", Duration.seconds(3600)), + metric: this.buildPaymentSuccessMetric( + "DirectDebit", + "SupporterPlus", + Duration.seconds(3600) + ), comparisonOperator: ComparisonOperator.LESS_THAN_OR_EQUAL_TO_THRESHOLD, evaluationPeriods: 18, treatMissingData: TreatMissingData.BREACHING, @@ -399,9 +429,21 @@ export class SupportWorkers extends GuStack { expression: "SUM([FILL(m1,0),FILL(m2,0),FILL(m3,0)])", label: "AllPaperConversions", usingMetrics: { - m1: this.buildPaymentSuccessMetric("Stripe", "Paper", Duration.seconds(300)), - m2: this.buildPaymentSuccessMetric("DirectDebit", "Paper", Duration.seconds(300)), - m3: this.buildPaymentSuccessMetric("PayPal", "Paper", Duration.seconds(300)), + m1: this.buildPaymentSuccessMetric( + "Stripe", + "Paper", + Duration.seconds(300) + ), + m2: this.buildPaymentSuccessMetric( + "DirectDebit", + "Paper", + Duration.seconds(300) + ), + m3: this.buildPaymentSuccessMetric( + "PayPal", + "Paper", + Duration.seconds(300) + ), }, }), comparisonOperator: ComparisonOperator.LESS_THAN_OR_EQUAL_TO_THRESHOLD, @@ -419,9 +461,21 @@ export class SupportWorkers extends GuStack { label: "AllWeeklyConversions", expression: "SUM([FILL(m1,0),FILL(m2,0),FILL(m3,0)])", usingMetrics: { - m1: this.buildPaymentSuccessMetric("Stripe", "GuardianWeekly", Duration.seconds(300)), - m2: this.buildPaymentSuccessMetric("DirectDebit", "GuardianWeekly", Duration.seconds(300)), - m3: this.buildPaymentSuccessMetric("PayPal", "GuardianWeekly", Duration.seconds(300)), + m1: this.buildPaymentSuccessMetric( + "Stripe", + "GuardianWeekly", + Duration.seconds(300) + ), + m2: this.buildPaymentSuccessMetric( + "DirectDebit", + "GuardianWeekly", + Duration.seconds(300) + ), + m3: this.buildPaymentSuccessMetric( + "PayPal", + "GuardianWeekly", + Duration.seconds(300) + ), }, }), comparisonOperator: ComparisonOperator.LESS_THAN_OR_EQUAL_TO_THRESHOLD, @@ -470,7 +524,11 @@ export class SupportWorkers extends GuStack { }).node.addDependency(stateMachine); } - buildPaymentSuccessMetric = (paymentProvider: PaymentProvider, productType: ProductType, period: Duration) => { + buildPaymentSuccessMetric = ( + paymentProvider: PaymentProvider, + productType: ProductType, + period: Duration + ) => { return new Metric({ metricName: "PaymentSuccess", namespace: "support-frontend", @@ -481,6 +539,6 @@ export class SupportWorkers extends GuStack { }, statistic: "Sum", period: period, - }) - } + }); + }; } diff --git a/support-e2e/tests/test/checkout.ts b/support-e2e/tests/test/checkout.ts index 1d422d14f0..974a089865 100644 --- a/support-e2e/tests/test/checkout.ts +++ b/support-e2e/tests/test/checkout.ts @@ -58,7 +58,8 @@ export const testCheckout = (testDetails: TestDetails) => { context, baseURL, }) => { - const url = `/${internationalisationId.toLowerCase()}/checkout?product=${product}&ratePlan=${ratePlan}`; + // Temporary opt out of this test + const url = `/${internationalisationId.toLowerCase()}/checkout?product=${product}&ratePlan=${ratePlan}#ab-confirmEmail=control`; const page = await context.newPage(); await setupPage(page, context, baseURL, url); diff --git a/support-frontend/assets/helpers/abTests/abtestDefinitions.ts b/support-frontend/assets/helpers/abTests/abtestDefinitions.ts index a37e926b6a..68755b62a4 100644 --- a/support-frontend/assets/helpers/abTests/abtestDefinitions.ts +++ b/support-frontend/assets/helpers/abTests/abtestDefinitions.ts @@ -92,4 +92,25 @@ export const tests: Tests = { targetPage: pageUrlRegexes.contributions.allLandingPagesAndThankyouPages, excludeContributionsOnlyCountries: true, }, + confirmEmail: { + variants: [ + { + id: 'control', + }, + { + id: 'variant', + }, + ], + audiences: { + ALL: { + offset: 0, + size: 1, + }, + }, + isActive: true, + referrerControlled: false, // ab-test name not needed to be in paramURL + seed: 5, + targetPage: pageUrlRegexes.contributions.genericCheckoutOnly, + excludeContributionsOnlyCountries: false, + }, }; diff --git a/support-frontend/assets/pages/[countryGroupId]/checkout/components/PersonalDetailsFields.tsx b/support-frontend/assets/pages/[countryGroupId]/checkout/components/PersonalDetailsFields.tsx index 4240ce8952..746902d500 100644 --- a/support-frontend/assets/pages/[countryGroupId]/checkout/components/PersonalDetailsFields.tsx +++ b/support-frontend/assets/pages/[countryGroupId]/checkout/components/PersonalDetailsFields.tsx @@ -1,4 +1,5 @@ import { TextInput } from '@guardian/source/react-components'; +import escapeStringRegexp from 'escape-string-regexp'; import { useState } from 'react'; import { doesNotContainExtendedEmojiOrLeadingSpace, @@ -14,6 +15,9 @@ type PersonalDetailsFieldsProps = { email: string; setEmail: (value: string) => void; isEmailAddressReadOnly: boolean; + requireConfirmedEmail: boolean; + confirmedEmail: string; + setConfirmedEmail: (value: string) => void; }; export function PersonalDetailsFields({ @@ -25,10 +29,14 @@ export function PersonalDetailsFields({ email, setEmail, isEmailAddressReadOnly, + requireConfirmedEmail, + confirmedEmail, + setConfirmedEmail, }: PersonalDetailsFieldsProps) { const [firstNameError, setFirstNameError] = useState(); const [lastNameError, setLastNameError] = useState(); const [emailError, setEmailError] = useState(); + const [confirmedEmailError, setConfirmedEmailError] = useState(); return ( <> @@ -66,6 +74,44 @@ export function PersonalDetailsFields({ }} /> + {requireConfirmedEmail && !isEmailAddressReadOnly && ( +
+ { + setConfirmedEmail(event.currentTarget.value); + }} + onBlur={(event) => { + event.target.checkValidity(); + }} + name="confirm-email" + required + maxLength={80} + error={confirmedEmailError} + pattern={escapeStringRegexp(email)} + onInvalid={(event) => { + preventDefaultValidityMessage(event.currentTarget); + const validityState = event.currentTarget.validity; + if (validityState.valid) { + setConfirmedEmailError(undefined); + } else { + if (validityState.valueMissing) { + setConfirmedEmailError('Please confirm your email address.'); + } else if (validityState.patternMismatch) { + setConfirmedEmailError('The email addresses do not match.'); + } else { + setConfirmedEmailError('Please enter a valid email address.'); + } + } + }} + /> +
+ )} {children}
{ setIsProcessingPayment(true); + /** * The validation for this is currently happening on the client side form validation * So we'll assume strings are not null. @@ -571,10 +575,6 @@ export function CheckoutComponent({ labels: ['generic-checkout'], }; - if (stripeExpressCheckoutPaymentType === 'link') { - referrerAcquisitionData.labels.push('express-checkout-link'); - } - if (paymentMethod && paymentFields) { /** TODO * - add debugInfo @@ -879,6 +879,8 @@ export function CheckoutComponent({ event.billingDetails?.email && setEmail(event.billingDetails.email); + event.billingDetails?.email && + setConfirmedEmail(event.billingDetails.email); setPaymentMethod('StripeExpressCheckoutElement'); setStripeExpressCheckoutPaymentType( @@ -942,6 +944,11 @@ export function CheckoutComponent({ setLastName={(lastName) => setLastName(lastName)} email={email} setEmail={(email) => setEmail(email)} + requireConfirmedEmail={inConfirmEmailVariant} + confirmedEmail={confirmedEmail} + setConfirmedEmail={(confirmedEmail) => + setConfirmedEmail(confirmedEmail) + } > diff --git a/support-frontend/package.json b/support-frontend/package.json index e96ce4ba73..9b28cb287e 100644 --- a/support-frontend/package.json +++ b/support-frontend/package.json @@ -97,6 +97,7 @@ "@types/uuid": "^9.0.2", "classnames": "^2.5.1", "dompurify": "^3.2.0", + "escape-string-regexp": "5.0.0", "framer-motion": "^1.11.1", "lodash.debounce": "^4.0.6", "mockdate": "^3.0.5", diff --git a/support-frontend/yarn.lock b/support-frontend/yarn.lock index c6049a9926..7071d2110e 100644 --- a/support-frontend/yarn.lock +++ b/support-frontend/yarn.lock @@ -5700,6 +5700,11 @@ escape-html@~1.0.3: resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" integrity sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg= +escape-string-regexp@5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz#4683126b500b61762f2dbebace1806e8be31b1c8" + integrity sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw== + escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"