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:
Родитель
5158db0157
Коммит
ddff37ef84
|
@ -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 = You’re 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);
|
||||
|
|
Загрузка…
Ссылка в новой задаче