Fixup exposure cards UI (#3236)
* localize category strings * truncate long company titles * be able to add multiple rows into exposed info section * fix width of button * pass verified email into breach card * add backup logo * set unique colour for each exposure * use fallback logo and shift rest of categories to the left, increase width of company name space * remove comments * fix lint errors * use correct l10n strings * fix lint error * fix unit test failure * dont hardcode :
This commit is contained in:
Родитель
4aa64be7cb
Коммит
cae921e55c
|
@ -76,8 +76,8 @@ open-in-new-tab-alt = Open link in a new tab
|
|||
|
||||
# Status Pill
|
||||
|
||||
status-pill-action-needed = Action Needed
|
||||
status-pill-progress = In Progress
|
||||
status-pill-action-needed = Action needed
|
||||
status-pill-progress = In progress
|
||||
status-pill-fixed = Fixed
|
||||
|
||||
# Exposure Card
|
||||
|
@ -103,7 +103,7 @@ exposure-card-description-info-for-sale-part-two = Remove this profile to protec
|
|||
# $data_breach_date is the date of the data breach.
|
||||
exposure-card-description-data-breach-part-one = Your information was exposed in the <data_breach_link>{ $data_breach_company } data breach on { $data_breach_date }.</data_breach_link>
|
||||
exposure-card-description-data-breach-part-two = We’ll walk you through the steps to fix it.
|
||||
exposure-card-your-exposed-info = Your exposed info
|
||||
exposure-card-your-exposed-info = Your exposed info:
|
||||
exposure-card-exposure-type-data-broker = Info for sale
|
||||
exposure-card-exposure-type-data-breach = Data breach
|
||||
exposure-card-cta = Let’s fix it
|
||||
|
|
|
@ -6,7 +6,6 @@
|
|||
|
||||
import { Session } from "next-auth";
|
||||
import styles from "./View.module.scss";
|
||||
import TwitterImage from "../../../../../components/client/assets/twitter-icon.png";
|
||||
import { Toolbar } from "../../../../../components/client/toolbar/Toolbar";
|
||||
import { DashboardTopBanner } from "./DashboardTopBanner";
|
||||
import { useL10n } from "../../../../../hooks/l10n";
|
||||
|
@ -62,13 +61,14 @@ export const View = (props: Props) => {
|
|||
className={styles.exposureListItem}
|
||||
>
|
||||
<ExposureCard
|
||||
exposureImg={TwitterImage}
|
||||
exposureData={breach}
|
||||
exposureName={breach.Name}
|
||||
fromEmail={verifiedEmail.email}
|
||||
exposureDetailsLink={""} //TODO: Find out what link to add in a breach card
|
||||
dateFound={breach.AddedDate}
|
||||
statusPillType={"needAction"}
|
||||
statusPillType="needAction"
|
||||
locale={props.locale}
|
||||
color={getRandomLightNebulaColor(breach.Name)}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
|
@ -159,32 +159,46 @@ export const View = (props: Props) => {
|
|||
|
||||
const exposureCardElems = filteredExposures.map(
|
||||
(exposure: ScanResult | HibpLikeDbBreach, index) => {
|
||||
let email;
|
||||
// Get the email assosciated with breach
|
||||
if (!isScanResult(exposure)) {
|
||||
props.userBreaches.breachesData.verifiedEmails.forEach(
|
||||
(verifiedEmail) => {
|
||||
if (
|
||||
verifiedEmail.breaches.some((breach) => breach.Id === exposure.Id)
|
||||
) {
|
||||
email = verifiedEmail.email;
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{isScanResult(exposure) ? (
|
||||
// Scanned result
|
||||
<li key={index} className={styles.exposureListItem}>
|
||||
<ExposureCard
|
||||
exposureImg={TwitterImage}
|
||||
exposureData={exposure}
|
||||
exposureName={exposure.data_broker}
|
||||
exposureDetailsLink={exposure.link}
|
||||
dateFound={dateObject(exposure.created_at)}
|
||||
statusPillType={"needAction"}
|
||||
statusPillType="needAction"
|
||||
locale={props.locale}
|
||||
color={getRandomLightNebulaColor(exposure.data_broker)}
|
||||
/>
|
||||
</li>
|
||||
) : (
|
||||
// Breaches result
|
||||
<li key={index} className={styles.exposureListItem}>
|
||||
<ExposureCard
|
||||
exposureImg={TwitterImage}
|
||||
exposureData={exposure}
|
||||
exposureName={exposure.Name}
|
||||
fromEmail={email}
|
||||
exposureDetailsLink={""}
|
||||
dateFound={exposure.AddedDate}
|
||||
statusPillType={"needAction"}
|
||||
statusPillType="needAction"
|
||||
locale={props.locale}
|
||||
color={getRandomLightNebulaColor(exposure.Name)}
|
||||
/>
|
||||
</li>
|
||||
)}
|
||||
|
@ -228,3 +242,49 @@ export const View = (props: Props) => {
|
|||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Same logic as breachLogo.js
|
||||
function getRandomLightNebulaColor(name: string) {
|
||||
const colors = [
|
||||
"#C689FF",
|
||||
"#D9BFFF",
|
||||
"#AB71FF",
|
||||
"#E7DFFF",
|
||||
"#AB71FF",
|
||||
"#3FE1B0",
|
||||
"#54FFBD",
|
||||
"#88FFD1",
|
||||
"#B3FFE3",
|
||||
"#D1FFEE",
|
||||
"#F770FF",
|
||||
"#F68FFF",
|
||||
"#F6B8FF",
|
||||
"#00B3F4",
|
||||
"#00DDFF",
|
||||
"#80EBFF",
|
||||
"#FF8450",
|
||||
"#FFA266",
|
||||
"#FFB587",
|
||||
"#FFD5B2",
|
||||
"#FF848B",
|
||||
"#FF9AA2",
|
||||
"#FFBDC5",
|
||||
"#FF8AC5",
|
||||
"#FFB4DB",
|
||||
];
|
||||
|
||||
const charValues = name.split("").map((letter) => letter.codePointAt(0));
|
||||
|
||||
const charSum = charValues.reduce((sum: number | undefined, codePoint) => {
|
||||
if (codePoint === undefined) return sum;
|
||||
if (sum === undefined) return codePoint;
|
||||
return sum + codePoint;
|
||||
}, undefined);
|
||||
|
||||
if (charSum === undefined) {
|
||||
return colors[0];
|
||||
}
|
||||
|
||||
const colorIndex = charSum % colors.length;
|
||||
return colors[colorIndex];
|
||||
}
|
||||
|
|
|
@ -45,6 +45,17 @@
|
|||
justify-content: space-between;
|
||||
height: 30px; // fixed height to standardize image/icon heights
|
||||
|
||||
.fallbackLogo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
border-radius: 50px;
|
||||
color: black;
|
||||
font: $text-title-3xs;
|
||||
}
|
||||
|
||||
dd {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
@ -53,17 +64,30 @@
|
|||
display: none;
|
||||
}
|
||||
|
||||
.exposureCompanyTitle {
|
||||
font: $text-body-sm;
|
||||
max-width: 200px;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media screen and (min-width: $screen-lg) {
|
||||
flex: 1 1 0%;
|
||||
|
||||
&.exposureImageWrapper {
|
||||
flex: 0.2 0 $width-first-column-filter-bar;
|
||||
}
|
||||
|
||||
&.hideOnMobile {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.exposureImage {
|
||||
max-width: 120px;
|
||||
height: 100%;
|
||||
.exposureImageWrapper {
|
||||
.exposureImage {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -101,6 +125,26 @@
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-md;
|
||||
|
||||
@media screen and (min-width: $screen-lg) {
|
||||
flex: 1 1 0;
|
||||
|
||||
.fixItBtn {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.exposedInfoTitle {
|
||||
@media screen and (min-width: $screen-lg) {
|
||||
align-self: center;
|
||||
flex: 0 0 90px; // fix width of categories title
|
||||
}
|
||||
|
||||
@media screen and (min-width: $screen-xl) {
|
||||
align-self: center;
|
||||
flex: 0 0 150px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: $screen-lg) {
|
||||
|
@ -121,14 +165,20 @@
|
|||
margin-left: 0;
|
||||
padding-left: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
grid-gap: $spacing-sm;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-row-gap: $spacing-sm;
|
||||
|
||||
@media screen and (min-width: $screen-sm) {
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
}
|
||||
|
||||
@media screen and (min-width: $screen-lg) {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: $layout-xs;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.detailsFoundItem {
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
"use client";
|
||||
|
||||
import React, { ReactElement, useState } from "react";
|
||||
import React, { CSSProperties, ReactElement, useState } from "react";
|
||||
import styles from "./ExposureCard.module.scss";
|
||||
import { StatusPill, StatusPillType } from "../server/StatusPill";
|
||||
import Image, { StaticImageData } from "next/image";
|
||||
|
@ -50,18 +50,21 @@ export function isScanResult(
|
|||
}
|
||||
|
||||
export type ExposureCardProps = {
|
||||
exposureImg: StaticImageData;
|
||||
exposureImg?: StaticImageData;
|
||||
exposureName: string;
|
||||
exposureData: Exposure;
|
||||
exposureDetailsLink: string;
|
||||
dateFound: Date;
|
||||
statusPillType: StatusPillType;
|
||||
locale: string;
|
||||
fromEmail?: string;
|
||||
color: string;
|
||||
};
|
||||
|
||||
type BreachExposureCategoryProps = {
|
||||
exposureCategoryLabel: string;
|
||||
icon: ReactElement;
|
||||
email: string;
|
||||
};
|
||||
|
||||
type ScannedExposureCategoryProps = {
|
||||
|
@ -78,10 +81,12 @@ export const ExposureCard = (props: ExposureCardProps) => {
|
|||
exposureDetailsLink,
|
||||
statusPillType,
|
||||
locale,
|
||||
color,
|
||||
} = props;
|
||||
|
||||
const l10n = useL10n();
|
||||
const [exposureCardExpanded, setExposureCardExpanded] = useState(false);
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat(locale, {
|
||||
// See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat#datestyle
|
||||
dateStyle: "medium",
|
||||
|
@ -89,6 +94,8 @@ export const ExposureCard = (props: ExposureCardProps) => {
|
|||
const exposureCategoriesArray: React.ReactElement[] = [];
|
||||
const exposureItem = props.exposureData;
|
||||
|
||||
const verifiedEmailofBreach = props.fromEmail;
|
||||
|
||||
const BreachExposureCategory = (props: BreachExposureCategoryProps) => {
|
||||
const description = l10n.getString("exposure-card-num-found", {
|
||||
exposure_num: 1, // We don't count categories for breaches.
|
||||
|
@ -100,7 +107,11 @@ export const ExposureCard = (props: ExposureCardProps) => {
|
|||
<span className={styles.exposureTypeIcon}>{props.icon}</span>
|
||||
{props.exposureCategoryLabel}
|
||||
</dt>
|
||||
<dd>{description}</dd>
|
||||
<dd>
|
||||
{props.email === "email-addresses"
|
||||
? verifiedEmailofBreach
|
||||
: description}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -181,6 +192,7 @@ export const ExposureCard = (props: ExposureCardProps) => {
|
|||
exposureCategoriesArray.push(
|
||||
<BreachExposureCategory
|
||||
key={item}
|
||||
email={item}
|
||||
icon={<EmailIcon alt="" width="13" height="13" />}
|
||||
exposureCategoryLabel={l10n.getString("exposure-card-email")}
|
||||
/>
|
||||
|
@ -189,6 +201,7 @@ export const ExposureCard = (props: ExposureCardProps) => {
|
|||
exposureCategoriesArray.push(
|
||||
<BreachExposureCategory
|
||||
key={item}
|
||||
email={item}
|
||||
icon={<PasswordIcon alt="" width="13" height="13" />}
|
||||
exposureCategoryLabel={l10n.getString("exposure-card-password")}
|
||||
/>
|
||||
|
@ -197,6 +210,7 @@ export const ExposureCard = (props: ExposureCardProps) => {
|
|||
exposureCategoriesArray.push(
|
||||
<BreachExposureCategory
|
||||
key={item}
|
||||
email={item}
|
||||
icon={<PhoneIcon alt="" width="13" height="13" />}
|
||||
exposureCategoryLabel={l10n.getString("exposure-card-phone-number")}
|
||||
/>
|
||||
|
@ -205,6 +219,7 @@ export const ExposureCard = (props: ExposureCardProps) => {
|
|||
exposureCategoriesArray.push(
|
||||
<BreachExposureCategory
|
||||
key={item}
|
||||
email={item}
|
||||
icon={<QuestionMarkCircle alt="" width="13" height="13" />}
|
||||
exposureCategoryLabel={l10n.getString("exposure-card-ip-address")}
|
||||
/>
|
||||
|
@ -215,8 +230,9 @@ export const ExposureCard = (props: ExposureCardProps) => {
|
|||
exposureCategoriesArray.push(
|
||||
<BreachExposureCategory
|
||||
key={item}
|
||||
icon={<QuestionMarkCircle alt="" width="13" height="13" />}
|
||||
exposureCategoryLabel={formatOtherBreachCategoriesLabel(item)} // Other exposure categories labels not localized for now
|
||||
email={item}
|
||||
icon={<QuestionMarkCircle alt="" width="13" height="13" />} // default icon for categories without a unique one
|
||||
exposureCategoryLabel={l10n.getString(item)} // categories are localized in data-classes.ftl
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -232,27 +248,48 @@ export const ExposureCard = (props: ExposureCardProps) => {
|
|||
return <>{listItems}</>;
|
||||
};
|
||||
|
||||
function fallbackLogo(exposureId: string) {
|
||||
const firstLetter = exposureId?.[0]?.toUpperCase() || "";
|
||||
|
||||
return (
|
||||
<span
|
||||
className={styles.fallbackLogo}
|
||||
style={{ background: color } as CSSProperties}
|
||||
>
|
||||
{firstLetter}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const exposureCard = (
|
||||
<div>
|
||||
<div className={styles.exposureCard}>
|
||||
<div className={styles.exposureHeader}>
|
||||
<dl className={styles.exposureHeaderList}>
|
||||
<dt className={styles.visuallyHidden}>
|
||||
{l10n.getString("exposure-card-company-logo")}
|
||||
{l10n.getString("exposure-card-label-company-logo")}
|
||||
</dt>
|
||||
<dd
|
||||
className={`${styles.exposureImageWrapper} ${styles.hideOnMobile}`}
|
||||
className={`${styles.hideOnMobile} ${styles.exposureImageWrapper}`}
|
||||
>
|
||||
<Image
|
||||
className={styles.exposureImage}
|
||||
alt=""
|
||||
src={exposureImg}
|
||||
/>
|
||||
{/* While logo is not yet set, the fallback image is the first character of the exposure name */}
|
||||
{exposureImg ? (
|
||||
<Image
|
||||
className={styles.exposureImage}
|
||||
alt=""
|
||||
src={exposureImg}
|
||||
/>
|
||||
) : (
|
||||
<>{fallbackLogo(props.exposureName)}</>
|
||||
)}
|
||||
</dd>
|
||||
<dt className={styles.visuallyHidden}>
|
||||
{l10n.getString("exposure-card-company")}
|
||||
{l10n.getString("exposure-card-label-company")}
|
||||
</dt>
|
||||
<dd>{exposureName}</dd>
|
||||
<dd>
|
||||
<span className={styles.exposureCompanyTitle}>
|
||||
{exposureName}
|
||||
</span>
|
||||
</dd>
|
||||
<dt className={styles.visuallyHidden}>
|
||||
{l10n.getString("exposure-card-exposure-type")}
|
||||
</dt>
|
||||
|
@ -343,7 +380,7 @@ export const ExposureCard = (props: ExposureCardProps) => {
|
|||
height="13"
|
||||
/>
|
||||
</span>
|
||||
</a>{" "}
|
||||
</a>
|
||||
{l10n.getString(
|
||||
"exposure-card-description-data-breach-part-two"
|
||||
)}
|
||||
|
@ -352,7 +389,9 @@ export const ExposureCard = (props: ExposureCardProps) => {
|
|||
)}
|
||||
<div className={styles.exposedInfoContainer}>
|
||||
<div className={styles.exposedInfoWrapper}>
|
||||
<p>{l10n.getString("exposure-card-your-exposed-info")}:</p>
|
||||
<p className={styles.exposedInfoTitle}>
|
||||
{l10n.getString("exposure-card-your-exposed-info")}
|
||||
</p>
|
||||
<dl>
|
||||
<ExposureCategoriesListElem />
|
||||
</dl>
|
||||
|
@ -370,9 +409,3 @@ export const ExposureCard = (props: ExposureCardProps) => {
|
|||
|
||||
return exposureCard;
|
||||
};
|
||||
|
||||
function formatOtherBreachCategoriesLabel(sentence: string): string {
|
||||
return sentence
|
||||
.replaceAll("-", " ")
|
||||
.replace(/^[a-z]/, (i) => i.toUpperCase());
|
||||
}
|
||||
|
|
|
@ -51,6 +51,10 @@
|
|||
@media screen and (min-width: $screen-lg) {
|
||||
flex: 1 1 0%;
|
||||
|
||||
&.exposureImageWrapper {
|
||||
flex: 0.2 0 $width-first-column-filter-bar;
|
||||
}
|
||||
|
||||
&.hideOnMobile {
|
||||
display: flex;
|
||||
}
|
||||
|
|
|
@ -212,7 +212,7 @@ export const ExposuresFilter = ({ setFilterValues }: ExposuresFilterProps) => {
|
|||
<>
|
||||
<div className={styles.filterHeaderWrapper}>
|
||||
<ul className={styles.filterHeaderList}>
|
||||
<li>
|
||||
<li className={styles.exposureImageWrapper}>
|
||||
<button
|
||||
className={styles.filterBtn}
|
||||
ref={filterBtnRef}
|
||||
|
|
|
@ -4,7 +4,11 @@
|
|||
font: $text-body-xs;
|
||||
font-weight: 600;
|
||||
display: inline-block;
|
||||
min-width: 120px; // keep the width fixed
|
||||
min-width: 90px; // keep the width fixed
|
||||
|
||||
@media screen and (min-width: $screen-md) {
|
||||
min-width: 120px; // keep the width fixed
|
||||
}
|
||||
text-align: center;
|
||||
padding: $spacing-sm 0;
|
||||
border-radius: $border-radius-sm;
|
||||
|
|
|
@ -238,6 +238,7 @@ $text-body-xs: 400 clamp(10px, 0.975svw, 12px) / 1.5 var(--font-inter),
|
|||
sans-serif;
|
||||
|
||||
$tab-bar-height: 100px;
|
||||
$width-first-column-filter-bar: 50px;
|
||||
|
||||
@mixin visually-hidden {
|
||||
// These styles are taken from
|
||||
|
|
Загрузка…
Ссылка в новой задаче