MNTOR-2742 - Implement Formstack iframe for cancellation survey (#4409)

* Add a cancellation survey

* Replace our own button by the embedded one

* MNTOR-2742 - Use Formstack iframe to handle cancellation survey

* add image

* increase margins on text

* add first unit test

* add unit tests for dialog flow

* update strings

* update strings

* more changes requested

* add unicode characteR

* remove alchemer src

* add and ignore test

* remove unused strings

* remove unused imgs

* remove unused string

* remove unused strings

* add unit test

* fix unit tests

* add attribution

* remove double telemetry unit test check

* add attribution

* Update src/app/(proper_react)/(redesign)/(authenticated)/user/(dashboard)/settings/View.tsx

Co-authored-by: Vincent <Vinnl@users.noreply.github.com>

---------

Co-authored-by: Vincent <git@vincenttunru.com>
Co-authored-by: Vincent <Vinnl@users.noreply.github.com>
This commit is contained in:
Kaitlyn Andres 2024-04-18 11:08:04 -05:00 коммит произвёл GitHub
Родитель 89b6bc8cbe
Коммит 2c9326028f
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: B5690EEEBB952194
9 изменённых файлов: 450 добавлений и 20 удалений

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

@ -8,6 +8,22 @@ settings-cancel-plus-title = Cancel { -brand-monitor-plus } subscription
settings-cancel-plus-details = Your subscription will revert to a free account after the current billing cycle ends. Your data broker scan results will be permanently deleted, and you may be re-added to those sites.
settings-cancel-plus-link-label = Cancel from your { -brand-mozilla-account }
## Cancel Plus subscription - flow with a cancellation survey
settings-cancel-plus-survey-button-label = Cancel your subscription
settings-cancel-plus-step-confirm-heading = Leaving now means data brokers may add you back
settings-cancel-plus-step-confirm-content-pt1 = Data brokers regularly scrape the internet and search public records to find new info about you. Theyll often add you back within 3-4 months after removal.
settings-cancel-plus-step-confirm-content-pt2 = { -brand-monitor-plus } continually watches for new profiles and removes them for you, no matter how many times youre re-added.
settings-cancel-plus-step-confirm-cta-label = Continue to cancellation
settings-cancel-plus-step-confirm-cancel-label = Never mind, take me back
settings-cancel-plus-step-survey-heading = Were sorry to see you go. Will you tell us why youre leaving?
settings-cancel-plus-step-survey-lead = Your experience is important to us. We read every response and take it into consideration.
settings-cancel-plus-step-survey-cta-label = Continue to cancellation
settings-unsubscribe-dialog-confirmation-redirect-title = Directing you to your { -brand-mozilla-account } to cancel
settings-unsubscribe-dialog-confirmation-redirect-description-pt1 = Well automatically redirect you to your { -brand-mozilla-account } where you can cancel your { -brand-monitor } subscription.
settings-unsubscribe-dialog-confirmation-redirect-description-pt2 = Please note, all of your { -brand-monitor-plus } services will be <b>permanently deleted</b> after your current billing cycle ends.
settings-unsubscribe-dialog-cancellation-survey-form-placeholder = What could have gone better?
## Delete Monitor account
settings-delete-monitor-plus-account-title = Delete { -brand-monitor } account

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

@ -0,0 +1,41 @@
@import "../../../../../../tokens";
// The attribute selector makes this more specific that <Button>'s direct styles,
// so we can overwrite the font weight. Maybe it should just not be a <Button>
[type="button"].trigger {
font-weight: 600;
}
.contentWrapper {
display: flex;
flex-direction: column;
align-items: center;
gap: $spacing-md;
text-align: center;
.tertiaryCta {
font-weight: 600;
}
p {
max-width: 600px;
}
}
.cancellationIllustrationWrapper {
height: 200px; // height of illustration;
width: 100%;
padding-bottom: $spacing-lg;
img {
height: 100%;
}
}
.iframeWrapper {
width: 100%;
overflow: hidden;
display: flex;
justify-content: center;
height: 240px; //height of iframe
}

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

@ -0,0 +1,186 @@
/* 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 { useState } from "react";
import { useOverlayTriggerState } from "react-stately";
import { useOverlayTrigger } from "react-aria";
import Image from "next/image";
import styles from "./CancelFlow.module.scss";
import CancellationSurveyPlaneIllustration from "./images/CancellationSurveyPlaneIllustration.png";
import { useTelemetry } from "../../../../../../hooks/useTelemetry";
import { ModalOverlay } from "../../../../../../components/client/dialog/ModalOverlay";
import { Dialog } from "../../../../../../components/client/dialog/Dialog";
import { Button } from "../../../../../../components/client/Button";
import { useL10n } from "../../../../../../hooks/l10n";
import { TelemetryButton } from "../../../../../../components/client/TelemetryButton";
export type Props = {
fxaSubscriptionsUrl: string;
};
export const CancelFlow = (props: Props) => {
const l10n = useL10n();
const recordTelemetry = useTelemetry();
const [step, setCurrentStep] = useState<"confirm" | "survey" | "redirecting">(
"confirm",
);
const dialogState = useOverlayTriggerState({
onOpenChange: (isOpen) => {
recordTelemetry("popup", isOpen ? "view" : "exit", {
popup_id: "settings-cancel-monitor-plus-dialog",
});
},
});
const dialogTrigger = useOverlayTrigger({ type: "dialog" }, dialogState);
return (
<>
<Button
variant="tertiary"
onPress={() => dialogState.open()}
className={styles.trigger}
>
{l10n.getString("settings-cancel-plus-survey-button-label")}
</Button>
{dialogState.isOpen && (
<ModalOverlay
state={dialogState}
{...dialogTrigger.overlayProps}
isDismissable={true}
>
<Dialog
title={l10n.getString(
step === "confirm"
? "settings-cancel-plus-step-confirm-heading"
: step === "survey"
? "settings-cancel-plus-step-survey-heading"
: "settings-unsubscribe-dialog-confirmation-redirect-title",
)}
illustration={
<Image
className={styles.cancellationIllustrationWrapper}
src={CancellationSurveyPlaneIllustration}
alt=""
/>
}
onDismiss={() => dialogState.close()}
>
<div className={styles.contentWrapper}>
{step === "confirm" && (
<>
<p>
{l10n.getString(
"settings-cancel-plus-step-confirm-content-pt1",
)}
</p>
<p>
{l10n.getString(
"settings-cancel-plus-step-confirm-content-pt2",
)}
</p>
<TelemetryButton
event={{
module: "button",
name: "click",
data: {
button_id: "continue_to_cancellation",
},
}}
variant="primary"
onPress={() => setCurrentStep("survey")}
>
{l10n.getString(
"settings-cancel-plus-step-confirm-cta-label",
)}
</TelemetryButton>
<TelemetryButton
event={{
module: "popup",
name: "exit",
data: {
popup_id: "never_mind_take_me_back",
},
}}
variant="tertiary"
onPress={() => dialogState.close()}
className={styles.tertiaryCta}
>
{l10n.getString(
"settings-cancel-plus-step-confirm-cancel-label",
)}
</TelemetryButton>
</>
)}
{step === "survey" && (
<>
<p>
{l10n.getString("settings-cancel-plus-step-survey-lead")}
</p>
<div className={styles.iframeWrapper}>
<iframe
scrolling={"no"}
frameBorder={0}
src="https://mozilla.formstack.com/forms/mozilla_monitor_plus_cancel"
width="800"
height="320"
aria-label={l10n.getString(
"settings-unsubscribe-dialog-cancellation-survey-form-placeholder",
)}
></iframe>
</div>
<TelemetryButton
event={{
module: "button",
name: "click",
data: {
button_id: "continue_to_cancellation",
},
}}
className={styles.tertiaryCta}
variant="tertiary"
onPress={() => {
setCurrentStep("redirecting");
setTimeout(() => {
/* c8 ignore next */
document.location = props.fxaSubscriptionsUrl;
}, 5000);
}}
>
{l10n.getString(
"settings-cancel-plus-step-survey-cta-label",
)}
</TelemetryButton>
</>
)}
{step === "redirecting" && (
<>
<p>
{l10n.getString(
"settings-unsubscribe-dialog-confirmation-redirect-description-pt1",
)}
</p>
<p>
{l10n.getFragment(
"settings-unsubscribe-dialog-confirmation-redirect-description-pt2",
{
elems: {
b: <b />,
},
},
)}
</p>
</>
)}
</div>
</Dialog>
</ModalOverlay>
)}
</>
);
};

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

@ -15,6 +15,7 @@ import {
Props as DialogProps,
} from "../../../../../../components/client/dialog/Dialog";
import { Button } from "../../../../../../components/client/Button";
import { TelemetryButton } from "../../../../../../components/client/TelemetryButton";
export type Props = {
triggerLabel: string;
@ -41,13 +42,20 @@ export const SettingsConfirmationDialog = (props: Props) => {
return (
<>
<Button
<TelemetryButton
event={{
module: "popup",
name: "exit",
data: {
popup_id: "never_mind_take_me_back",
},
}}
variant="tertiary"
onPress={() => dialogState.open()}
className={styles.trigger}
>
{props.triggerLabel}
</Button>
</TelemetryButton>
{dialogState.isOpen && (
<ModalOverlay
state={dialogState}

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

@ -12,7 +12,6 @@ import { getSpecificL10nSync } from "../../../../../../functions/server/mockL10n
import { TestComponentWrapper } from "../../../../../../../TestComponentWrapper";
import { SerializedSubscriber } from "../../../../../../../next-auth";
import { onAddEmail, onRemoveEmail } from "./actions";
import { sanitizeEmailRow } from "../../../../../../functions/server/sanitize";
const mockedSessionUpdate = jest.fn();
const mockedRecordTelemetry = jest.fn();
@ -40,6 +39,7 @@ jest.mock("./actions", () => {
import { SettingsView } from "./View";
import { FeatureFlagName } from "../../../../../../../db/tables/featureFlags";
import { sanitizeEmailRow } from "../../../../../../functions/server/sanitize";
const subscriberId = 7;
const mockedSubscriber: SerializedSubscriber = {
@ -449,6 +449,177 @@ it("shows the Plus cancellation link if the user has Plus", () => {
expect(cancellationHeading).toBeInTheDocument();
});
it("takes you through the cancellation dialog flow all the way to subplat", async () => {
const user = userEvent.setup();
render(
<TestComponentWrapper>
<SettingsView
l10n={getSpecificL10nSync()}
user={{
...mockedUser,
fxa: {
...mockedUser.fxa,
subscriptions: ["monitor"],
} as Session["user"]["fxa"],
}}
breachCountByEmailAddress={{
[mockedUser.email]: 42,
}}
emailAddresses={[]}
fxaSettingsUrl=""
fxaSubscriptionsUrl=""
yearlySubscriptionUrl=""
monthlySubscriptionUrl=""
subscriptionBillingAmount={mockedSubscriptionBillingAmount}
enabledFeatureFlags={["MonitorAccountDeletion", "CancellationSurvey"]}
/>
</TestComponentWrapper>,
);
const cancellationButton = screen.getByRole("button", {
name: "Cancel your subscription",
});
await user.click(cancellationButton);
expect(mockedRecordTelemetry).toHaveBeenCalledWith(
"popup",
"view",
expect.objectContaining({
popup_id: "settings-cancel-monitor-plus-dialog",
}),
);
expect(
screen.getByRole("dialog", {
name: "Leaving now means data brokers may add you back",
}),
).toBeInTheDocument();
const continueToCancellationButton = screen.getByRole("button", {
name: "Continue to cancellation",
});
await user.click(continueToCancellationButton);
expect(
screen.getByRole("dialog", {
name: "Were sorry to see you go. Will you tell us why youre leaving?",
}),
).toBeInTheDocument();
const continueToCancellationButton2 = screen.getByRole("button", {
name: "Continue to cancellation",
});
await user.click(continueToCancellationButton2);
expect(
screen.getByRole("dialog", {
name: "Directing you to your Mozilla account to cancel",
}),
).toBeInTheDocument();
});
it("closes the cancellation survey if the user selects nevermind, take me back", async () => {
const user = userEvent.setup();
render(
<TestComponentWrapper>
<SettingsView
l10n={getSpecificL10nSync()}
user={{
...mockedUser,
fxa: {
...mockedUser.fxa,
subscriptions: ["monitor"],
} as Session["user"]["fxa"],
}}
breachCountByEmailAddress={{
[mockedUser.email]: 42,
}}
emailAddresses={[]}
fxaSettingsUrl=""
fxaSubscriptionsUrl=""
yearlySubscriptionUrl=""
monthlySubscriptionUrl=""
subscriptionBillingAmount={mockedSubscriptionBillingAmount}
enabledFeatureFlags={["MonitorAccountDeletion", "CancellationSurvey"]}
/>
</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",
});
await user.click(takeMeBackButton);
expect(mockedRecordTelemetry).toHaveBeenCalledWith(
"popup",
"exit",
expect.objectContaining({
popup_id: "never_mind_take_me_back",
}),
);
expect(takeMeBackButton).not.toBeInTheDocument();
});
it("closes the cancellation dialog", async () => {
const user = userEvent.setup();
render(
<TestComponentWrapper>
<SettingsView
l10n={getSpecificL10nSync()}
user={{
...mockedUser,
fxa: {
...mockedUser.fxa,
subscriptions: ["monitor"],
} as Session["user"]["fxa"],
}}
breachCountByEmailAddress={{
[mockedUser.email]: 42,
}}
emailAddresses={[]}
fxaSettingsUrl=""
fxaSubscriptionsUrl=""
yearlySubscriptionUrl=""
monthlySubscriptionUrl=""
subscriptionBillingAmount={mockedSubscriptionBillingAmount}
enabledFeatureFlags={["MonitorAccountDeletion", "CancellationSurvey"]}
/>
</TestComponentWrapper>,
);
const cancellationButton = screen.getByRole("button", {
name: "Cancel your subscription",
});
await user.click(cancellationButton);
const cancellationDialogCloseBtn = screen.getByRole("button", {
name: "Close modal",
});
await user.click(cancellationDialogCloseBtn);
expect(mockedRecordTelemetry).toHaveBeenCalledWith(
"popup",
"exit",
expect.objectContaining({
popup_id: "settings-cancel-monitor-plus-dialog",
}),
);
});
it("does not show the account deletion button if the relevant flag is not enabled", () => {
render(
<TestComponentWrapper>
@ -785,6 +956,7 @@ describe("to learn about usage", () => {
const cancelPlusLink = screen.getByRole("link", {
name: "Cancel from your Mozilla account Open link in a new tab",
});
await user.click(cancelPlusLink);
expect(mockedRecordTelemetry).toHaveBeenCalledWith(
@ -821,6 +993,7 @@ describe("to learn about usage", () => {
const deactivateAccountLink = screen.getByRole("link", {
name: "Go to Mozilla account settings Open link in a new tab",
});
await user.click(deactivateAccountLink);
expect(mockedRecordTelemetry).toHaveBeenCalledWith(

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

@ -20,6 +20,7 @@ import { sanitizeEmailRow } from "../../../../../../functions/server/sanitize";
import { SettingsConfirmationDialog } from "./SettingsConfirmationDialog";
import { DeleteAccountButton } from "./DeleteAccountButton";
import { FeatureFlagName } from "../../../../../../../db/tables/featureFlags";
import { CancelFlow } from "./CancelFlow";
export type Props = {
l10n: ExtendedReactLocalization;
@ -97,21 +98,25 @@ export const SettingsView = (props: Props) => {
<div className={styles.cancelSection}>
<h3>{l10n.getString("settings-cancel-plus-title")}</h3>
<p>{l10n.getString("settings-cancel-plus-details")}</p>
<TelemetryLink
href={props.fxaSubscriptionsUrl}
target="_blank"
rel="noopener noreferrer"
eventData={{
link_id: "cancel_plus",
}}
>
{l10n.getString("settings-cancel-plus-link-label")}
<OpenInNew
alt={l10n.getString("open-in-new-tab-alt")}
width="13"
height="13"
/>
</TelemetryLink>
{props.enabledFeatureFlags.includes("CancellationSurvey") ? (
<CancelFlow fxaSubscriptionsUrl={props.fxaSubscriptionsUrl} />
) : (
<TelemetryLink
href={props.fxaSubscriptionsUrl}
target="_blank"
rel="noopener noreferrer"
eventData={{
link_id: "cancel_plus",
}}
>
{l10n.getString("settings-cancel-plus-link-label")}
<OpenInNew
alt={l10n.getString("open-in-new-tab-alt")}
width="13"
height="13"
/>
</TelemetryLink>
)}
</div>
</>
)}

Двоичный файл не отображается.

После

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

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

@ -36,7 +36,8 @@ export type FeatureFlagName =
| "FxaUidTelemetry"
| "RebrandAnnouncement"
| "MonitorAccountDeletion"
| "RedesignedEmails";
| "RedesignedEmails"
| "CancellationSurvey";
export async function getEnabledFeatureFlags(
options:

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

@ -84,7 +84,7 @@ function generateCspData() {
"font-src 'self'",
"form-action 'self'",
"frame-ancestors 'self'",
"frame-src 'self' https://js.stripe.com",
"frame-src 'self' https://js.stripe.com https://mozilla.formstack.com",
"object-src 'none'",
"block-all-mixed-content",
"upgrade-insecure-requests",