diff --git a/apps/web/public/locales/en/app.json b/apps/web/public/locales/en/app.json
index ab25177a5..548cd7c14 100644
--- a/apps/web/public/locales/en/app.json
+++ b/apps/web/public/locales/en/app.json
@@ -125,7 +125,7 @@
"specifyTimesDescription": "Include start and end times for each option",
"stepSummary": "Step {{current}} of {{total}}",
"sunday": "Sunday",
- "redirect": "Click here if you are not redirect automatically…",
+ "redirect": "Click here if you are not redirected automatically…",
"timeFormat": "Time format:",
"timeZone": "Time Zone:",
"title": "Title",
@@ -137,6 +137,8 @@
"user": "User",
"userAlreadyExists": "A user with that email already exists",
"userNotFound": "A user with that email doesn't exist",
+ "loginSuccessful": "You're logged in! Please wait while you are redirected…",
+ "userDoesNotExist": "The requested user was not found",
"verificationCodeHelp": "Didn't get the email? Check your spam/junk.",
"verificationCodePlaceholder": "Enter your 6-digit code",
"verificationCodeSent": "A verification code has been sent to {{email}} Change",
diff --git a/apps/web/src/components/auth/login-form.tsx b/apps/web/src/components/auth/login-form.tsx
index f4ac55991..2977a7279 100644
--- a/apps/web/src/components/auth/login-form.tsx
+++ b/apps/web/src/components/auth/login-form.tsx
@@ -276,6 +276,7 @@ export const LoginForm: React.FunctionComponent<{
const { register, handleSubmit, getValues, formState, setError } = useForm<{
email: string;
}>();
+
const requestLogin = trpc.auth.requestLogin.useMutation();
const authenticateLogin = trpc.auth.authenticateLogin.useMutation();
diff --git a/apps/web/src/pages/auth/login.tsx b/apps/web/src/pages/auth/login.tsx
new file mode 100644
index 000000000..163f8befd
--- /dev/null
+++ b/apps/web/src/pages/auth/login.tsx
@@ -0,0 +1,138 @@
+import { prisma } from "@rallly/database";
+import clsx from "clsx";
+import { GetServerSideProps } from "next";
+import Link from "next/link";
+import { useRouter } from "next/router";
+import { Trans, useTranslation } from "next-i18next";
+import React from "react";
+
+import CheckCircle from "@/components/icons/check-circle.svg";
+import { AuthLayout } from "@/components/layouts/auth-layout";
+import { Spinner } from "@/components/spinner";
+import {
+ composeGetServerSideProps,
+ decryptToken,
+ LoginTokenPayload,
+ withSessionSsr,
+} from "@/utils/auth";
+import { withPageTranslations } from "@/utils/with-page-translations";
+
+const defaultRedirectPath = "/profile";
+
+const redirectToInvalidToken = {
+ redirect: {
+ destination: "/auth/invalid-token",
+ permanent: false,
+ },
+};
+
+const Redirect = () => {
+ const { t } = useTranslation("app");
+ const [enabled, setEnabled] = React.useState(false);
+ const router = useRouter();
+
+ React.useEffect(() => {
+ setTimeout(() => {
+ setEnabled(true);
+ }, 500);
+ setTimeout(() => {
+ router.replace(defaultRedirectPath);
+ }, 3000);
+ }, [router]);
+
+ return (
+
+
+ {enabled ? (
+
+ ) : (
+
+ )}
+
+
{t("loginSuccessful")}
+
+ ,
+ }}
+ />
+
+
+ );
+};
+export const Page = (
+ props:
+ | {
+ success: true;
+ name: string;
+ }
+ | {
+ success: false;
+ errorCode: "userNotFound";
+ },
+) => {
+ const { t } = useTranslation("app");
+ return (
+
+ {props.success ? (
+
+ ) : (
+
+ )}
+
+ );
+};
+
+export default Page;
+
+export const getServerSideProps: GetServerSideProps = composeGetServerSideProps(
+ withPageTranslations(["app"]),
+ withSessionSsr(async (ctx) => {
+ const token = ctx.query.token as string;
+
+ if (!token) {
+ // token is missing
+ return redirectToInvalidToken;
+ }
+
+ const payload = await decryptToken(token);
+
+ if (!payload) {
+ // token is invalid or expired
+ return redirectToInvalidToken;
+ }
+
+ const user = await prisma.user.findFirst({
+ select: {
+ id: true,
+ },
+ where: { id: payload.userId },
+ });
+
+ if (!user) {
+ // user does not exist
+ return {
+ props: {
+ success: false,
+ errorCode: "userNotFound",
+ },
+ };
+ }
+
+ ctx.req.session.user = { id: user.id, isGuest: false };
+
+ await ctx.req.session.save();
+
+ return {
+ props: {
+ success: true,
+ },
+ };
+ }),
+);
diff --git a/apps/web/src/server/routers/auth.ts b/apps/web/src/server/routers/auth.ts
index 09b054513..7131f90fd 100644
--- a/apps/web/src/server/routers/auth.ts
+++ b/apps/web/src/server/routers/auth.ts
@@ -3,6 +3,8 @@ import { sendEmail } from "@rallly/emails";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
+import { absoluteUrl } from "@/utils/absolute-url";
+
import {
createToken,
decryptToken,
@@ -130,6 +132,7 @@ export const auth = router({
props: {
name: user.name,
code,
+ magicLink: absoluteUrl(`/auth/login?token=${token}`),
},
});
diff --git a/apps/web/tests/authentication.spec.ts b/apps/web/tests/authentication.spec.ts
index df9ed5154..03b858fbd 100644
--- a/apps/web/tests/authentication.spec.ts
+++ b/apps/web/tests/authentication.spec.ts
@@ -1,20 +1,32 @@
-import { expect, Page, test } from "@playwright/test";
+import { expect, test } from "@playwright/test";
import { prisma } from "@rallly/database";
import { load } from "cheerio";
import smtpTester from "smtp-tester";
const testUserEmail = "test@example.com";
+let mailServer: smtpTester.SmtpTester;
+/**
+ * Get the 6-digit code from the email
+ * @returns 6-digit code
+ */
+const getCode = async () => {
+ const { email } = await mailServer.captureOne(testUserEmail, {
+ wait: 5000,
+ });
+
+ const $ = load(email.html);
+
+ return $("#code").text().trim();
+};
test.describe.serial(() => {
- let mailServer: smtpTester.SmtpTester;
-
test.beforeAll(() => {
mailServer = smtpTester.init(4025);
});
test.afterAll(async () => {
try {
- await prisma.user.delete({
+ await prisma.user.deleteMany({
where: {
email: testUserEmail,
},
@@ -25,93 +37,107 @@ test.describe.serial(() => {
mailServer.stop();
});
- /**
- * Get the 6-digit code from the email
- * @returns 6-digit code
- */
- const getCode = async () => {
- const { email } = await mailServer.captureOne(testUserEmail, {
- wait: 5000,
+ test.describe("new user", () => {
+ test("shows that user doesn't exist yet", async ({ page }) => {
+ await page.goto("/login");
+
+ // your login page test logic
+ await page.getByPlaceholder("jessie.smith@email.com").type(testUserEmail);
+
+ await page.getByText("Continue").click();
+
+ // Make sure the user doesn't exist yet and that logging in is not possible
+ await expect(
+ page.getByText("A user with that email doesn't exist"),
+ ).toBeVisible();
});
- const $ = load(email.html);
+ test("user registration", async ({ page }) => {
+ await page.goto("/register");
- return $("#code").text().trim();
- };
+ await page.getByText("Create an account").waitFor();
- test("shows that user doesn't exist yet", async ({ page }) => {
- await page.goto("/login");
+ await page.getByPlaceholder("Jessie Smith").type("Test User");
+ await page.getByPlaceholder("jessie.smith@email.com").type(testUserEmail);
- // your login page test logic
- await page.getByPlaceholder("jessie.smith@email.com").type(testUserEmail);
+ await page.click("text=Continue");
- await page.getByText("Continue").click();
+ const codeInput = page.getByPlaceholder("Enter your 6-digit code");
- // Make sure the user doesn't exist yet and that logging in is not possible
- await expect(
- page.getByText("A user with that email doesn't exist"),
- ).toBeVisible();
+ codeInput.waitFor({ state: "visible" });
+
+ const code = await getCode();
+
+ await codeInput.type(code);
+
+ await page.getByText("Continue").click();
+
+ await expect(page.getByText("Your details")).toBeVisible();
+ });
});
- test("user registration", async ({ page }) => {
- await page.goto("/register");
+ test.describe("existing user", () => {
+ test("can't register with the same email", async ({ page }) => {
+ await page.goto("/register");
- await page.getByText("Create an account").waitFor();
+ await page.getByText("Create an account").waitFor();
- await page.getByPlaceholder("Jessie Smith").type("Test User");
- await page.getByPlaceholder("jessie.smith@email.com").type(testUserEmail);
+ await page.getByPlaceholder("Jessie Smith").type("Test User");
+ await page.getByPlaceholder("jessie.smith@email.com").type(testUserEmail);
- await page.click("text=Continue");
+ await page.click("text=Continue");
- const codeInput = page.getByPlaceholder("Enter your 6-digit code");
+ await expect(
+ page.getByText("A user with that email already exists"),
+ ).toBeVisible();
+ });
- codeInput.waitFor({ state: "visible" });
+ test.describe("login", () => {
+ test.afterEach(async ({ page }) => {
+ await page.goto("/logout");
+ });
+ });
- const code = await getCode();
+ test("can login with magic link", async ({ page }) => {
+ await page.goto("/login");
- await codeInput.type(code);
+ await page.getByPlaceholder("jessie.smith@email.com").type(testUserEmail);
- await page.getByText("Continue").click();
+ await page.getByText("Continue").click();
- await expect(page.getByText("Your details")).toBeVisible();
- });
+ const { email } = await mailServer.captureOne(testUserEmail, {
+ wait: 5000,
+ });
- test("can't register with the same email", async ({ page }) => {
- await page.goto("/register");
+ const $ = load(email.html);
- await page.getByText("Create an account").waitFor();
+ const magicLink = $("#magicLink").attr("href");
- await page.getByPlaceholder("Jessie Smith").type("Test User");
- await page.getByPlaceholder("jessie.smith@email.com").type(testUserEmail);
+ if (!magicLink) {
+ throw new Error("Magic link not found");
+ }
- await page.click("text=Continue");
+ await page.goto(magicLink);
- await expect(
- page.getByText("A user with that email already exists"),
- ).toBeVisible();
- });
+ page.getByText("Click here").click();
- const login = async (page: Page) => {
- await page.goto("/login");
+ await expect(page.getByText("Your details")).toBeVisible();
+ });
- await page.getByPlaceholder("jessie.smith@email.com").type(testUserEmail);
+ test("can login with verification code", async ({ page }) => {
+ await page.goto("/login");
- await page.getByText("Continue").click();
+ await page.getByPlaceholder("jessie.smith@email.com").type(testUserEmail);
- const code = await getCode();
+ await page.getByText("Continue").click();
- await page.getByPlaceholder("Enter your 6-digit code").type(code);
+ const code = await getCode();
- await page.getByText("Continue").click();
- };
+ await page.getByPlaceholder("Enter your 6-digit code").type(code);
- test("user login", async ({ page }) => {
- await login(page);
- await expect(page.getByText("Your details")).toBeVisible();
- });
+ await page.getByText("Continue").click();
- test("logged in user can't access login page", async ({ page }) => {
- await login(page);
- await expect(page).toHaveURL("/profile");
+ await expect(page.getByText("Your details")).toBeVisible();
+ });
});
});
diff --git a/packages/emails/src/templates/components/email-layout.tsx b/packages/emails/src/templates/components/email-layout.tsx
index a76252558..78a5e7e93 100644
--- a/packages/emails/src/templates/components/email-layout.tsx
+++ b/packages/emails/src/templates/components/email-layout.tsx
@@ -27,6 +27,7 @@ export const EmailLayout = ({
children,
footNote,
}: React.PropsWithChildren) => {
+ const firstName = recipientName.split(" ")[0];
return (
@@ -127,12 +128,12 @@ export const EmailLayout = ({
}}
>
-
-
+
+
- Hi {recipientName},
+ Hi {firstName},
{children}
{footNote ? (
<>
diff --git a/packages/emails/src/templates/components/notification-email.tsx b/packages/emails/src/templates/components/notification-email.tsx
index 4ed01b698..6c80699f7 100644
--- a/packages/emails/src/templates/components/notification-email.tsx
+++ b/packages/emails/src/templates/components/notification-email.tsx
@@ -1,5 +1,6 @@
import { EmailLayout } from "./email-layout";
-import { Link, Section } from "./styled-components";
+import { Button, Card, Link, Text } from "./styled-components";
+import { getDomain } from "./utils";
export interface NotificationBaseProps {
name: string;
@@ -14,7 +15,6 @@ export interface NotificationEmailProps extends NotificationBaseProps {
export const NotificationEmail = ({
name,
- title,
pollUrl,
unsubscribeUrl,
preview,
@@ -25,21 +25,18 @@ export const NotificationEmail = ({
recipientName={name}
footNote={
<>
- You're receiving this email because notifications are enabled for{" "}
- {title}. If you want to stop receiving emails about
- this event you can{" "}
+ If you would like to stop receiving updates you can{" "}
turn notifications off
- .
>
}
preview={preview}
>
{children}
-
+
+
+
);
};
diff --git a/packages/emails/src/templates/components/styled-components.tsx b/packages/emails/src/templates/components/styled-components.tsx
index 613d4efaf..5f7760f93 100644
--- a/packages/emails/src/templates/components/styled-components.tsx
+++ b/packages/emails/src/templates/components/styled-components.tsx
@@ -1,3 +1,4 @@
+import { absoluteUrl } from "@rallly/utils";
import {
Button as UnstyledButton,
ButtonProps,
@@ -11,18 +12,29 @@ import {
} from "@react-email/components";
import clsx from "clsx";
-export const Text = (props: TextProps) => {
+import { getDomain } from "./utils";
+
+export const Text = (
+ props: TextProps & { light?: boolean; small?: boolean },
+) => {
+ const { light, small, className, ...forwardProps } = props;
return (
);
};
+export const Domain = () => {
+ return {getDomain()};
+};
+
export const Button = (props: ButtonProps) => {
return (
{
export const Heading = (
props: React.ComponentProps,
) => {
+ const { as = "h3" } = props;
return (
+ );
+};
+
+export const SubHeadingText = (props: TextProps) => {
+ const { className, ...forwardProps } = props;
+ return (
+
);
};
export const Section = (props: SectionProps) => {
+ const { className, ...forwardProps } = props;
return (
-
+
);
};
@@ -70,3 +100,12 @@ export const SmallText = (props: TextProps) => {
/>
);
};
+
+export const Card = (props: SectionProps) => {
+ return (
+
+ );
+};
diff --git a/packages/emails/src/templates/components/utils.ts b/packages/emails/src/templates/components/utils.ts
index 0198f1275..0ceb1bbfd 100644
--- a/packages/emails/src/templates/components/utils.ts
+++ b/packages/emails/src/templates/components/utils.ts
@@ -1,3 +1,7 @@
+import { absoluteUrl } from "@rallly/utils";
+
export const removeProtocalFromUrl = (url: string) => {
return url.replace(/(^\w+:|^)\/\//, "");
};
+
+export const getDomain = () => removeProtocalFromUrl(absoluteUrl());
diff --git a/packages/emails/src/templates/login.tsx b/packages/emails/src/templates/login.tsx
index 09a855fbf..abfe4a24f 100644
--- a/packages/emails/src/templates/login.tsx
+++ b/packages/emails/src/templates/login.tsx
@@ -1,20 +1,27 @@
import { absoluteUrl } from "@rallly/utils";
+import { Hr } from "@react-email/components";
import { EmailLayout } from "./components/email-layout";
-import { Heading, Link, Text } from "./components/styled-components";
-import { removeProtocalFromUrl } from "./components/utils";
+import {
+ Button,
+ Heading,
+ Link,
+ Section,
+ Text,
+} from "./components/styled-components";
+import { getDomain, removeProtocalFromUrl } from "./components/utils";
interface LoginEmailProps {
name: string;
code: string;
- // magicLink: string;
+ magicLink: string;
}
export const LoginEmail = ({
name = "Guest",
code = "123456",
-}: // magicLink = "https://rallly.co",
-LoginEmailProps) => {
+ magicLink = "https://rallly.co",
+}: LoginEmailProps) => {
return (
{
recipientName={name}
preview={`Your 6-digit code: ${code}`}
>
- Your 6-digit code is:
-
- {code}
-
-
- Use this code to complete the verification process on{" "}
- {removeProtocalFromUrl(absoluteUrl())}
-
-
-
- This code is valid for 15 minutes
-
-
- {/* Magic link
-
- Alternatively, you can login by using this{" "}
- magic link ✨
- */}
+ Use this link to log in on this device.
+
+ This link is valid for 15 minutes
+
+
+ Alternatively, you can enter this 6-digit verification code directly.
+
+
+ {code}
+
+
);
};
diff --git a/packages/emails/src/templates/new-participant-confirmation.tsx b/packages/emails/src/templates/new-participant-confirmation.tsx
index 0d4cc22a3..576d78420 100644
--- a/packages/emails/src/templates/new-participant-confirmation.tsx
+++ b/packages/emails/src/templates/new-participant-confirmation.tsx
@@ -1,5 +1,13 @@
import { EmailLayout } from "./components/email-layout";
-import { Button, Section, Text } from "./components/styled-components";
+import {
+ Button,
+ Card,
+ Domain,
+ Heading,
+ Section,
+ Text,
+} from "./components/styled-components";
+import { getDomain } from "./components/utils";
interface NewParticipantConfirmationEmailProps {
name: string;
@@ -8,31 +16,32 @@ interface NewParticipantConfirmationEmailProps {
}
export const NewParticipantConfirmationEmail = ({
title = "Untitled Poll",
- name = "Guest",
+ name = "John",
editSubmissionUrl = "https://rallly.co",
}: NewParticipantConfirmationEmailProps) => {
return (
You are receiving this email because a response was submitting >
+ <>
+ You are receiving this email because a response was submitted on{" "}
+ . If this wasn't you, please ignore this email.
+ >
}
recipientName={name}
preview="To edit your response use the link below"
>
- Thank you for submitting your availability for {title}.
+ Thank you for responding to {title}.
+
+
+ While the poll is still open you can change your response using the link
+ below.
- To review your response, use the link below:
-
-
- Keep this link safe and do not share it with others.
-
-
);
};
diff --git a/packages/emails/src/templates/new-participant.tsx b/packages/emails/src/templates/new-participant.tsx
index 7fb734521..06d2a5e48 100644
--- a/packages/emails/src/templates/new-participant.tsx
+++ b/packages/emails/src/templates/new-participant.tsx
@@ -8,7 +8,7 @@ export interface NewParticipantEmailProps extends NotificationBaseProps {
}
export const NewParticipantEmail = ({
- name = "Guest",
+ name = "John",
title = "Untitled Poll",
participantName = "Someone",
pollUrl = "https://rallly.co",
diff --git a/packages/emails/src/templates/new-poll.tsx b/packages/emails/src/templates/new-poll.tsx
index 2939d31dd..d68929761 100644
--- a/packages/emails/src/templates/new-poll.tsx
+++ b/packages/emails/src/templates/new-poll.tsx
@@ -1,8 +1,16 @@
-import { absoluteUrl } from "@rallly/utils";
+import { absoluteUrl, preventWidows } from "@rallly/utils";
import { EmailLayout } from "./components/email-layout";
-import { Heading, Link, Section, Text } from "./components/styled-components";
-import { removeProtocalFromUrl } from "./components/utils";
+import {
+ Button,
+ Card,
+ Heading,
+ Link,
+ Section,
+ SubHeadingText,
+ Text,
+} from "./components/styled-components";
+import { getDomain } from "./components/utils";
export interface NewPollEmailProps {
title: string;
@@ -22,7 +30,7 @@ const ShareLink = ({
participantLink: string;
}>) => {
return (
-
{children}
-
- );
-};
-
-const LinkContainer = (props: { link: string }) => {
- return (
-
+
);
};
export const NewPollEmail = ({
title = "Untitled Poll",
- name = "Guest",
+ name = "John",
adminLink = "https://rallly.co/admin/abcdefg123",
participantLink = "https://rallly.co/p/wxyz9876",
}: NewPollEmailProps) => {
@@ -55,39 +53,51 @@ export const NewPollEmail = ({
footNote={
<>
You are receiving this email because a new poll was created with this
- email address on{" "}
-
- {removeProtocalFromUrl(absoluteUrl())}
-
- . If this wasn't you, please ignore this email.
+ email address on {getDomain()}. If
+ this wasn't you, please ignore this email.
>
}
recipientName={name}
preview="Share your participant link to start collecting responses."
>
- Your new poll is ready! Now lets find a date for{" "}
- {title}.
-
-
- Copy this link and share it with your participants to start collecting
- responses.
-
-
-
-
- Share via email →
-
-
- Your secret link
-
- Use this link to access the admin page where you can view and edit your
- poll.
-
-
-
- Go to admin page →
+ Your poll is live! Here are two links you will need to manage your poll.
+
+ Admin link
+
+ Use this link to view results and make changes to your poll.
+
+
+
+ {adminLink}
+
+
+
+
+
+
+
+ Participant link
+
+ Copy this link and share it with your participants to start collecting
+ responses.
+
+
+
+ {participantLink}
+
+
+
+
+ Share via email
+
+
+
);
};
diff --git a/packages/emails/src/templates/register.tsx b/packages/emails/src/templates/register.tsx
index 4edf63411..cd2f6f1c9 100644
--- a/packages/emails/src/templates/register.tsx
+++ b/packages/emails/src/templates/register.tsx
@@ -1,9 +1,8 @@
import { absoluteUrl } from "@rallly/utils";
-import { Heading } from "@react-email/heading";
import { EmailLayout } from "./components/email-layout";
-import { Link, Text } from "./components/styled-components";
-import { removeProtocalFromUrl } from "./components/utils";
+import { Domain, Heading, Link, Text } from "./components/styled-components";
+import { getDomain } from "./components/utils";
interface RegisterEmailProps {
name: string;
@@ -11,7 +10,7 @@ interface RegisterEmailProps {
}
export const RegisterEmail = ({
- name = "Guest",
+ name = "John",
code = "123456",
}: RegisterEmailProps) => {
return (
@@ -21,25 +20,22 @@ export const RegisterEmail = ({
You're receiving this email because a request was made to
register an account on{" "}
- {removeProtocalFromUrl(absoluteUrl())}
+ {getDomain()}
- .
+ . If this wasn't you, please ignore this email.
>
}
recipientName={name}
preview={`Your 6-digit code is: ${code}`}
>
- Your 6-digit code is:
-
+
+ Use this code to complete the verification process on
+
+ Your 6-digit code is:
+
{code}
-
- Use this code to complete the verification process on{" "}
- {removeProtocalFromUrl(absoluteUrl())}
-
-
- This code is valid for 15 minutes
-
+ This code is valid for 15 minutes
);
};
diff --git a/packages/emails/src/templates/turn-on-notifications.tsx b/packages/emails/src/templates/turn-on-notifications.tsx
index 4a2d1f419..551467f98 100644
--- a/packages/emails/src/templates/turn-on-notifications.tsx
+++ b/packages/emails/src/templates/turn-on-notifications.tsx
@@ -1,6 +1,8 @@
import { EmailLayout } from "./components/email-layout";
import {
Button,
+ Card,
+ Heading,
Link,
Section,
SmallText,
@@ -16,14 +18,14 @@ type EnableNotificationsEmailProps = {
export const EnableNotificationsEmail = ({
title = "Untitled Poll",
- name = "Guest",
+ name = "John",
verificationLink = "https://rallly.co",
adminLink = "https://rallly.co",
}: EnableNotificationsEmailProps) => {
return (
You are receiving this email because a request was made to enable
@@ -32,18 +34,19 @@ export const EnableNotificationsEmail = ({
}
>
- Before we can send you notifications we need to verify your email.
+ Would you like to get notified when participants respond to{" "}
+ {title}?
-
- Click the button below to complete the email verification and enable
- notifications for {title}.
-
-
-
-
- The link will expire in 15 minutes.
+
+ Enable notifications
+ You will get an email when someone responds to the poll.
+
+
+
+ The link will expire in 15 minutes.
+
);
};
diff --git a/packages/utils/index.ts b/packages/utils/index.ts
index b58c8fd52..e7fffef94 100644
--- a/packages/utils/index.ts
+++ b/packages/utils/index.ts
@@ -1 +1,2 @@
export * from "./src/absolute-url";
+export * from "./src/prevent-widows";
diff --git a/packages/utils/src/prevent-widows.ts b/packages/utils/src/prevent-widows.ts
new file mode 100644
index 000000000..e765c8698
--- /dev/null
+++ b/packages/utils/src/prevent-widows.ts
@@ -0,0 +1,7 @@
+export function preventWidows(text = "") {
+ if (text.split(" ").length < 3) {
+ return text;
+ }
+ const index = text.lastIndexOf(" ");
+ return [text.substring(0, index), text.substring(index + 1)].join("\u00a0");
+}