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:
Kaitlyn Andres 2024-01-05 11:41:35 -05:00 коммит произвёл GitHub
Родитель 0919bcbe72
Коммит e2ae06855d
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
13 изменённых файлов: 359 добавлений и 89 удалений

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

@ -132,3 +132,5 @@ landing-premium-continuous-data-removal-ans = { $data_broker_sites_total_num ->
landing-premium-max-scan = Weve 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 = Weve 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(
"Weve 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;