MNTOR-3246- Add error state and limitations apply page (#4697)

* add error state

* add limitations apply page

* style limitations apply page

* add open in new tab icon

* pass class

* fix tests

* fix test

* only show coupon code state instead of nevermind take me back for first time coupon users

* fix strings

* make string id support plural form

* update string var ids

* do not pass down classname as a prop
This commit is contained in:
Kaitlyn Andres 2024-06-27 14:19:52 -04:00 коммит произвёл GitHub
Родитель 5158db0157
Коммит ddff37ef84
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: B5690EEEBB952194
10 изменённых файлов: 318 добавлений и 11 удалений

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

@ -2,8 +2,22 @@
# 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/.
rules-and-restrictions-pill = Rules & Restrictions
# Variables:
# $discount_percentage_num is the amount discounted in percentage per month.
# $discount_duration is the number of month(s) that users will pay the discounted price.
limitations-apply-header = { $discount_percentage_num } off the next { $discount_duration } months
# $discount_percentage_num is the amount discounted in percentage per month
# $discount_duration is the number of month(s) that users will pay the discounted price
limitations-apply-title =
{ $discount_duration ->
[one] { $discount_percentage_num } off { $discount_duration } month promotion
*[other] { $discount_percentage_num } off { $discount_duration } months promotion
}
limitations-pill = Rules & Restrictions
limitations-apply-description-one = Discount available for a limited time only.
limitations-apply-description-two = Redeemable 1 time only.
# Variables:
# $discount_percentage_num is the amount discounted in percentage per month
# $discount_duration is the number of month(s) that users will pay the discounted price
limitations-apply-description-three =
{ $discount_duration ->
[one] { -brand-mozilla-monitor } monthly subscribers will receive { $discount_percentage_num } off their next { $discount_duration } consecutive month billing.
*[other] { -brand-mozilla-monitor } monthly subscribers will receive { $discount_percentage_num } off their next { $discount_duration } consecutive months billing.
}

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

@ -39,9 +39,10 @@ settings-unsubscribe-dialog-promotion-description = {
*[other] { -brand-monitor-plus } will continue protecting your personal data, and a { $discount_percentage_num } discount has been applied to your next { $discount_duration } months.
}
settings-unsubscribe-dialog-promotion-cta-subtitle = Discount applied to your next month. Redeemable one time only.
settings-unsubscribe-dialog-promotion-unsuccessful = There was a problem applying your discount. <try_again_link>Please try again.</try_again_link>
settings-unsubscribe-dialog-promotion-complete = Youre all set!
settings-unsubscribe-dialog-promotion-back-to-dashboard-cta = Go to my Dashboard
settings-unsubscribe-dialog-promotion-limitations-apply = Limitations apply
settings-unsubscribe-dialog-promotion-limitations-apply = Limited time, restrictions apply
## Delete Monitor account

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

@ -38,6 +38,36 @@
}
}
.hidden {
display: none;
}
.limitationsApplyLink {
text-decoration-color: $color-grey-30;
.limitationsApplyText {
display: flex;
gap: $spacing-xs;
align-items: center;
justify-content: center;
text-decoration-color: $color-grey-30;
svg {
width: 13px; // width of open in new tab icon
}
}
}
.errorApplyingCoupon {
padding-top: $spacing-sm;
color: $color-red-60;
font-weight: 500;
button {
color: $color-red-60;
font-weight: 500;
}
}
small {
color: $color-grey-30;
font-weight: 400;

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

@ -20,6 +20,7 @@ import { TelemetryButton } from "../../../../../../components/client/TelemetryBu
import { ExperimentData } from "../../../../../../../telemetry/generated/nimbus/experiments";
import { onApplyCouponCode, onCheckUserHasCurrentCouponSet } from "./actions";
import { TelemetryLink } from "../../../../../../components/client/TelemetryLink";
import { OpenInNew } from "../../../../../../components/server/Icons";
export type Props = {
fxaSubscriptionsUrl: string;
@ -181,6 +182,21 @@ ${styles.staticAlternative}
);
};
const errorApplyingCoupon = (
<p className={styles.errorApplyingCoupon}>
{l10n.getFragment("settings-unsubscribe-dialog-promotion-unsuccessful", {
elems: {
try_again_link: (
<Button
variant="tertiary"
onPress={() => void handleApplyCouponCode()}
/>
),
},
})}
</p>
);
return (
<>
<Button
@ -229,11 +245,24 @@ ${styles.staticAlternative}
}}
variant="primary"
onPress={() => void handleApplyCouponCode()}
className={`${styles.discountCta} ${styles.primaryCta}`}
className={`${couponSuccess === false && styles.hidden} ${styles.discountCta} ${styles.primaryCta}`}
>
{discountedNext3Months.headline}
</TelemetryButton>
<small>{discountedNext3Months.subtitle}</small>
<TelemetryLink
eventData={{
link_id: "limitations_apply",
}}
href="/limitations-apply"
target="_blank"
className={`${couponSuccess === false && styles.hidden} ${styles.limitationsApplyLink}`}
>
<small className={styles.limitationsApplyText}>
{discountedNext3Months.subtitle}
<OpenInNew alt="" />
</small>
</TelemetryLink>
{couponSuccess === false && errorApplyingCoupon}
</>
) : (
<TelemetryButton

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

@ -11,7 +11,12 @@ import type { EmailAddressRow, SubscriberRow } from "knex/types/tables";
import { getL10n } from "../../../../../../functions/l10n/storybookAndJest";
import { TestComponentWrapper } from "../../../../../../../TestComponentWrapper";
import { SerializedSubscriber } from "../../../../../../../next-auth";
import { onAddEmail, onRemoveEmail } from "./actions";
import {
onAddEmail,
onApplyCouponCode,
onCheckUserHasCurrentCouponSet,
onRemoveEmail,
} from "./actions";
const mockedSessionUpdate = jest.fn();
const mockedRecordTelemetry = jest.fn();
@ -24,6 +29,7 @@ jest.mock("next-auth/react", () => {
},
};
});
jest.mock("../../../../../../hooks/useTelemetry", () => {
return {
useTelemetry: () => mockedRecordTelemetry,
@ -35,10 +41,11 @@ jest.mock("./actions", () => {
onRemoveEmail: jest.fn(),
onAddEmail: jest.fn(),
onDeleteAccount: () => new Promise(() => undefined),
onApplyCouponCode: () => ({ success: true }),
onCheckUserHasCurrentCouponSet: () => new Promise(() => undefined),
onApplyCouponCode: jest.fn(),
onCheckUserHasCurrentCouponSet: jest.fn(),
};
});
const mockedRouterRefresh = jest.fn();
jest.mock("next/navigation", () => ({
@ -851,6 +858,11 @@ it("shows the Plus cancellation link if the user has Plus", () => {
it("takes you through the cancellation dialog flow all the way to subplat", async () => {
const user = userEvent.setup();
(onCheckUserHasCurrentCouponSet as jest.Mock).mockResolvedValueOnce({
success: false,
});
(onApplyCouponCode as jest.Mock).mockResolvedValueOnce({ success: true });
render(
<TestComponentWrapper>
<SettingsView
@ -924,6 +936,10 @@ it("takes you through the cancellation dialog flow all the way to subplat", asyn
it("closes the cancellation survey if the user selects nevermind, take me back", async () => {
const user = userEvent.setup();
(onCheckUserHasCurrentCouponSet as jest.Mock).mockResolvedValueOnce({
success: false,
});
render(
<TestComponentWrapper>
<SettingsView
@ -976,6 +992,9 @@ it("closes the cancellation survey if the user selects nevermind, take me back",
it("closes the cancellation dialog", async () => {
const user = userEvent.setup();
(onCheckUserHasCurrentCouponSet as jest.Mock).mockResolvedValueOnce({
success: false,
});
render(
<TestComponentWrapper>
@ -1731,6 +1750,11 @@ describe("to learn about usage", () => {
it("selects the coupon code discount cta and shows the all-set dialog step", async () => {
const user = userEvent.setup();
(onCheckUserHasCurrentCouponSet as jest.Mock).mockResolvedValueOnce({
success: false,
});
(onApplyCouponCode as jest.Mock).mockResolvedValueOnce({ success: true });
render(
<TestComponentWrapper>
<SettingsView
@ -1807,3 +1831,116 @@ it("selects the coupon code discount cta and shows the all-set dialog step", asy
}),
);
});
it("shows error message if the applying the coupon code function was unsuccessful", async () => {
const user = userEvent.setup();
(onCheckUserHasCurrentCouponSet as jest.Mock).mockResolvedValueOnce({
success: false,
});
(onApplyCouponCode as jest.Mock).mockResolvedValueOnce({ success: false });
render(
<TestComponentWrapper>
<SettingsView
l10n={getL10n()}
user={{
...mockedUser,
fxa: {
...mockedUser.fxa,
subscriptions: ["monitor"],
} as Session["user"]["fxa"],
}}
subscriber={mockedSubscriber}
breachCountByEmailAddress={{
[mockedUser.email]: 42,
}}
emailAddresses={[]}
fxaSettingsUrl=""
fxaSubscriptionsUrl=""
yearlySubscriptionUrl=""
monthlySubscriptionUrl=""
subscriptionBillingAmount={mockedSubscriptionBillingAmount}
enabledFeatureFlags={[
"CancellationFlow",
"ConfirmCancellation",
"DiscountCouponNextThreeMonths",
]}
experimentData={defaultExperimentData}
/>
</TestComponentWrapper>,
);
const cancellationButton = screen.getByRole("button", {
name: "Cancel your subscription",
});
await user.click(cancellationButton);
const discountCta = screen.getByRole("button", {
name: "Stay and get 30% off 3 months",
});
await user.click(discountCta);
const errorMessage = await screen.findByText(
"There was a problem applying your discount",
{ exact: false },
);
expect(errorMessage).toBeInTheDocument();
const tryAgainCta = screen.getByRole("button", {
name: "Please try again.",
});
await user.click(tryAgainCta);
expect(onApplyCouponCode).toHaveBeenCalled();
});
it("does not show the coupon code if a user already has a coupon set", async () => {
const user = userEvent.setup();
(onCheckUserHasCurrentCouponSet as jest.Mock).mockResolvedValueOnce({
success: true,
});
render(
<TestComponentWrapper>
<SettingsView
l10n={getL10n()}
user={{
...mockedUser,
fxa: {
...mockedUser.fxa,
subscriptions: ["monitor"],
} as Session["user"]["fxa"],
}}
subscriber={mockedSubscriber}
breachCountByEmailAddress={{
[mockedUser.email]: 42,
}}
emailAddresses={[]}
fxaSettingsUrl=""
fxaSubscriptionsUrl=""
yearlySubscriptionUrl=""
monthlySubscriptionUrl=""
subscriptionBillingAmount={mockedSubscriptionBillingAmount}
enabledFeatureFlags={[
"CancellationFlow",
"ConfirmCancellation",
"DiscountCouponNextThreeMonths",
]}
experimentData={defaultExperimentData}
/>
</TestComponentWrapper>,
);
const cancellationButton = screen.getByRole("button", {
name: "Cancel your subscription",
});
await user.click(cancellationButton);
const takeMeBackButton = screen.getByRole("button", {
name: "Never mind, take me back",
});
expect(takeMeBackButton).toBeInTheDocument();
});

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

@ -0,0 +1,36 @@
@import "../../../../tokens";
.limitationsApplyContainer {
display: flex;
padding: $layout-md $spacing-sm;
.limitationsApplyWrapper {
display: flex;
flex-direction: column;
gap: $layout-md;
align-items: center;
margin: 0 auto;
@media screen and (min-width: $screen-md) {
flex-direction: row;
}
.limitationsApplyDescription {
display: flex;
flex-direction: column;
gap: $spacing-md;
h1 {
font: $text-title-sm;
font-family: var(--font-inter);
}
aside {
width: fit-content;
background-color: $color-purple-10;
border-radius: $border-radius-lg;
padding: $spacing-sm $spacing-md;
}
}
}
}

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

@ -0,0 +1,41 @@
/* 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 Image from "next/image";
import ClappingIllustration from "./images/limitations-apply-illustration.svg";
import styles from "./LimitationsApply.module.scss";
import { ExtendedReactLocalization } from "../../../../functions/l10n";
export const LimitationsApplyView = ({
l10n,
}: {
l10n: ExtendedReactLocalization;
}) => {
return (
<main className={styles.limitationsApplyContainer}>
<div className={styles.limitationsApplyWrapper}>
<Image src={ClappingIllustration} alt="" />
<div className={styles.limitationsApplyDescription}>
<aside>{l10n.getString("limitations-pill")}</aside>
<h1>
{l10n.getString("limitations-apply-title", {
discount_percentage_num: "30%",
discount_duration: 3,
})}
</h1>
<ul>
<li>{l10n.getString("limitations-apply-description-one")}</li>
<li>{l10n.getString("limitations-apply-description-two")}</li>
<li>
{l10n.getString("limitations-apply-description-three", {
discount_percentage_num: "30%",
discount_duration: 3,
})}
</li>
</ul>
</div>
</div>
</main>
);
};

Различия файлов скрыты, потому что одна или несколько строк слишком длинны

После

Ширина:  |  Высота:  |  Размер: 70 KiB

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

@ -0,0 +1,10 @@
/* 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 { getL10n } from "../../../../functions/l10n/serverComponents";
import { LimitationsApplyView } from "./View";
export default function LimitationsApplyPage() {
return <LimitationsApplyView l10n={getL10n()} />;
}

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

@ -33,7 +33,7 @@ export const TelemetryLink = ({
return target ? (
<a
{...props}
className={styles.link}
className={`${styles.link} ${props.className ? props.className : ""}`}
target={target}
onClick={(event) => {
record("link", "click", eventData);