Merge pull request #17334 from mozilla/FXA-7579

fix(libs): Add Coupon component
This commit is contained in:
Lisa Chan 2024-09-11 12:38:53 -04:00 коммит произвёл GitHub
Родитель a4e178197e e66d0a6042
Коммит c33d786bc6
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: B5690EEEBB952194
21 изменённых файлов: 373 добавлений и 50 удалений

Просмотреть файл

@ -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
}
/>
<CouponForm
cartId={cart.id}
cartVersion={cart.version}
promoCode={cart.couponCode}
readOnly={false}
/>
</section>
<div className="page-body rounded-t-lg tablet:rounded-t-none">

Просмотреть файл

@ -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"
/>
<PrimaryButton type="submit"> Set email</PrimaryButton>
<BaseButton
className="mt-10 w-full"
type="submit"
variant={ButtonVariant.Primary}
>
{' '}
Set email
</BaseButton>
</form>
</div>

Просмотреть файл

@ -38,10 +38,10 @@ export default function Index() {
<h2 className="text-xl mt-8">Without auth</h2>
<div className="flex gap-8">
<div className="flex flex-col gap-2 p-4 items-center">
<h2>VPN - Monthly</h2>
<h2>123Done Pro - Monthly</h2>
<Link
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
href="/en/vpn/checkout/monthly/new"
href="/en/123donepro/checkout/monthly/new"
>
Redirect
</Link>

Просмотреть файл

@ -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 {

Просмотреть файл

@ -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';

Просмотреть файл

@ -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'
);
};

Просмотреть файл

@ -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<HTMLButtonElement> {
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 (
<button
{...props}
className={`flex items-center justify-center font-semibold h-12 rounded-md p-4 z-10 aria-disabled:relative aria-disabled:after:absolute aria-disabled:after:content-[''] aria-disabled:after:top-0 aria-disabled:after:left-0 aria-disabled:after:w-full aria-disabled:after:h-full aria-disabled:after:bg-white aria-disabled:after:opacity-50 aria-disabled:after:z-30 aria-disabled:border-none ${props.className} ${variantStyles}`}
>
{children}
</button>
);
}

Просмотреть файл

@ -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"
/>
) : (
<PrimaryButton
<BaseButton
className="mt-10 w-full"
type="submit"
variant={ButtonVariant.Primary}
aria-disabled={
!stripeFieldsComplete || !nonStripeFieldsComplete || loading
}
>
<Image src={LockImage} className="h-4 w-4 mx-3" alt="" />
<Localized id="next-new-user-submit">Subscribe Now</Localized>
</PrimaryButton>
</BaseButton>
)}
</Form.Submit>
)}

Просмотреть файл

@ -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

Просмотреть файл

@ -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 (
<>
<h2 className="m-0 mb-4 font-semibold text-grey-600">
<Localized id="next-coupon-promo-code-applied">
Promo Code Applied
</Localized>
</h2>
<form
action={removeCoupon}
className="flex gap-4 justify-between items-center"
data-testid="coupon-hascoupon"
>
<span className="break-all">{couponCode}</span>
{readOnly ? null : (
<span>
<SubmitButton
className="w-24"
variant={ButtonVariant.Secondary}
data-testid="coupon-remove-button"
>
<Localized id="next-coupon-remove">Remove</Localized>
</SubmitButton>
</span>
)}
</form>
</>
);
};
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 (
<>
<h2 className="m-0 mb-4 font-semibold text-grey-600">
<Localized id="next-coupon-promo-code">Promo Code</Localized>
</h2>
<form action={applyCoupon} data-testid="coupon-form">
<div className="flex gap-4 justify-between items-center">
<Localized attrs={{ placeholder: true }} id="next-coupon-enter-code">
<input
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"
type="text"
name="coupon"
data-testid="coupon-input"
placeholder="Enter code"
disabled={readOnly}
/>
</Localized>
<div>
<SubmitButton
className="w-20"
variant={ButtonVariant.Primary}
type="submit"
data-testid="coupon-button"
disabled={readOnly}
>
<Localized id="next-coupon-submit">Apply</Localized>
</SubmitButton>
</div>
</div>
{error && (
<div className="text-red-700 mt-4" data-testid="coupon-error">
<Localized id={error}>{getFallbackTextByFluentId(error)}</Localized>
</div>
)}
</form>
</>
);
};
interface CouponFormProps {
cartId: string;
cartVersion: number;
promoCode: string | null;
readOnly: boolean;
}
export function CouponForm({
cartId,
cartVersion,
promoCode,
readOnly,
}: CouponFormProps) {
const hasCouponCode = !!promoCode;
return (
<div className="bg-white rounded-b-lg shadow-sm shadow-grey-300 mt-6 p-4 rounded-t-lg text-base tablet:my-8">
{hasCouponCode ? (
<WithCoupon
cartId={cartId}
cartVersion={cartVersion}
couponCode={promoCode}
readOnly={readOnly}
/>
) : (
<WithoutCoupon
cartId={cartId}
cartVersion={cartVersion}
readOnly={readOnly}
/>
)}
</div>
);
}
export default CouponForm;

Просмотреть файл

@ -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<HTMLButtonElement> {
children: React.ReactNode;
}
export function PrimaryButton({ children, ...props }: PrimaryButtonProps) {
return (
<button
{...props}
className={`flex items-center justify-center bg-blue-500 font-semibold h-12 rounded-md text-white w-full p-4 mt-6 hover:bg-blue-700 z-10 aria-disabled:relative aria-disabled:after:absolute aria-disabled:after:content-[''] aria-disabled:after:top-0 aria-disabled:after:left-0 aria-disabled:after:w-full aria-disabled:after:h-full aria-disabled:after:bg-white aria-disabled:after:opacity-50 aria-disabled:after:z-30 aria-disabled:border-none ${props.className}`}
>
{children}
</button>
);
}

Просмотреть файл

@ -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<HTMLButtonElement>) {
const { pending } = useFormStatus();
const isSubmitting = pending;
return (
<BaseButton
variant={variant}
{...otherProps}
disabled={isSubmitting || disabled}
type="submit"
className={className}
>
{isSubmitting ? (
<Image
src={spinnerWhiteImage}
alt=""
className="animate-spin h-9 w-9"
/>
) : (
children
)}
</BaseButton>
);
}

Просмотреть файл

@ -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;
}

Просмотреть файл

@ -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.

Просмотреть файл

@ -69,7 +69,7 @@ export async function PurchaseDetails(props: PurchaseDetailsProps) {
);
return (
<div className="component-card text-sm px-4 rounded-t-none tablet:rounded-t-lg">
<div className="bg-white rounded-b-lg shadow-sm shadow-grey-300 text-sm px-4 rounded-t-none tablet:rounded-t-lg">
<div className="flex gap-4 my-0 py-4 row-divider-grey-200">
<Image
src={webIcon}

Просмотреть файл

@ -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/. */
import '@testing-library/jest-dom/extend-expect';
import {
BASIC_ERROR,
CouponErrorMessageType,
getFallbackTextByFluentId,
} from './error-ftl-messages';
describe('getFallbackTextByFluentId', () => {
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.'
);
});
});

Просмотреть файл

@ -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 };

Просмотреть файл

@ -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',

Просмотреть файл

@ -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';

Просмотреть файл

@ -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;
}

Просмотреть файл

@ -8,4 +8,3 @@
@import './links.css';
@import './portal.css';
@import './tooltips.css';
@import './containers.css';