updates
All checks were successful
Deploy / deploy (ubuntu-latest, 2.44.0) (push) Successful in 1m39s

This commit is contained in:
Braydon 2024-09-18 19:17:48 -04:00
parent 015a1fa9de
commit 7bb85dc2ca
18 changed files with 207 additions and 114 deletions

BIN
bun.lockb

Binary file not shown.

@ -31,7 +31,6 @@
"react": "^18", "react": "^18",
"react-dom": "^18", "react-dom": "^18",
"react-hook-form": "^7.53.0", "react-hook-form": "^7.53.0",
"react-social-icons": "^6.18.0",
"react-turnstile": "^1.1.3", "react-turnstile": "^1.1.3",
"sharp": "^0.33.5", "sharp": "^0.33.5",
"sonner": "^1.5.0", "sonner": "^1.5.0",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

@ -0,0 +1,5 @@
<svg width="98" height="96" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z"
fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 986 B

@ -0,0 +1,16 @@
<svg xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="64"
height="64">
<defs>
<path id="A"
d="M44.5 20H24v8.5h11.8C34.7 33.9 30.1 37 24 37c-7.2 0-13-5.8-13-13s5.8-13 13-13c3.1 0 5.9 1.1 8.1 2.9l6.4-6.4C34.6 4.1 29.6 2 24 2 11.8 2 2 11.8 2 24s9.8 22 22 22c11 0 21-8 21-22 0-1.3-.2-2.7-.5-4z"/>
</defs>
<clipPath id="B">
<use xlink:href="#A"/>
</clipPath>
<g transform="matrix(.727273 0 0 .727273 -.954545 -1.45455)">
<path d="M0 37V11l17 13z" clip-path="url(#B)" fill="#fbbc05"/>
<path d="M0 11l17 13 7-6.1L48 14V0H0z" clip-path="url(#B)" fill="#ea4335"/>
<path d="M0 37l30-23 7.9 1L48 0v48H0z" clip-path="url(#B)" fill="#34a853"/>
<path d="M48 48L17 24l-4-3 35-10z" clip-path="url(#B)" fill="#4285f4"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 861 B

@ -6,7 +6,6 @@ import { Separator } from "@/components/ui/separator";
import AuthForm from "@/components/auth/auth-form"; import AuthForm from "@/components/auth/auth-form";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import Greeting from "@/components/auth/greeting"; import Greeting from "@/components/auth/greeting";
import Footer from "@/components/auth/footer";
/** /**
* The page to authenticate with. * The page to authenticate with.
@ -29,7 +28,6 @@ const AuthPage = (): ReactElement => (
<Separator className="w-28" /> <Separator className="w-28" />
</div> </div>
<AuthForm /> <AuthForm />
<Footer />
</motion.div> </motion.div>
</main> </main>
); );
@ -40,7 +38,7 @@ const AuthPage = (): ReactElement => (
* @return the providers jsx * @return the providers jsx
*/ */
const OAuthProviders = (): ReactElement => ( const OAuthProviders = (): ReactElement => (
<div className="mt-1 flex gap-2.5"> <div className="mt-1 flex gap-2.5 justify-center">
<OAuthProvider name="GitHub" link="#" /> <OAuthProvider name="GitHub" link="#" />
<OAuthProvider name="Google" link="#" /> <OAuthProvider name="Google" link="#" />
</div> </div>

@ -14,7 +14,10 @@ const inter: NextFont = Inter({ subsets: ["latin"] });
* The metadata for this app. * The metadata for this app.
*/ */
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Pulse App", title: {
default: "Pulse App",
template: "%s • Pulse App",
},
description: description:
"A lightweight service monitoring solution for tracking the availability of whatever service your heart desires!", "A lightweight service monitoring solution for tracking the availability of whatever service your heart desires!",
openGraph: { openGraph: {
@ -31,7 +34,7 @@ export const metadata: Metadata = {
}, },
}; };
export const viewport: Viewport = { export const viewport: Viewport = {
themeColor: "#DC2626", themeColor: "#A855F7",
}; };
/** /**
@ -48,7 +51,7 @@ const RootLayout = ({
<div <div
style={{ style={{
background: background:
"linear-gradient(to top, hsla(240, 6%, 10%, 0.7), hsl(var(--background)))", "linear-gradient(to top, hsl(240, 6%, 10%), hsl(var(--background)))",
}} }}
> >
<CookiesProvider> <CookiesProvider>

@ -74,7 +74,7 @@ const UserProvider = ({ children }: { children: ReactNode }) => {
) { ) {
router.push("/dashboard/onboarding"); router.push("/dashboard/onboarding");
} }
}, [cookies, router]); }, [cookies, router, path]);
useEffect(() => { useEffect(() => {
fetchUser(); fetchUser();

@ -50,9 +50,9 @@ body {
--card-foreground: 0 0% 98%; --card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%; --popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%; --popover-foreground: 0 0% 98%;
--primary: 0 0% 98%; --primary: 271 91% 65%;
--primary-foreground: 240 5.9% 10%; --primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 15.9%; --secondary: 272 72% 47%;
--secondary-foreground: 0 0% 98%; --secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%; --muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%; --muted-foreground: 240 5% 64.9%;

@ -0,0 +1,17 @@
import { User } from "@/app/types/user/user";
import { Session } from "@/app/types/user/session";
/**
* The response for successfully logging in.
*/
export type UserAuthResponse = {
/**
* The created session for the user.
*/
session: Session;
/**
* The user logging in.
*/
user: User;
};

@ -14,13 +14,16 @@ import {
LockClosedIcon, LockClosedIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
import { apiRequest } from "@/lib/api"; import { apiRequest } from "@/lib/api";
import { Session } from "@/app/types/user/session";
import { Cookies, useCookies } from "next-client-cookies"; import { Cookies, useCookies } from "next-client-cookies";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime"; import { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import Turnstile, { useTurnstile } from "react-turnstile"; import Turnstile, { useTurnstile } from "react-turnstile";
import { TurnstileObject } from "turnstile-types"; import { TurnstileObject } from "turnstile-types";
import { UserAuthResponse } from "@/app/types/user/response/user-auth-response";
import { hasFlag } from "@/lib/user";
import { UserFlag } from "@/app/types/user/user-flag";
import { User } from "@/app/types/user/user";
/** /**
* Define the form schemas for the various stages. * Define the form schemas for the various stages.
@ -28,23 +31,30 @@ import { TurnstileObject } from "turnstile-types";
const buildEmailInput = (allowEmpty: boolean) => const buildEmailInput = (allowEmpty: boolean) =>
z z
.string() .string()
.email("Invalid email address") .email("That email address is invalid.")
.refine( .refine(
(val) => { (val) => {
return !allowEmpty || val.length > 0; return !allowEmpty || val.length > 0;
}, },
{ message: "Email is required" } { message: "An email address is required." }
); );
const EmailSchema = z.object({ const EmailSchema = z.object({
email: buildEmailInput(false), email: buildEmailInput(false),
}); });
const RegisterSchema = z.object({ const RegisterSchema = z
.object({
email: buildEmailInput(true), email: buildEmailInput(true),
username: z.string(), username: z
.string()
.regex(/^[a-z0-9_.]*$/, "That username is invalid."),
password: z.string(), password: z.string(),
passwordConfirmation: z.string(), passwordConfirmation: z.string(),
})
.refine((data) => data.password === data.passwordConfirmation, {
path: ["passwordConfirmation"],
message: "Your passwords do not match.",
}); });
const LoginSchema = z.object({ const LoginSchema = z.object({
@ -108,7 +118,7 @@ const AuthForm = (): ReactElement => {
setStage(data?.exists ? "login" : "register"); setStage(data?.exists ? "login" : "register");
} else { } else {
const registering: boolean = stage === "register"; const registering: boolean = stage === "register";
const { data, error } = await apiRequest<Session>({ const { data, error } = await apiRequest<UserAuthResponse>({
endpoint: `/auth/${registering ? "register" : "login"}`, endpoint: `/auth/${registering ? "register" : "login"}`,
method: "POST", method: "POST",
body: registering body: registering
@ -132,13 +142,18 @@ const AuthForm = (): ReactElement => {
turnstile.reset(); turnstile.reset();
} else { } else {
// Otherwise store the session and redirect to the dashboard // Otherwise store the session and redirect to the dashboard
cookies.set("session", JSON.stringify(data), { cookies.set("session", JSON.stringify(data?.session), {
expires: expires:
((data?.expires as number) - Date.now()) / 86_400_000, ((data?.session.expires as number) - Date.now()) /
86_400_000,
secure: true, secure: true,
sameSite: "lax", sameSite: "lax",
}); });
router.push("/dashboard"); router.push(
hasFlag(data?.user as User, UserFlag.COMPLETED_ONBOARDING)
? "/dashboard"
: "/dashboard/onboarding"
);
return; return;
} }
} }
@ -238,7 +253,7 @@ const AuthForm = (): ReactElement => {
{/* Submit Form */} {/* Submit Form */}
<Button <Button
className="h-11 flex gap-2.5 items-center bg-zinc-800/75 text-white border border-zinc-700/35 hover:bg-zinc-800/75 hover:opacity-75 transition-all transform-gpu group" className="h-11 flex gap-2.5 items-center text-white border border-secondary transition-all transform-gpu group"
type="submit" type="submit"
disabled={loading} disabled={loading}
> >

@ -1,28 +0,0 @@
import { ReactElement } from "react";
import Link from "next/link";
/**
* The auth footer.
*
* @return the footer jsx
*/
const Footer = (): ReactElement => (
<footer className="flex justify-center text-center">
<p className="max-w-[17rem] opacity-95 select-none">
By registering you agree to our{" "}
<DocumentLink name="Terms and Conditions" link="/legal/terms" /> and
our <DocumentLink name="Privacy Policy" link="/legal/privacy" />.
</p>
</footer>
);
const DocumentLink = ({ name, link }: { name: string; link: string }) => (
<Link
className="text-red-500 hover:opacity-85 transition-all transform-gpu"
href={link}
>
{name}
</Link>
);
export default Footer;

@ -1,7 +1,7 @@
import { ReactElement } from "react"; import { ReactElement } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { SocialIcon } from "react-social-icons";
import Link from "next/link"; import Link from "next/link";
import Image from "next/image";
/** /**
* The props for this oauth provider. * The props for this oauth provider.
@ -26,14 +26,14 @@ type OAuthProviderProps = {
const OAuthProvider = ({ name, link }: OAuthProviderProps): ReactElement => ( const OAuthProvider = ({ name, link }: OAuthProviderProps): ReactElement => (
<Link className="cursor-not-allowed" href={link}> <Link className="cursor-not-allowed" href={link}>
<Button <Button
className="h-12 bg-zinc-800/85 text-white border border-zinc-700/35 hover:bg-muted hover:opacity-75 transition-all transform-gpu" className="w-32 h-12 flex gap-2.5 bg-zinc-800/85 text-white border border-zinc-700/35 hover:bg-muted hover:opacity-75 transition-all transform-gpu"
disabled disabled
> >
<SocialIcon <Image
className="w-10 h-10" src={`/media/platforms/${name.toLowerCase()}.svg`}
as="div" alt={`${name}'s Logo`}
bgColor="transparent" width={24}
network={name.toLowerCase()} height={24}
/> />
<span>{name}</span> <span>{name}</span>
</Button> </Button>

@ -1,7 +1,11 @@
"use client"; "use client";
import { ReactElement, useState } from "react"; import { ReactElement, useState } from "react";
import { BriefcaseIcon, ClipboardIcon } from "@heroicons/react/24/outline"; import {
BriefcaseIcon,
ClipboardIcon,
LinkIcon,
} from "@heroicons/react/24/outline";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@ -21,21 +25,24 @@ import { User } from "@/app/types/user/user";
* Define the various stages of onboarding. * Define the various stages of onboarding.
*/ */
const organizationName = z.string().min(2, "You need a longer org name!!!"); const organizationName = z.string().min(2, "You need a longer org name!!!");
const organizationSlug = z.string().min(2, "You need a longer org slug!!!");
const stages: OnboardingStage[] = [ const stages: OnboardingStage[] = [
{ {
name: "Onboarding", name: "Create a new organization",
description: description:
"Welcome to Pulse App! To get started, first create your organization!", "First create your organization! Organizations are used to manage your status pages.",
schema: z.object({ schema: z.object({
organizationName, organizationName,
organizationSlug,
}), }),
}, },
{ {
name: "Status Page", name: "Create a new status page",
description: description:
"Next, create your status page and jump right into the app!", "Thanks! Next, create your status page and jump right into the app!",
schema: z.object({ schema: z.object({
organizationName, organizationName,
organizationSlug,
statusPageName: z statusPageName: z
.string() .string()
.min(2, "You need a longer status page name!!!"), .min(2, "You need a longer status page name!!!"),
@ -64,15 +71,28 @@ const OnboardingForm = (): ReactElement => {
const { const {
register, register,
handleSubmit, handleSubmit,
watch,
formState: { errors }, formState: { errors },
} = useForm({ } = useForm({
resolver: zodResolver(stage.schema), resolver: zodResolver(stage.schema),
}); });
const organizationName: string | undefined = watch(
"organizationName",
undefined
);
const defaultOrgSlug: string = `${user?.username}'s Cool Org`;
const organizationSlugPreview = buildOrgSlugPreview(
organizationName || defaultOrgSlug
);
/** /**
* Handle submitting the form. * Handle submitting the form.
*/ */
const onSubmit = async ({ organizationName, statusPageName }: any) => { const onSubmit = async ({
organizationName,
organizationSlug,
statusPageName,
}: any) => {
// Completed onboarding // Completed onboarding
if (stage === stages[stages.length - 1]) { if (stage === stages[stages.length - 1]) {
const { data, error } = await apiRequest<void>({ const { data, error } = await apiRequest<void>({
@ -81,6 +101,7 @@ const OnboardingForm = (): ReactElement => {
session, session,
body: { body: {
organizationName, organizationName,
organizationSlug,
statusPageName, statusPageName,
}, },
}); });
@ -103,45 +124,77 @@ const OnboardingForm = (): ReactElement => {
return ( return (
<motion.div <motion.div
key={stage.name} key={stage.name}
className="flex flex-col gap-3" className="w-96 flex flex-col gap-2.5"
initial={{ x: -50, opacity: 0 }} initial={{ x: -50, opacity: 0 }}
animate={{ x: 0, opacity: 1 }} animate={{ x: 0, opacity: 1 }}
transition={{ duration: 0.4 }} transition={{ duration: 0.4 }}
>
<form
className="flex flex-col gap-2"
onSubmit={handleSubmit(onSubmit)}
> >
{/* Header */} {/* Header */}
<div className="flex flex-col gap-1 select-none pointer-events-none"> <div className="flex flex-col gap-1 text-center items-center select-none pointer-events-none">
<h1 className="text-3xl font-bold">{stage.name}</h1> <h1 className="text-3xl font-bold">{stage.name}</h1>
<p className="max-w-[20rem] opacity-65"> <p className="max-w-[25rem] opacity-65">{stage.description}</p>
{stage.description}
</p>
</div> </div>
<form
className="flex flex-col gap-0.5"
onSubmit={handleSubmit(onSubmit)}
>
<div className="my-3 p-6 pb-3.5 flex flex-col gap-3.5 justify-center bg-zinc-900 rounded-lg">
{/* Organization Name */} {/* Organization Name */}
{stage === stages[0] && ( {stage === stages[0] && (
<div className="flex flex-col gap-1.5">
<p className="text-sm font-medium">
Organization Name
</p>
<div className="relative"> <div className="relative">
<BriefcaseIcon className="absolute left-2 top-[0.6rem] w-[1.15rem] h-[1.15rem]" /> <BriefcaseIcon className="absolute left-2 top-[0.6rem] w-[1.15rem] h-[1.15rem]" />
<Input <Input
className="pl-8 rounded-lg" className="pl-8 rounded-lg"
placeholder="Organization Name" defaultValue={defaultOrgSlug}
{...register("organizationName")} {...register("organizationName")}
/> />
</div> </div>
</div>
)}
{/* Organization Slug */}
{stage === stages[0] && (
<div className="flex flex-col gap-1.5">
<p className="text-sm font-medium">
Organization Slug
</p>
<div className="relative">
<div className="absolute left-2 top-[0.5rem] flex gap-1 items-center">
<LinkIcon className="w-[1.15rem] h-[1.15rem]" />
<p className="text-sm opacity-60">
pulseapp.cc/
</p>
</div>
<Input
className="pl-[7.25rem] rounded-lg"
placeholder={organizationSlugPreview}
defaultValue={organizationSlugPreview}
{...register("organizationSlug")}
/>
</div>
</div>
)} )}
{/* Status Page Name */} {/* Status Page Name */}
{stage === stages[1] && ( {stage === stages[1] && (
<div className="flex flex-col gap-1.5">
<p className="text-sm font-medium">
Status Page Name
</p>
<div className="relative"> <div className="relative">
<ClipboardIcon className="absolute left-2 top-[0.6rem] w-[1.15rem] h-[1.15rem]" /> <ClipboardIcon className="absolute left-2 top-[0.6rem] w-[1.15rem] h-[1.15rem]" />
<Input <Input
className="pl-8 rounded-lg" className="pl-8 rounded-lg"
placeholder="Status Page Name" defaultValue={`${user?.username}'s Status Page`}
{...register("statusPageName")} {...register("statusPageName")}
/> />
</div> </div>
</div>
)} )}
{/* Display the global error if it exists, otherwise show the first field error */} {/* Display the global error if it exists, otherwise show the first field error */}
@ -155,12 +208,14 @@ const OnboardingForm = (): ReactElement => {
.find((err: any) => err?.message) .find((err: any) => err?.message)
?.message?.toString()} ?.message?.toString()}
</p> </p>
</div>
{/* Back/Next Buttons */} {/* Back/Next Buttons */}
<div className="mt-1.5 flex justify-between"> <div className="flex justify-between">
<Button <Button
className="bg-white" className="text-white"
type="button" type="button"
color={stage === stages[0] ? "secondary" : "primary"}
disabled={stage === stages[0]} disabled={stage === stages[0]}
onClick={() => onClick={() =>
setStage(stages[stages.indexOf(stage) - 1]) setStage(stages[stages.indexOf(stage) - 1])
@ -168,7 +223,7 @@ const OnboardingForm = (): ReactElement => {
> >
Back Back
</Button> </Button>
<Button className="bg-white" type="submit"> <Button className="text-white" type="submit">
{stage === stages[stages.length - 1] {stage === stages[stages.length - 1]
? "Finish" ? "Finish"
: "Next"} : "Next"}
@ -178,4 +233,12 @@ const OnboardingForm = (): ReactElement => {
</motion.div> </motion.div>
); );
}; };
const buildOrgSlugPreview = (organizationName: string): string =>
organizationName
.trim()
.toLowerCase()
.replace(/\s+/g, "-") // Replace spaces with hyphens
.replace(/[^a-z0-9-]/g, ""); // Remove special characters (keeping hyphens)
export default OnboardingForm; export default OnboardingForm;

@ -4,6 +4,11 @@ import Link from "next/link";
import { ReactElement } from "react"; import { ReactElement } from "react";
import Branding from "@/components/branding"; import Branding from "@/components/branding";
/**
* The greeting for the landing page.
*
* @return the greeting jsx
*/
const Greeting = (): ReactElement => ( const Greeting = (): ReactElement => (
<div className="h-screen flex flex-col gap-4 justify-center text-center items-center"> <div className="h-screen flex flex-col gap-4 justify-center text-center items-center">
<div className="flex flex-col gap-2 items-center select-none pointer-events-none"> <div className="flex flex-col gap-2 items-center select-none pointer-events-none">
@ -11,7 +16,7 @@ const Greeting = (): ReactElement => (
<Branding className="animate-pulse" size="lg" /> <Branding className="animate-pulse" size="lg" />
{/* Greeting */} {/* Greeting */}
<h1 className="text-3xl text-red-500 font-bold">Pulse App</h1> <h1 className="text-3xl text-primary font-bold">Pulse App</h1>
<p className="max-w-[30rem] text-center opacity-75"> <p className="max-w-[30rem] text-center opacity-75">
A lightweight service monitoring solution for tracking the A lightweight service monitoring solution for tracking the
availability of whatever service your heart desires! availability of whatever service your heart desires!
@ -27,7 +32,7 @@ const Greeting = (): ReactElement => (
variant="ghost" variant="ghost"
> >
<Image <Image
src="/media/github.png" src="/media/platforms/github.svg"
alt="GitHub Logo" alt="GitHub Logo"
width={32} width={32}
height={32} height={32}

@ -10,7 +10,7 @@ const buttonVariants = cva(
variants: { variants: {
variant: { variant: {
default: default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90", "bg-primary/75 text-primary-foreground shadow hover:bg-primary/60",
destructive: destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline: outline: