src/app/components/client/Chart.tsx (339 lines of code) (raw):
/* 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 Link from "next/link";
import { useL10n } from "../../hooks/l10n";
import styles from "./Chart.module.scss";
import { QuestionMarkCircle } from "../server/Icons";
import { useOverlayTrigger } from "react-aria";
import { useOverlayTriggerState } from "react-stately";
import { Button } from "../client/Button";
import { ModalOverlay } from "./dialog/ModalOverlay";
import { Dialog } from "./dialog/Dialog";
import ModalImage from "../client/assets/modal-default-img.svg";
import { DashboardSummary } from "../../functions/server/dashboard";
import { WaitlistDialog } from "./SubscriberWaitlistDialog";
import { useTelemetry } from "../../hooks/useTelemetry";
import {
CONST_MAX_NUM_ADDRESSES,
CONST_MAX_NUM_ADDRESSES_PLUS,
CONST_ONEREP_MAX_SCANS_THRESHOLD,
} from "../../../constants";
import { VisuallyHidden } from "../server/VisuallyHidden";
export type Props = {
data: Array<[string, number]>;
isEligibleForFreeScan: boolean;
isEligibleForPremium: boolean;
isPremiumUser: boolean;
scanInProgress: boolean;
isShowFixed: boolean;
summary: DashboardSummary;
totalNumberOfPerformedScans?: number;
};
export const DoughnutChart = (props: Props) => {
const l10n = useL10n();
const recordTelemetry = useTelemetry();
const explainerDialogState = useOverlayTriggerState({
onOpenChange: (isOpen) => {
recordTelemetry("popup", isOpen ? "view" : "exit", {
popup_id: `number_of_exposures_info`,
});
},
});
const explainerDialogTrigger = useOverlayTrigger(
{ type: "dialog" },
explainerDialogState,
);
const waitlistDialogState = useOverlayTriggerState({});
const waitlistDialogTrigger = useOverlayTrigger(
{ type: "dialog" },
waitlistDialogState,
);
const sumOfFixedExposures = props.data.reduce(
(total, [_label, num]) => total + num,
0,
);
const percentages = props.data.map(([label, num]) => {
return [label, num / sumOfFixedExposures] as const;
});
const diameter = 100;
const ringWidth = 15;
const radius = (diameter - ringWidth) / 2;
const circumference = 2 * Math.PI * radius;
const sliceBorderWidth = 0;
const headingNumberSize = diameter / 8;
const headingLabelSize = headingNumberSize / 2;
const headingGap = 4;
const slices = percentages.map(([label, percent], index) => {
const percentOffset = percentages
.slice(0, index)
.reduce((offset, [_label, num]) => offset + num, 0);
const sliceLength = circumference * (1 - percent) + sliceBorderWidth;
return (
<circle
key={label}
cx={diameter / 2}
cy={diameter / 2}
r={radius}
className={styles.slice}
fill="none"
strokeWidth={ringWidth}
strokeDasharray={`${circumference} ${circumference}`}
strokeDashoffset={`${sliceLength}`}
// Rotate it to not overlap the other slices
transform={`rotate(${-90 + 360 * percentOffset} ${diameter / 2} ${
diameter / 2
})`}
/>
);
});
const includesDataBrokers = props.isEligibleForPremium || props.isPremiumUser;
const modalContentActionNeeded = (
<div className={styles.modalBodyContent}>
<p>
{l10n.getString(
includesDataBrokers
? "modal-active-number-of-exposures-part-one-premium"
: "modal-active-number-of-exposures-part-one-all",
{
limit: props.isPremiumUser
? CONST_MAX_NUM_ADDRESSES_PLUS
: CONST_MAX_NUM_ADDRESSES,
},
)}
</p>
<p>{l10n.getString("modal-active-number-of-exposures-part-two")}</p>
<p>
{l10n.getString(
includesDataBrokers
? "modal-active-number-of-exposures-part-three-premium"
: "modal-active-number-of-exposures-part-three-all",
)}
</p>
<div className={styles.confirmButtonWrapper}>
<Button
variant="primary"
// TODO: Add unit test when changing this code:
/* c8 ignore next */
onPress={() => explainerDialogState.close()}
autoFocus={true}
className={styles.startButton}
>
{l10n.getString("modal-cta-ok")}
</Button>
</div>
</div>
);
const modalContentFixed = (
<div className={styles.modalBodyContent}>
{l10n.getString(
includesDataBrokers
? "modal-fixed-number-of-exposures-part-one"
: "modal-fixed-number-of-exposures-all",
)}
{includesDataBrokers &&
l10n.getString("modal-fixed-number-of-exposures-part-two")}
</div>
);
const getPromptContent = () => {
if (!props.scanInProgress && props.isEligibleForFreeScan) {
return (
<>
<p>
{l10n.getString("exposure-chart-returning-user-upgrade-prompt")}
</p>
{typeof props.totalNumberOfPerformedScans === "undefined" ||
props.totalNumberOfPerformedScans <
CONST_ONEREP_MAX_SCANS_THRESHOLD ? (
<Link
href="/user/welcome/free-scan?referrer=dashboard"
onClick={() => {
recordTelemetry("link", "click", {
link_id: "exposures_chart_free_scan",
});
}}
>
{l10n.getString(
"exposure-chart-returning-user-upgrade-prompt-cta",
)}
</Link>
) : (
<>
<Button variant="link" {...waitlistDialogTrigger.triggerProps}>
{l10n.getString(
"exposure-chart-returning-user-upgrade-prompt-cta",
)}
</Button>
<WaitlistDialog
dialogTriggerState={waitlistDialogState}
{...waitlistDialogTrigger.overlayProps}
/>
</>
)}
</>
);
}
if (props.scanInProgress) {
return (
<p>
{l10n.getFragment("exposure-chart-scan-in-progress-prompt", {
elems: { b: <strong /> },
})}
</p>
);
}
};
const promptContent = getPromptContent();
return (
<>
<figure className={styles.chartContainer}>
<div className={styles.chartAndLegendWrapper}>
<svg
// https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/img_role#svg_and_roleimg
role="img"
aria-label={l10n
.getString("exposure-chart-heading", {
nr: sumOfFixedExposures,
})
.replace("<nr>", "")
.replace("</nr>", "")
.replace("<label>", "")
.replace("</label>", "")}
viewBox={`0 0 ${diameter} ${diameter}`}
className={styles.chart}
>
<circle
cx={diameter / 2}
cy={diameter / 2}
r={radius}
fill="none"
strokeWidth={ringWidth}
className={styles.gutter}
/>
{slices}
{props.isShowFixed
? l10n.getFragment("exposure-chart-heading-fixed", {
elems: {
nr: (
<text
className={styles.headingNr}
fontSize={headingNumberSize}
x={diameter / 2}
y={diameter / 2 - headingGap / 2}
textAnchor="middle"
/>
),
label: (
<text
className={styles.headingLabel}
fontSize={headingLabelSize}
x={diameter / 2}
y={diameter / 2 + headingLabelSize + headingGap / 2}
textAnchor="middle"
/>
),
},
vars: { nr: sumOfFixedExposures },
})
: l10n.getFragment("exposure-chart-heading", {
elems: {
nr: (
<text
className={styles.headingNr}
fontSize={headingNumberSize}
x={diameter / 2}
y={diameter / 2 - headingGap / 2}
textAnchor="middle"
/>
),
label: (
<text
className={styles.headingLabel}
fontSize={headingLabelSize}
x={diameter / 2}
y={diameter / 2 + headingLabelSize + headingGap / 2}
textAnchor="middle"
/>
),
},
vars: { nr: sumOfFixedExposures },
})}
</svg>
<div className={styles.legend}>
<table>
<thead>
<tr>
{/* The first column contains the chart colour,
which is irrelevant to screen readers. */}
<td aria-hidden={true} />
<th>
{l10n.getString("exposure-chart-legend-heading-type")}
</th>
<th>{l10n.getString("exposure-chart-legend-heading-nr")}</th>
</tr>
</thead>
<tbody>
{props.data.map(([label, num]) => (
<tr key={label}>
<td aria-hidden={true}>
<svg viewBox="0 0 10 10">
<rect rx={2} width="10" height="10" />
</svg>
</td>
<td>{label}</td>
<td>
{l10n.getString("exposure-chart-legend-value-nr", {
nr: num,
})}
</td>
</tr>
))}
</tbody>
</table>
{promptContent && (
<div className={styles.prompt}>{promptContent}</div>
)}
</div>
</div>
<figcaption>
{props.isShowFixed
? l10n.getFragment("exposure-chart-caption-fixed", {
vars: {
total_fixed_exposures_num:
props.summary.dataBreachFixedDataPointsNum +
props.summary.dataBrokerAutoFixedDataPointsNum +
props.summary.dataBrokerManuallyResolvedDataPointsNum,
total_exposures_num: props.summary.totalDataPointsNum,
},
})
: l10n.getString("exposure-chart-caption")}
<button
// TODO: Add unit test when changing this code:
/* c8 ignore next */
onClick={() => explainerDialogState.open()}
aria-label={l10n.getString("open-modal-alt")}
aria-describedby="modalFixedNumberOfExposures"
>
<VisuallyHidden id="modalFixedNumberOfExposures">
{props.isShowFixed
? l10n.getString("modal-fixed-number-of-exposures-title")
: l10n.getString("modal-active-number-of-exposures-title")}
</VisuallyHidden>
<QuestionMarkCircle
alt=""
aria-label={l10n.getString("open-modal-alt")}
width="15"
height="15"
/>
</button>
</figcaption>
</figure>
{explainerDialogState.isOpen && (
<ModalOverlay
state={explainerDialogState}
{...explainerDialogTrigger.overlayProps}
isDismissable={true}
>
<Dialog
title={
props.isShowFixed
? l10n.getString("modal-fixed-number-of-exposures-title")
: l10n.getString("modal-active-number-of-exposures-title")
}
illustration={<Image src={ModalImage} alt="" />}
// TODO: Add unit test when changing this code:
/* c8 ignore next */
onDismiss={() => explainerDialogState.close()}
>
{props.isShowFixed ? modalContentFixed : modalContentActionNeeded}
</Dialog>
</ModalOverlay>
)}
</>
);
};