From e66d0a6042999be7ab2e301d1374c4134e237570 Mon Sep 17 00:00:00 2001 From: Lisa Chan Date: Tue, 10 Sep 2024 20:24:03 -0400 Subject: [PATCH] feat(apps/libs): Add CouponForm Component --- .../checkout/[interval]/[cartId]/layout.tsx | 9 +- .../[interval]/[cartId]/start/page.tsx | 12 +- apps/payments/next/app/page.tsx | 4 +- apps/payments/next/app/styles/global.css | 2 +- libs/payments/ui/src/index.ts | 6 +- .../payments/ui/src/lib/actions/updateCart.ts | 9 +- .../client/components/BaseButton/index.tsx | 34 ++++ .../client/components/CheckoutForm/index.tsx | 12 +- .../lib/client/components/CouponForm/en.ftl | 13 ++ .../client/components/CouponForm/index.tsx | 155 ++++++++++++++++++ .../lib/client/components/PrimaryButton.tsx | 18 -- .../client/components/SubmitButton/index.tsx | 46 ++++++ .../validators/UpdateCartActionArgs.ts | 16 +- .../server/components/PurchaseDetails/en.ftl | 3 - .../components/PurchaseDetails/index.tsx | 2 +- .../src/lib/utils/error-ftl-messages.spec.ts | 36 ++++ .../ui/src/lib/utils/error-ftl-messages.ts | 36 ++++ libs/payments/ui/src/lib/utils/types.ts | 1 + libs/payments/ui/src/server.ts | 1 + libs/shared/assets/src/styles/containers.css | 7 - libs/shared/assets/src/styles/index.css | 1 - 21 files changed, 373 insertions(+), 50 deletions(-) create mode 100644 libs/payments/ui/src/lib/client/components/BaseButton/index.tsx create mode 100644 libs/payments/ui/src/lib/client/components/CouponForm/en.ftl create mode 100644 libs/payments/ui/src/lib/client/components/CouponForm/index.tsx delete mode 100644 libs/payments/ui/src/lib/client/components/PrimaryButton.tsx create mode 100644 libs/payments/ui/src/lib/client/components/SubmitButton/index.tsx create mode 100644 libs/payments/ui/src/lib/utils/error-ftl-messages.spec.ts create mode 100644 libs/payments/ui/src/lib/utils/error-ftl-messages.ts delete mode 100644 libs/shared/assets/src/styles/containers.css diff --git a/apps/payments/next/app/[locale]/[offeringId]/checkout/[interval]/[cartId]/layout.tsx b/apps/payments/next/app/[locale]/[offeringId]/checkout/[interval]/[cartId]/layout.tsx index 4a224fa2dc..a27dcdc523 100644 --- a/apps/payments/next/app/[locale]/[offeringId]/checkout/[interval]/[cartId]/layout.tsx +++ b/apps/payments/next/app/[locale]/[offeringId]/checkout/[interval]/[cartId]/layout.tsx @@ -3,9 +3,10 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { headers } from 'next/headers'; +import { CouponForm } from '@fxa/payments/ui'; import { - getApp, fetchCMSData, + getApp, getCartAction, PurchaseDetails, SubscriptionTitle, @@ -67,6 +68,12 @@ export default async function RootLayout({ cms.defaultPurchase.data.attributes.purchaseDetails.data.attributes } /> +
diff --git a/apps/payments/next/app/[locale]/[offeringId]/checkout/[interval]/[cartId]/start/page.tsx b/apps/payments/next/app/[locale]/[offeringId]/checkout/[interval]/[cartId]/start/page.tsx index 391ab753bc..c105c53d1b 100644 --- a/apps/payments/next/app/[locale]/[offeringId]/checkout/[interval]/[cartId]/start/page.tsx +++ b/apps/payments/next/app/[locale]/[offeringId]/checkout/[interval]/[cartId]/start/page.tsx @@ -4,7 +4,7 @@ import { revalidatePath } from 'next/cache'; import { headers } from 'next/headers'; -import { PaymentSection } from '@fxa/payments/ui'; +import { BaseButton, ButtonVariant, PaymentSection } from '@fxa/payments/ui'; import { getApp, getCartOrRedirectAction, @@ -16,7 +16,6 @@ import { getCMSContent, } from 'apps/payments/next/app/_lib/apiClient'; import { auth, signIn } from 'apps/payments/next/auth'; -import { PrimaryButton } from 'libs/payments/ui/src/lib/client/components/PrimaryButton'; import { CheckoutParams } from '../layout'; export const dynamic = 'force-dynamic'; @@ -110,7 +109,14 @@ export default async function Checkout({ params }: { params: CheckoutParams }) { name="email" className="w-full border rounded-md border-black/30 p-3 placeholder:text-grey-500 placeholder:font-normal focus:border focus:!border-black/30 focus:!shadow-[0_0_0_3px_rgba(10,132,255,0.3)] focus-visible:outline-none data-[invalid=true]:border-alert-red data-[invalid=true]:text-alert-red data-[invalid=true]:shadow-inputError" /> - Set email + + {' '} + Set email +
diff --git a/apps/payments/next/app/page.tsx b/apps/payments/next/app/page.tsx index 02eae1ad2c..eeec5ab0ad 100644 --- a/apps/payments/next/app/page.tsx +++ b/apps/payments/next/app/page.tsx @@ -38,10 +38,10 @@ export default function Index() {

Without auth

-

VPN - Monthly

+

123Done Pro - Monthly

Redirect diff --git a/apps/payments/next/app/styles/global.css b/apps/payments/next/app/styles/global.css index 4028c17c7f..ed4dab3c77 100644 --- a/apps/payments/next/app/styles/global.css +++ b/apps/payments/next/app/styles/global.css @@ -25,7 +25,7 @@ body { } .page-body { - @apply component-card border-t-0 mb-6 pt-4 px-4 pb-14 text-grey-600 desktop:px-12 desktop:pb-12; + @apply bg-white rounded-b-lg shadow-sm shadow-grey-300 border-t-0 mb-6 pt-4 px-4 pb-14 text-grey-600 desktop:px-12 desktop:pb-12; } .page-header { diff --git a/libs/payments/ui/src/index.ts b/libs/payments/ui/src/index.ts index 0abbf34ed2..bc4167dd51 100644 --- a/libs/payments/ui/src/index.ts +++ b/libs/payments/ui/src/index.ts @@ -4,8 +4,12 @@ // Use this file to export React client components (e.g. those with 'use client' directive) or other non-server utilities -export * from './lib/utils/helpers'; +export * from './lib/client/components/BaseButton'; export * from './lib/client/components/CheckoutForm'; export * from './lib/client/components/CheckoutCheckbox'; +export * from './lib/client/components/CouponForm'; export * from './lib/client/components/PaymentSection'; +export * from './lib/client/components/SubmitButton'; export * from './lib/client/providers/Providers'; +export * from './lib/utils/helpers'; +export * from './lib/utils/types'; diff --git a/libs/payments/ui/src/lib/actions/updateCart.ts b/libs/payments/ui/src/lib/actions/updateCart.ts index 2b115844ae..7423d6ab3c 100644 --- a/libs/payments/ui/src/lib/actions/updateCart.ts +++ b/libs/payments/ui/src/lib/actions/updateCart.ts @@ -4,10 +4,12 @@ 'use server'; +import { plainToClass } from 'class-transformer'; +import { revalidatePath } from 'next/cache'; + import { UpdateCart } from '@fxa/payments/cart'; import { getApp } from '../nestapp/app'; import { UpdateCartActionArgs } from '../nestapp/validators/UpdateCartActionArgs'; -import { plainToClass } from 'class-transformer'; export const updateCartAction = async ( cartId: string, @@ -23,4 +25,9 @@ export const updateCartAction = async ( cartDetails, }) ); + + revalidatePath( + '/[locale]/[offeringId]/checkout/[interval]/[cartId]/start', + 'page' + ); }; diff --git a/libs/payments/ui/src/lib/client/components/BaseButton/index.tsx b/libs/payments/ui/src/lib/client/components/BaseButton/index.tsx new file mode 100644 index 0000000000..6b45365f84 --- /dev/null +++ b/libs/payments/ui/src/lib/client/components/BaseButton/index.tsx @@ -0,0 +1,34 @@ +/* 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/. */ + +export enum ButtonVariant { + Primary, + Secondary, +} + +interface ButtonProps extends React.ButtonHTMLAttributes { + children: React.ReactNode; + variant?: ButtonVariant; +} + +export function BaseButton({ children, variant, ...props }: ButtonProps) { + let variantStyles = ''; + switch (variant) { + case ButtonVariant.Primary: + variantStyles = 'bg-blue-500 hover:bg-blue-700 text-white'; + break; + case ButtonVariant.Secondary: + variantStyles = 'bg-grey-100 hover:bg-grey-200 text-black'; + break; + } + + return ( + + ); +} diff --git a/libs/payments/ui/src/lib/client/components/CheckoutForm/index.tsx b/libs/payments/ui/src/lib/client/components/CheckoutForm/index.tsx index 0d8c7f182d..6ef5d87373 100644 --- a/libs/payments/ui/src/lib/client/components/CheckoutForm/index.tsx +++ b/libs/payments/ui/src/lib/client/components/CheckoutForm/index.tsx @@ -4,6 +4,7 @@ 'use client'; import { Localized, useLocalization } from '@fluent/react'; +import { PayPalButtons } from '@paypal/react-paypal-js'; import * as Form from '@radix-ui/react-form'; import { PaymentElement, @@ -14,12 +15,11 @@ import { StripePaymentElementChangeEvent } from '@stripe/stripe-js'; import Image from 'next/image'; import { useRouter } from 'next/navigation'; import { useEffect, useState } from 'react'; + +import { BaseButton, ButtonVariant, CheckoutCheckbox } from '@fxa/payments/ui'; import LockImage from '@fxa/shared/assets/images/lock.svg'; -import { CheckoutCheckbox } from '../CheckoutCheckbox'; -import { PrimaryButton } from '../PrimaryButton'; import { checkoutCartWithStripe } from '../../../actions/checkoutCartWithStripe'; import { handleStripeErrorAction } from '../../../actions/handleStripeError'; -import { PayPalButtons } from '@paypal/react-paypal-js'; interface CheckoutFormProps { cmsCommonContent: { @@ -251,15 +251,17 @@ export function CheckoutForm({ className="mt-6" /> ) : ( - Subscribe Now - + )} )} diff --git a/libs/payments/ui/src/lib/client/components/CouponForm/en.ftl b/libs/payments/ui/src/lib/client/components/CouponForm/en.ftl new file mode 100644 index 0000000000..f133c3e210 --- /dev/null +++ b/libs/payments/ui/src/lib/client/components/CouponForm/en.ftl @@ -0,0 +1,13 @@ +## Component - CouponForm + +next-coupon-enter-code = + .placeholder = Enter Code + +# Title of container where a user can input a coupon code to get a discount on a subscription. +next-coupon-promo-code = Promo Code + +# Title of container showing discount coupon code applied to a subscription. +next-coupon-promo-code-applied = Promo Code Applied + +next-coupon-remove = Remove +next-coupon-submit = Apply diff --git a/libs/payments/ui/src/lib/client/components/CouponForm/index.tsx b/libs/payments/ui/src/lib/client/components/CouponForm/index.tsx new file mode 100644 index 0000000000..0cc6a140c9 --- /dev/null +++ b/libs/payments/ui/src/lib/client/components/CouponForm/index.tsx @@ -0,0 +1,155 @@ +/* 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/. */ + +'use client'; + +import { Localized } from '@fluent/react'; +import { ButtonVariant } from '../BaseButton'; +import { SubmitButton } from '../SubmitButton'; +import { updateCartAction } from '../../../actions/updateCart'; +import { getFallbackTextByFluentId } from '../../../utils/error-ftl-messages'; + +interface WithCouponProps { + cartId: string; + cartVersion: number; + couponCode: string; + readOnly: boolean; +} +const WithCoupon = ({ + cartId, + cartVersion, + couponCode, + readOnly, +}: WithCouponProps) => { + async function removeCoupon() { + await updateCartAction(cartId, cartVersion, { couponCode: '' }); + } + + return ( + <> +

+ + Promo Code Applied + +

+ +
+ {couponCode} + {readOnly ? null : ( + + + Remove + + + )} +
+ + ); +}; + +interface WithoutCouponProps { + cartId: string; + cartVersion: number; + readOnly: boolean; +} + +const WithoutCoupon = ({ + cartId, + cartVersion, + readOnly, +}: WithoutCouponProps) => { + async function applyCoupon(formData: FormData) { + const promotionCode = formData.get('coupon') as string; + + await updateCartAction(cartId, cartVersion, { + couponCode: promotionCode, + }); + } + + const error = null; + + return ( + <> +

+ Promo Code +

+ +
+
+ + + +
+ + Apply + +
+
+ + {error && ( +
+ {getFallbackTextByFluentId(error)} +
+ )} +
+ + ); +}; + +interface CouponFormProps { + cartId: string; + cartVersion: number; + promoCode: string | null; + readOnly: boolean; +} + +export function CouponForm({ + cartId, + cartVersion, + promoCode, + readOnly, +}: CouponFormProps) { + const hasCouponCode = !!promoCode; + return ( +
+ {hasCouponCode ? ( + + ) : ( + + )} +
+ ); +} + +export default CouponForm; diff --git a/libs/payments/ui/src/lib/client/components/PrimaryButton.tsx b/libs/payments/ui/src/lib/client/components/PrimaryButton.tsx deleted file mode 100644 index 6835a9279d..0000000000 --- a/libs/payments/ui/src/lib/client/components/PrimaryButton.tsx +++ /dev/null @@ -1,18 +0,0 @@ -/* 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/. */ -interface PrimaryButtonProps - extends React.ButtonHTMLAttributes { - children: React.ReactNode; -} - -export function PrimaryButton({ children, ...props }: PrimaryButtonProps) { - return ( - - ); -} diff --git a/libs/payments/ui/src/lib/client/components/SubmitButton/index.tsx b/libs/payments/ui/src/lib/client/components/SubmitButton/index.tsx new file mode 100644 index 0000000000..ec5c93e6dc --- /dev/null +++ b/libs/payments/ui/src/lib/client/components/SubmitButton/index.tsx @@ -0,0 +1,46 @@ +/* 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/. */ + +'use client'; + +import Image from 'next/image'; +import { useFormStatus } from 'react-dom'; +import spinnerWhiteImage from '@fxa/shared/assets/images/spinnerwhite.svg'; +import { BaseButton, ButtonVariant } from '../BaseButton'; + +interface SubmitButtonProps { + children: React.ReactNode; + variant: ButtonVariant; +} + +export function SubmitButton({ + children, + variant, + disabled, + className, + ...otherProps +}: SubmitButtonProps & React.HTMLProps) { + const { pending } = useFormStatus(); + const isSubmitting = pending; + + return ( + + {isSubmitting ? ( + + ) : ( + children + )} + + ); +} diff --git a/libs/payments/ui/src/lib/nestapp/validators/UpdateCartActionArgs.ts b/libs/payments/ui/src/lib/nestapp/validators/UpdateCartActionArgs.ts index 6463935085..97ffed8eea 100644 --- a/libs/payments/ui/src/lib/nestapp/validators/UpdateCartActionArgs.ts +++ b/libs/payments/ui/src/lib/nestapp/validators/UpdateCartActionArgs.ts @@ -2,9 +2,13 @@ * 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 { Optional } from '@nestjs/common'; import { Type } from 'class-transformer'; -import { IsNumber, IsString, ValidateNested } from 'class-validator'; +import { + IsNumber, + IsOptional, + IsString, + ValidateNested, +} from 'class-validator'; export class UpdateCartActionCartTaxAddress { @IsString() @@ -16,20 +20,20 @@ export class UpdateCartActionCartTaxAddress { export class UpdateCartActionCartDetailsArgs { @IsString() - @Optional() + @IsOptional() uid?: string; - @Optional() + @IsOptional() @ValidateNested() @Type(() => UpdateCartActionCartTaxAddress) taxAddress?: UpdateCartActionCartTaxAddress; @IsString() - @Optional() + @IsOptional() couponCode?: string; @IsString() - @Optional() + @IsOptional() email?: string; } diff --git a/libs/payments/ui/src/lib/server/components/PurchaseDetails/en.ftl b/libs/payments/ui/src/lib/server/components/PurchaseDetails/en.ftl index 23a6ae7ad3..140f2dece6 100644 --- a/libs/payments/ui/src/lib/server/components/PurchaseDetails/en.ftl +++ b/libs/payments/ui/src/lib/server/components/PurchaseDetails/en.ftl @@ -5,9 +5,6 @@ next-plan-details-list-price = List Price next-plan-details-tax = Taxes and Fees next-plan-details-total-label = Total -# Title of container where a user can input a coupon code to get a discount on a subscription. -next-coupon-promo-code = Promo Code - ## Purchase details - shared by multiple components, including purchase details and payment form ## $amount (Number) - The amount billed. It will be formatted as currency. diff --git a/libs/payments/ui/src/lib/server/components/PurchaseDetails/index.tsx b/libs/payments/ui/src/lib/server/components/PurchaseDetails/index.tsx index 2e1d7bc6e3..f6d792482a 100644 --- a/libs/payments/ui/src/lib/server/components/PurchaseDetails/index.tsx +++ b/libs/payments/ui/src/lib/server/components/PurchaseDetails/index.tsx @@ -69,7 +69,7 @@ export async function PurchaseDetails(props: PurchaseDetailsProps) { ); return ( -
+
{ + it('returns default basic error message if no error id is provided', () => { + expect(getFallbackTextByFluentId('')).toEqual( + 'Something went wrong. Please try again later.' + ); + }); + + it('returns default basic error message if provided error id does not match any key in dictionary', () => { + expect(getFallbackTextByFluentId('foo-bar')).toEqual( + 'Something went wrong. Please try again later.' + ); + }); + + it('returns error message for provided error', () => { + expect(getFallbackTextByFluentId(BASIC_ERROR)).toEqual( + 'Something went wrong. Please try again later.' + ); + }); + + it('returns coupon error message', () => { + expect(getFallbackTextByFluentId(CouponErrorMessageType.Expired)).toEqual( + 'The code you entered has expired.' + ); + }); +}); diff --git a/libs/payments/ui/src/lib/utils/error-ftl-messages.ts b/libs/payments/ui/src/lib/utils/error-ftl-messages.ts new file mode 100644 index 0000000000..3f9a8d6236 --- /dev/null +++ b/libs/payments/ui/src/lib/utils/error-ftl-messages.ts @@ -0,0 +1,36 @@ +/* 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/. */ + +export enum CouponErrorMessageType { + Expired = 'next-coupon-error-expired', + Generic = 'next-coupon-error-generic', + Invalid = 'next-coupon-error-invalid', + LimitReached = 'next-coupon-error-limit-reached', +} + +const BASIC_ERROR = 'next-basic-error-message'; + +// Dictionary of fluentIds and corresponding human-readable error messages +// Each error ID key should have a matching error ID property in errorToErrorMessageIdMap +const getFallbackTextByFluentId = (key: string) => { + switch (key) { + // coupon error messages + case CouponErrorMessageType.Expired: + return 'The code you entered has expired.'; + case CouponErrorMessageType.Generic: + return 'An error occurred processing the code. Please try again.'; + case CouponErrorMessageType.Invalid: + return 'The code you entered is invalid.'; + case CouponErrorMessageType.LimitReached: + return 'The code you entered has reached its limit.'; + + // generic messages for groups of similar errors + case BASIC_ERROR: + default: + return 'Something went wrong. Please try again later.'; + } +}; + +// BASIC_ERROR, COUNTRY_CURRENCY_MISMATCH and PAYMENT_ERROR_1 are exported for errors.test.tsx +export { getFallbackTextByFluentId, BASIC_ERROR }; diff --git a/libs/payments/ui/src/lib/utils/types.ts b/libs/payments/ui/src/lib/utils/types.ts index 4451519d78..28fe0eeb97 100644 --- a/libs/payments/ui/src/lib/utils/types.ts +++ b/libs/payments/ui/src/lib/utils/types.ts @@ -1,6 +1,7 @@ /* 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/. */ + export enum SupportedPages { START = 'start', PROCESSING = 'processing', diff --git a/libs/payments/ui/src/server.ts b/libs/payments/ui/src/server.ts index a846a2a9c6..8a44afcd60 100644 --- a/libs/payments/ui/src/server.ts +++ b/libs/payments/ui/src/server.ts @@ -16,3 +16,4 @@ export { handleStripeErrorAction } from './lib/actions/handleStripeError'; export { getCartAction } from './lib/actions/getCart'; export { getCartOrRedirectAction } from './lib/actions/getCartOrRedirect'; export { setupCartAction } from './lib/actions/setupCart'; +export { updateCartAction } from './lib/actions/updateCart'; diff --git a/libs/shared/assets/src/styles/containers.css b/libs/shared/assets/src/styles/containers.css deleted file mode 100644 index a9fb3d93cf..0000000000 --- a/libs/shared/assets/src/styles/containers.css +++ /dev/null @@ -1,7 +0,0 @@ -.component-card { - @apply bg-white rounded-b-lg shadow-sm shadow-grey-300; -} - -.component-card.rounded-plan { - @apply rounded-lg tablet:mt-0; -} diff --git a/libs/shared/assets/src/styles/index.css b/libs/shared/assets/src/styles/index.css index 4c7497db96..939ac8451e 100644 --- a/libs/shared/assets/src/styles/index.css +++ b/libs/shared/assets/src/styles/index.css @@ -8,4 +8,3 @@ @import './links.css'; @import './portal.css'; @import './tooltips.css'; -@import './containers.css';