MNTOR-2675 - Scan limit / waitlist state (#3952)
* MNTOR-2675 - Scan limit component * rebase * add unit test * add unit test * next nav * onpress * add waitlist btn * add console error suppression * feat: add logic for swapping waitlist page * chore: review comments * add join waitlist to signup encouragement sections * Apply suggestions from code review Co-authored-by: Vincent <Vinnl@users.noreply.github.com> * Add waitlist/scan limit state for plans section (#3967) * Add waitlist/scan limit state for plans section * ui tweak * revert * remove uneeded default state * ignore test * remove jest nav * scanlimit to scanlimit reached * scanlimit to scanlimit reached * scanlimit to scanlimit reached --------- Co-authored-by: Joey Zhou <jozhou@mozilla.com> Co-authored-by: Vincent <Vinnl@users.noreply.github.com>
This commit is contained in:
Родитель
0919bcbe72
Коммит
e2ae06855d
|
@ -132,3 +132,5 @@ landing-premium-continuous-data-removal-ans = { $data_broker_sites_total_num ->
|
|||
landing-premium-max-scan = We’ve reached the maximum scans for the month. Enter your email to get on our waitlist.
|
||||
landing-premium-max-scan-at-capacity = At capacity
|
||||
landing-premium-max-scan-waitlist = Join waitlist
|
||||
landing-premium-waitlist-section-pt-1 = We’ve reached the maximum scans for the month.
|
||||
landing-premium-waitlist-section-pt-2 = Enter your email to get on our waitlist.
|
||||
|
|
|
@ -314,6 +314,46 @@
|
|||
}
|
||||
}
|
||||
|
||||
.scanLimitWrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $layout-xs;
|
||||
|
||||
.info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-md;
|
||||
|
||||
@media screen and (min-width: $screen-md) {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
b {
|
||||
@include uppercase-only-english;
|
||||
white-space: nowrap;
|
||||
align-self: flex-start;
|
||||
border-radius: $border-radius-sm;
|
||||
font: $text-body-sm;
|
||||
font-weight: 600;
|
||||
background: $color-purple-70;
|
||||
color: $color-white;
|
||||
padding: $spacing-sm $spacing-md;
|
||||
|
||||
@media screen and (min-width: $screen-md) {
|
||||
align-self: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.waitlistCta {
|
||||
align-self: flex-start;
|
||||
|
||||
@media screen and (min-width: $screen-md) {
|
||||
min-width: 200px; // width of waitlist button
|
||||
}
|
||||
}
|
||||
|
||||
.signUpEncouragementWrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
@ -329,4 +369,29 @@
|
|||
font-family: var(--font-inter);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.waitlistCta {
|
||||
align-self: center;
|
||||
}
|
||||
}
|
||||
|
||||
.waitlistSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: $spacing-lg;
|
||||
padding: $layout-md $spacing-md;
|
||||
|
||||
.waitlistTitle {
|
||||
text-align: center;
|
||||
font: $text-title-2xs;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
font-family: var(--font-inter);
|
||||
color: $color-purple-70;
|
||||
}
|
||||
|
||||
a {
|
||||
align-self: center;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -22,6 +22,16 @@ export const LandingUs: Story = {
|
|||
args: {
|
||||
eligibleForPremium: true,
|
||||
countryCode: "us",
|
||||
scanLimitReached: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const LandingUsScanLimit: Story = {
|
||||
name: "US visitors - Scan limit reached",
|
||||
args: {
|
||||
eligibleForPremium: true,
|
||||
countryCode: "us",
|
||||
scanLimitReached: true,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -21,6 +21,7 @@ import Meta, {
|
|||
LandingNonUsDe,
|
||||
LandingNonUsFr,
|
||||
LandingUs,
|
||||
LandingUsScanLimit,
|
||||
} from "./LandingView.stories";
|
||||
|
||||
jest.mock("next-auth/react");
|
||||
|
@ -388,4 +389,34 @@ describe("When Premium is available", () => {
|
|||
const progressCardIllustration = screen.getByTestId("progress-card-image");
|
||||
expect(progressCardIllustration).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the scan limit and waitlist cta when it hits the threshold", () => {
|
||||
const ComposedDashboard = composeStory(LandingUsScanLimit, Meta);
|
||||
render(<ComposedDashboard />);
|
||||
|
||||
const limitDescription = screen.getByText(
|
||||
"We’ve reached the maximum scans for the month. Enter your email to get on our waitlist.",
|
||||
);
|
||||
expect(limitDescription).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the waitlist page when the join waitlist cta is selected", async () => {
|
||||
const user = userEvent.setup();
|
||||
const ComposedDashboard = composeStory(LandingUsScanLimit, Meta);
|
||||
render(<ComposedDashboard />);
|
||||
const waitlistCta = screen.getAllByRole("link", {
|
||||
name: "Join waitlist",
|
||||
});
|
||||
// jsdom will complain about not being able to navigate to a different page
|
||||
// after clicking the link; suppress that error, as it's not relevant to the
|
||||
// test:
|
||||
jest.spyOn(console, "error").mockImplementationOnce(() => undefined);
|
||||
|
||||
await user.click(waitlistCta[0]);
|
||||
|
||||
expect(waitlistCta[0]).toHaveAttribute(
|
||||
"href",
|
||||
"https://www.mozilla.org/products/monitor/waitlist-scan/",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -22,11 +22,13 @@ import PCMagLogo from "./social-proof-images/pcmag.svg";
|
|||
import TechCruchLogo from "./social-proof-images/techcrunch.svg";
|
||||
import { TelemetryLink } from "./TelemetryLink";
|
||||
import { HeresHowWeHelp } from "./HeresHowWeHelp";
|
||||
import { ScanLimit } from "./ScanLimit";
|
||||
|
||||
export type Props = {
|
||||
eligibleForPremium: boolean;
|
||||
l10n: ExtendedReactLocalization;
|
||||
countryCode: string;
|
||||
scanLimitReached: boolean;
|
||||
};
|
||||
|
||||
export const View = (props: Props) => {
|
||||
|
@ -45,22 +47,27 @@ export const View = (props: Props) => {
|
|||
: "landing-all-hero-lead",
|
||||
)}
|
||||
</p>
|
||||
<SignUpForm
|
||||
isHero
|
||||
eligibleForPremium={props.eligibleForPremium}
|
||||
signUpCallbackUrl={`${process.env.SERVER_URL}/redesign/user/dashboard/`}
|
||||
eventId={{
|
||||
cta: "clicked_get_scan_header",
|
||||
field: "entered_email_address_header",
|
||||
}}
|
||||
/>
|
||||
{props.eligibleForPremium && props.scanLimitReached ? (
|
||||
<ScanLimit />
|
||||
) : (
|
||||
<SignUpForm
|
||||
scanLimitReached={props.scanLimitReached}
|
||||
isHero
|
||||
eligibleForPremium={props.eligibleForPremium}
|
||||
signUpCallbackUrl={`${process.env.SERVER_URL}/redesign/user/dashboard/`}
|
||||
eventId={{
|
||||
cta: "clicked_get_scan_header",
|
||||
field: "entered_email_address_header",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.heroImage}>
|
||||
<HeroImage {...props} />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className={styles.quoteWrapper}>
|
||||
<div className={styles.quoteWrapper}>
|
||||
<div className={styles.quote}>
|
||||
<h2>
|
||||
{props.eligibleForPremium
|
||||
|
@ -76,9 +83,9 @@ export const View = (props: Props) => {
|
|||
})}
|
||||
</h2>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className={styles.valuePropositionWrapper}>
|
||||
<div className={styles.valuePropositionWrapper}>
|
||||
<div className={`${styles.item} ${styles.grayBg}`}>
|
||||
<span>
|
||||
<h2>
|
||||
|
@ -116,6 +123,7 @@ export const View = (props: Props) => {
|
|||
)}
|
||||
</p>
|
||||
<SignUpForm
|
||||
scanLimitReached={props.scanLimitReached}
|
||||
eligibleForPremium={props.eligibleForPremium}
|
||||
signUpCallbackUrl={`${process.env.SERVER_URL}/redesign/user/dashboard/`}
|
||||
eventId={{
|
||||
|
@ -161,6 +169,7 @@ export const View = (props: Props) => {
|
|||
)}
|
||||
</p>
|
||||
<SignUpForm
|
||||
scanLimitReached={props.scanLimitReached}
|
||||
eligibleForPremium={props.eligibleForPremium}
|
||||
signUpCallbackUrl={`${process.env.SERVER_URL}/redesign/user/dashboard/`}
|
||||
eventId={{
|
||||
|
@ -173,7 +182,7 @@ export const View = (props: Props) => {
|
|||
<LeakedPasswordExampleIllustration {...props} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className={styles.signUpEncouragementWrapper}>
|
||||
<p className={styles.title}>
|
||||
|
@ -186,10 +195,11 @@ export const View = (props: Props) => {
|
|||
cta: "clicked_get_scan_fourth",
|
||||
field: "entered_email_address_fourth",
|
||||
}}
|
||||
scanLimitReached={props.scanLimitReached}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className={styles.socialProofWrapper}>
|
||||
<div className={styles.socialProofWrapper}>
|
||||
<h2>
|
||||
{props.l10n.getString("landing-all-social-proof-title", {
|
||||
num_users: 10,
|
||||
|
@ -210,10 +220,9 @@ export const View = (props: Props) => {
|
|||
<Image src={CNETLogo} alt="" />
|
||||
<Image src={GoogleLogo} alt="" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{!props.eligibleForPremium && <HeresHowWeHelp />}
|
||||
|
||||
<Plans {...props} />
|
||||
|
||||
<div className={styles.signUpEncouragementWrapper}>
|
||||
|
@ -227,6 +236,7 @@ export const View = (props: Props) => {
|
|||
cta: "clicked_get_scan_last",
|
||||
field: "entered_email_address_last",
|
||||
}}
|
||||
scanLimitReached={props.scanLimitReached}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
|
@ -271,20 +281,40 @@ const Plans = (props: Props) => {
|
|||
}
|
||||
|
||||
return (
|
||||
<section className={styles.plans}>
|
||||
<div className={styles.plans}>
|
||||
<h2 id={headingId} className={styles.planName}>
|
||||
{props.l10n.getString("landing-premium-plans-heading")}
|
||||
</h2>
|
||||
<p className={styles.lead}>
|
||||
{props.l10n.getString("landing-premium-plans-lead")}
|
||||
</p>
|
||||
|
||||
{props.eligibleForPremium && props.scanLimitReached && (
|
||||
<div className={styles.waitlistSection}>
|
||||
<b className={styles.waitlistTitle}>
|
||||
{props.l10n.getString("landing-premium-waitlist-section-pt-1")}
|
||||
<br />
|
||||
{props.l10n.getString("landing-premium-waitlist-section-pt-2")}
|
||||
</b>
|
||||
<SignUpForm
|
||||
eligibleForPremium={props.eligibleForPremium}
|
||||
signUpCallbackUrl={`${process.env.SERVER_URL}/redesign/user/dashboard/`}
|
||||
eventId={{
|
||||
cta: "intent_to_join_waitlist_third",
|
||||
}}
|
||||
scanLimitReached={props.scanLimitReached}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PlansTable
|
||||
aria-labelledby={headingId}
|
||||
premiumSubscriptionUrl={{
|
||||
monthly: getPremiumSubscriptionUrl({ type: "monthly" }),
|
||||
yearly: getPremiumSubscriptionUrl({ type: "yearly" }),
|
||||
}}
|
||||
scanLimitReached={props.scanLimitReached}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -324,26 +324,26 @@
|
|||
&.plusCard {
|
||||
position: relative;
|
||||
border: 4px solid $color-purple-70;
|
||||
|
||||
.badge {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
transform: translateY(-50%) translateX(-50%);
|
||||
background-color: $color-purple-70;
|
||||
color: $color-white;
|
||||
border-radius: $border-radius-md;
|
||||
font: $text-body-sm;
|
||||
font-weight: 600;
|
||||
padding: $spacing-sm $spacing-md;
|
||||
|
||||
@include uppercase-only-english;
|
||||
}
|
||||
}
|
||||
|
||||
&.freeCard {
|
||||
position: relative;
|
||||
border: 2px solid $color-grey-20;
|
||||
}
|
||||
.badge {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
transform: translateY(-50%) translateX(-50%);
|
||||
background-color: $color-purple-70;
|
||||
color: $color-white;
|
||||
border-radius: $border-radius-md;
|
||||
font: $text-body-sm;
|
||||
font-weight: 600;
|
||||
padding: $spacing-sm $spacing-md;
|
||||
|
||||
@include uppercase-only-english;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -64,10 +64,14 @@ export type Props = {
|
|||
};
|
||||
};
|
||||
|
||||
type ScanLimitProp = {
|
||||
scanLimitReached: boolean;
|
||||
};
|
||||
|
||||
const monthlyPriceAnnualBilling = 13.37;
|
||||
const monthlyPriceMonthlyBilling = 42.42;
|
||||
|
||||
export const PlansTable = (props: Props) => {
|
||||
export const PlansTable = (props: Props & ScanLimitProp) => {
|
||||
const l10n = useL10n();
|
||||
const roundedPriceFormatter = new Intl.NumberFormat(getLocale(l10n), {
|
||||
style: "currency",
|
||||
|
@ -85,10 +89,15 @@ export const PlansTable = (props: Props) => {
|
|||
return (
|
||||
<>
|
||||
<div className={styles.plansCards}>
|
||||
<div role="group" className={styles.plusCard}>
|
||||
<div
|
||||
role="group"
|
||||
className={props.scanLimitReached ? styles.freeCard : styles.plusCard}
|
||||
>
|
||||
<div className={styles.head}>
|
||||
<b className={styles.badge}>
|
||||
{l10n.getString("landing-premium-plans-table-annotation-plus")}
|
||||
{props.scanLimitReached
|
||||
? l10n.getString("landing-premium-max-scan-at-capacity")
|
||||
: l10n.getString("landing-premium-plans-table-annotation-plus")}
|
||||
</b>
|
||||
<h3>
|
||||
{l10n.getFragment(
|
||||
|
@ -159,17 +168,20 @@ export const PlansTable = (props: Props) => {
|
|||
</span>
|
||||
</p>
|
||||
<Button
|
||||
disabled={props.scanLimitReached}
|
||||
variant="primary"
|
||||
href={props.premiumSubscriptionUrl[billingPeriod]}
|
||||
className={styles.cta}
|
||||
>
|
||||
{l10n.getString("landing-premium-plans-table-cta-plus-label")}
|
||||
</Button>
|
||||
<small className={styles.reassurance}>
|
||||
{l10n.getString(
|
||||
"landing-premium-plans-table-reassurance-plus-label",
|
||||
)}
|
||||
</small>
|
||||
{!props.scanLimitReached && (
|
||||
<small className={styles.reassurance}>
|
||||
{l10n.getString(
|
||||
"landing-premium-plans-table-reassurance-plus-label",
|
||||
)}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
<hr />
|
||||
<div className={styles.featuresSection}>
|
||||
|
@ -314,6 +326,11 @@ export const PlansTable = (props: Props) => {
|
|||
</div>
|
||||
<div role="group" className={styles.freeCard}>
|
||||
<div className={styles.head}>
|
||||
{props.scanLimitReached && (
|
||||
<b className={styles.badge}>
|
||||
{l10n.getString("landing-premium-max-scan-at-capacity")}
|
||||
</b>
|
||||
)}
|
||||
<h3>
|
||||
{l10n.getString("landing-premium-plans-table-heading-free-title")}
|
||||
</h3>
|
||||
|
@ -333,17 +350,20 @@ export const PlansTable = (props: Props) => {
|
|||
<span className={styles.total} />
|
||||
</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={props.scanLimitReached}
|
||||
variant="primary"
|
||||
className={styles.cta}
|
||||
onPress={() => void signIn("fxa")}
|
||||
>
|
||||
{l10n.getString("landing-premium-plans-table-cta-free-label")}
|
||||
</Button>
|
||||
<small className={styles.reassurance}>
|
||||
{l10n.getString(
|
||||
"landing-premium-plans-table-reassurance-free-label",
|
||||
)}
|
||||
</small>
|
||||
{!props.scanLimitReached && (
|
||||
<small className={styles.reassurance}>
|
||||
{l10n.getString(
|
||||
"landing-premium-plans-table-reassurance-free-label",
|
||||
)}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
<hr />
|
||||
<div className={styles.featuresSection}>
|
||||
|
@ -487,12 +507,21 @@ export const PlansTable = (props: Props) => {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Table aria-labelledby={props["aria-labelledby"]} selectionMode="none">
|
||||
<Table
|
||||
aria-labelledby={props["aria-labelledby"]}
|
||||
selectionMode="none"
|
||||
scanLimitReached={props.scanLimitReached}
|
||||
>
|
||||
<TableHeader>
|
||||
<Column>
|
||||
{l10n.getString("landing-premium-plans-table-heading-feature")}
|
||||
</Column>
|
||||
<Column>
|
||||
{props.scanLimitReached && (
|
||||
<b className={styles.badge}>
|
||||
{l10n.getString("landing-premium-max-scan-at-capacity")}
|
||||
</b>
|
||||
)}
|
||||
<h3>
|
||||
{l10n.getString("landing-premium-plans-table-heading-free-title")}
|
||||
</h3>
|
||||
|
@ -504,7 +533,9 @@ export const PlansTable = (props: Props) => {
|
|||
</Column>
|
||||
<Column>
|
||||
<b className={styles.badge}>
|
||||
{l10n.getString("landing-premium-plans-table-annotation-plus")}
|
||||
{props.scanLimitReached
|
||||
? l10n.getString("landing-premium-max-scan-at-capacity")
|
||||
: l10n.getString("landing-premium-plans-table-annotation-plus")}
|
||||
</b>
|
||||
<h3>
|
||||
{l10n.getFragment(
|
||||
|
@ -654,14 +685,20 @@ export const PlansTable = (props: Props) => {
|
|||
</b>
|
||||
<span className={styles.total} />
|
||||
</p>
|
||||
<Button variant="secondary" onPress={() => void signIn("fxa")}>
|
||||
<Button
|
||||
disabled={props.scanLimitReached}
|
||||
variant="secondary"
|
||||
onPress={() => void signIn("fxa")}
|
||||
>
|
||||
{l10n.getString("landing-premium-plans-table-cta-free-label")}
|
||||
</Button>
|
||||
<small className={styles.reassurance}>
|
||||
{l10n.getString(
|
||||
"landing-premium-plans-table-reassurance-free-label",
|
||||
)}
|
||||
</small>
|
||||
{!props.scanLimitReached && (
|
||||
<small className={styles.reassurance}>
|
||||
{l10n.getString(
|
||||
"landing-premium-plans-table-reassurance-free-label",
|
||||
)}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
</Cell>
|
||||
<Cell>
|
||||
|
@ -723,16 +760,19 @@ export const PlansTable = (props: Props) => {
|
|||
</span>
|
||||
</p>
|
||||
<Button
|
||||
disabled={props.scanLimitReached}
|
||||
variant="primary"
|
||||
href={props.premiumSubscriptionUrl[billingPeriod]}
|
||||
>
|
||||
{l10n.getString("landing-premium-plans-table-cta-plus-label")}
|
||||
</Button>
|
||||
<small className={styles.reassurance}>
|
||||
{l10n.getString(
|
||||
"landing-premium-plans-table-reassurance-plus-label",
|
||||
)}
|
||||
</small>
|
||||
{!props.scanLimitReached && (
|
||||
<small className={styles.reassurance}>
|
||||
{l10n.getString(
|
||||
"landing-premium-plans-table-reassurance-plus-label",
|
||||
)}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
</Cell>
|
||||
</Row>
|
||||
|
@ -742,7 +782,9 @@ export const PlansTable = (props: Props) => {
|
|||
);
|
||||
};
|
||||
|
||||
const Table = (props: TableStateProps<object> & AriaTableProps<object>) => {
|
||||
const Table = (
|
||||
props: TableStateProps<object> & AriaTableProps<object> & ScanLimitProp,
|
||||
) => {
|
||||
const tableRef = useRef<HTMLTableElement>(null);
|
||||
const tableState = useTableState(props);
|
||||
const { collection } = tableState;
|
||||
|
@ -759,6 +801,7 @@ const Table = (props: TableStateProps<object> & AriaTableProps<object>) => {
|
|||
>
|
||||
{[...headerRow.childNodes].map((column) => (
|
||||
<TableColumnHeader
|
||||
scanLimitReached={props.scanLimitReached}
|
||||
key={column.key}
|
||||
column={column}
|
||||
state={tableState}
|
||||
|
@ -771,7 +814,12 @@ const Table = (props: TableStateProps<object> & AriaTableProps<object>) => {
|
|||
{[...collection.body.childNodes].map((row) => (
|
||||
<TableRow key={row.key} item={row} state={tableState}>
|
||||
{[...row.childNodes].map((cell) => (
|
||||
<TableCell key={cell.key} cell={cell} state={tableState} />
|
||||
<TableCell
|
||||
scanLimitReached={props.scanLimitReached}
|
||||
key={cell.key}
|
||||
cell={cell}
|
||||
state={tableState}
|
||||
/>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
|
@ -815,6 +863,7 @@ const TableHeaderRow = (props: {
|
|||
const TableColumnHeader = (props: {
|
||||
column: AriaTableColumnHeaderProps<unknown>["node"];
|
||||
state: TableState<object>;
|
||||
scanLimitReached: boolean;
|
||||
}) => {
|
||||
const columnRef = useRef<HTMLTableCellElement>(null);
|
||||
const { columnHeaderProps } = useTableColumnHeader(
|
||||
|
@ -824,6 +873,19 @@ const TableColumnHeader = (props: {
|
|||
);
|
||||
const { isFocusVisible, focusProps } = useFocusRing();
|
||||
|
||||
const outlineStyle = () => {
|
||||
switch (props.column.index) {
|
||||
case 0:
|
||||
return `${styles.featureCell} ${styles.featureHeadingCell}`;
|
||||
case 1:
|
||||
return `${styles.freeCell} ${styles.freeHeadingCell}`;
|
||||
case 2:
|
||||
return props.scanLimitReached
|
||||
? `${styles.freeCell} ${styles.freeHeadingCell}`
|
||||
: `${styles.plusCell} ${styles.plusHeadingCell}`;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<th
|
||||
{...mergeProps(columnHeaderProps, focusProps)}
|
||||
|
@ -831,14 +893,10 @@ const TableColumnHeader = (props: {
|
|||
ref={columnRef}
|
||||
// We don't currently do anything with focused table cells, so we don't
|
||||
// have any tests for it either:
|
||||
/* c8 ignore next */
|
||||
className={`${isFocusVisible ? styles.isFocused : styles.isBlurred} ${
|
||||
props.column.index === 0
|
||||
? `${styles.featureCell} ${styles.featureHeadingCell}`
|
||||
: props.column.index === 1
|
||||
? `${styles.freeCell} ${styles.freeHeadingCell}`
|
||||
: `${styles.plusCell} ${styles.plusHeadingCell}`
|
||||
}`}
|
||||
/* c8 ignore next 2*/
|
||||
className={`${
|
||||
isFocusVisible ? styles.isFocused : styles.isBlurred
|
||||
} ${outlineStyle()}`}
|
||||
>
|
||||
{props.column.rendered}
|
||||
</th>
|
||||
|
@ -877,6 +935,7 @@ const TableRow = (props: {
|
|||
const TableCell = (props: {
|
||||
cell: AriaTableCellProps["node"];
|
||||
state: TableState<object>;
|
||||
scanLimitReached: boolean;
|
||||
}) => {
|
||||
const cellRef = useRef<HTMLTableCellElement>(null);
|
||||
const { gridCellProps } = useTableCell(
|
||||
|
@ -903,6 +962,17 @@ const TableCell = (props: {
|
|||
);
|
||||
}
|
||||
|
||||
const outlineStyle = () => {
|
||||
if (!props.scanLimitReached) {
|
||||
if (props.cell.column?.index === 1) {
|
||||
return `${styles.freeCell} ${styles.freeBodyCell}`;
|
||||
} else {
|
||||
return `${styles.plusCell} ${styles.plusBodyCell}`;
|
||||
}
|
||||
}
|
||||
return `${styles.freeCell} ${styles.freeBodyCell}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<td
|
||||
{...mergeProps(gridCellProps, focusProps)}
|
||||
|
@ -912,11 +982,7 @@ const TableCell = (props: {
|
|||
// have any tests for it either:
|
||||
/* c8 ignore next */
|
||||
isFocusVisible ? styles.isFocused : styles.isBlurred
|
||||
} ${
|
||||
props.cell.column?.index === 1
|
||||
? `${styles.freeCell} ${styles.freeBodyCell}`
|
||||
: `${styles.plusCell} ${styles.plusBodyCell}`
|
||||
}`}
|
||||
} ${outlineStyle()}`}
|
||||
>
|
||||
<span className={styles.cellWrapper}>{props.cell.rendered}</span>
|
||||
</td>
|
||||
|
|
|
@ -0,0 +1,44 @@
|
|||
/* 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 styles from "./LandingView.module.scss";
|
||||
import { useL10n } from "../../../hooks/l10n";
|
||||
import { Button } from "../../../components/client/Button";
|
||||
import { useTelemetry } from "../../../hooks/useTelemetry";
|
||||
|
||||
export const ScanLimit = () => {
|
||||
const l10n = useL10n();
|
||||
|
||||
return (
|
||||
<div className={styles.scanLimitWrapper}>
|
||||
<div className={styles.info}>
|
||||
<b>{l10n.getString("landing-premium-max-scan-at-capacity")}</b>
|
||||
<p>{l10n.getString("landing-premium-max-scan")}</p>
|
||||
</div>
|
||||
<WaitlistCta />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const WaitlistCta = () => {
|
||||
const l10n = useL10n();
|
||||
const record = useTelemetry();
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={styles.waitlistCta}
|
||||
variant="primary"
|
||||
href={process.env.NEXT_PUBLIC_WAITLIST_URL}
|
||||
onPress={() => {
|
||||
record("ctaButton", "click", {
|
||||
button_id: "intent_to_join_waitlist_header",
|
||||
});
|
||||
}}
|
||||
>
|
||||
{l10n.getString("landing-premium-max-scan-waitlist")}
|
||||
</Button>
|
||||
);
|
||||
};
|
|
@ -11,6 +11,7 @@ import { Button } from "../../../components/client/Button";
|
|||
import styles from "./SignUpForm.module.scss";
|
||||
import { useTelemetry } from "../../../hooks/useTelemetry";
|
||||
import { VisuallyHidden } from "../../../components/server/VisuallyHidden";
|
||||
import { WaitlistCta } from "./ScanLimit";
|
||||
|
||||
export type Props = {
|
||||
eligibleForPremium: boolean;
|
||||
|
@ -18,8 +19,9 @@ export type Props = {
|
|||
isHero?: boolean;
|
||||
eventId: {
|
||||
cta: string;
|
||||
field: string;
|
||||
field?: string;
|
||||
};
|
||||
scanLimitReached: boolean;
|
||||
};
|
||||
|
||||
export const SignUpForm = (props: Props) => {
|
||||
|
@ -53,7 +55,9 @@ export const SignUpForm = (props: Props) => {
|
|||
</label>
|
||||
);
|
||||
|
||||
return (
|
||||
return props.scanLimitReached ? (
|
||||
<WaitlistCta />
|
||||
) : (
|
||||
<form className={styles.form} onSubmit={onSubmit}>
|
||||
<input
|
||||
className={props.isHero ? styles.isHero : ""}
|
||||
|
|
|
@ -4,7 +4,11 @@
|
|||
|
||||
import { headers } from "next/headers";
|
||||
import { getCountryCode } from "../../../functions/server/getCountryCode";
|
||||
import { isEligibleForPremium } from "../../../functions/server/onerep";
|
||||
import {
|
||||
isEligibleForPremium,
|
||||
getProfilesStats,
|
||||
monthlySubscribersQuota,
|
||||
} from "../../../functions/server/onerep";
|
||||
import { getEnabledFeatureFlags } from "../../../../db/tables/featureFlags";
|
||||
import { getL10n } from "../../../functions/server/l10n";
|
||||
import { View } from "./LandingView";
|
||||
|
@ -14,11 +18,20 @@ export default async function Page() {
|
|||
const countryCode = getCountryCode(headers());
|
||||
const eligibleForPremium = isEligibleForPremium(countryCode, enabledFlags);
|
||||
|
||||
// request the profile stats for the last 30 days
|
||||
const profileStats = await getProfilesStats(
|
||||
new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
|
||||
);
|
||||
const oneRepActivations = profileStats?.total_active;
|
||||
const scanLimitReached =
|
||||
typeof oneRepActivations === "undefined" ||
|
||||
oneRepActivations > monthlySubscribersQuota;
|
||||
return (
|
||||
<View
|
||||
eligibleForPremium={eligibleForPremium}
|
||||
l10n={getL10n()}
|
||||
countryCode={countryCode}
|
||||
scanLimitReached={scanLimitReached}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -5,20 +5,16 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getScansCount } from "../../../../db/tables/onerep_scans";
|
||||
import { bearerToken } from "../../utils/auth";
|
||||
|
||||
import {
|
||||
monthlyScansQuota,
|
||||
monthlySubscribersQuota,
|
||||
} from "../../../functions/server/onerep";
|
||||
export async function GET(req: NextRequest) {
|
||||
const headerToken = bearerToken(req);
|
||||
if (headerToken !== process.env.STATS_TOKEN) {
|
||||
return NextResponse.json({ success: "false" }, { status: 401 });
|
||||
}
|
||||
|
||||
const monthlyScanQuota = parseInt(
|
||||
(process.env.MONTHLY_SCANS_QUOTA as string) || "0",
|
||||
);
|
||||
const monthlySubscriberQuota = parseInt(
|
||||
(process.env.MONTHLY_SUBSCRIBERS_QUOTA as string) || "0",
|
||||
);
|
||||
|
||||
const now = new Date();
|
||||
const firstDayOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
|
||||
|
@ -42,11 +38,11 @@ export async function GET(req: NextRequest) {
|
|||
|
||||
const message = {
|
||||
scans: {
|
||||
quota: monthlyScanQuota,
|
||||
quota: monthlyScansQuota,
|
||||
count: parseInt(manualScansCount),
|
||||
},
|
||||
subscribers: {
|
||||
quota: monthlySubscriberQuota,
|
||||
quota: monthlySubscribersQuota,
|
||||
count: parseInt(initialScansCount),
|
||||
},
|
||||
};
|
||||
|
|
|
@ -73,7 +73,9 @@
|
|||
}
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.3;
|
||||
color: $color-grey-20;
|
||||
background: transparent;
|
||||
box-shadow: inset 0 0 0 2px $color-grey-20;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -20,6 +20,13 @@ import {
|
|||
} from "../../../db/tables/featureFlags";
|
||||
import { logger } from "./logging";
|
||||
|
||||
export const monthlyScansQuota = parseInt(
|
||||
(process.env.MONTHLY_SCANS_QUOTA as string) ?? "0",
|
||||
);
|
||||
export const monthlySubscribersQuota = parseInt(
|
||||
(process.env.MONTHLY_SUBSCRIBERS_QUOTA as string) ?? "0",
|
||||
);
|
||||
|
||||
export type CreateProfileRequest = {
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
|
|
Загрузка…
Ссылка в новой задаче