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-dom": "^18",
"react-hook-form": "^7.53.0",
"react-social-icons": "^6.18.0",
"react-turnstile": "^1.1.3",
"sharp": "^0.33.5",
"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 { motion } from "framer-motion";
import Greeting from "@/components/auth/greeting";
import Footer from "@/components/auth/footer";
/**
* The page to authenticate with.
@ -29,7 +28,6 @@ const AuthPage = (): ReactElement => (
<Separator className="w-28" />
</div>
<AuthForm />
<Footer />
</motion.div>
</main>
);
@ -40,7 +38,7 @@ const AuthPage = (): ReactElement => (
* @return the providers jsx
*/
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="Google" link="#" />
</div>

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

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

@ -50,9 +50,9 @@ body {
--card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary: 271 91% 65%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 15.9%;
--secondary: 272 72% 47%;
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.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,
} from "@heroicons/react/24/outline";
import { apiRequest } from "@/lib/api";
import { Session } from "@/app/types/user/session";
import { Cookies, useCookies } from "next-client-cookies";
import { useRouter } from "next/navigation";
import { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime";
import { motion } from "framer-motion";
import Turnstile, { useTurnstile } from "react-turnstile";
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.
@ -28,24 +31,31 @@ import { TurnstileObject } from "turnstile-types";
const buildEmailInput = (allowEmpty: boolean) =>
z
.string()
.email("Invalid email address")
.email("That email address is invalid.")
.refine(
(val) => {
return !allowEmpty || val.length > 0;
},
{ message: "Email is required" }
{ message: "An email address is required." }
);
const EmailSchema = z.object({
email: buildEmailInput(false),
});
const RegisterSchema = z.object({
email: buildEmailInput(true),
username: z.string(),
password: z.string(),
passwordConfirmation: z.string(),
});
const RegisterSchema = z
.object({
email: buildEmailInput(true),
username: z
.string()
.regex(/^[a-z0-9_.]*$/, "That username is invalid."),
password: z.string(),
passwordConfirmation: z.string(),
})
.refine((data) => data.password === data.passwordConfirmation, {
path: ["passwordConfirmation"],
message: "Your passwords do not match.",
});
const LoginSchema = z.object({
email: buildEmailInput(true),
@ -108,7 +118,7 @@ const AuthForm = (): ReactElement => {
setStage(data?.exists ? "login" : "register");
} else {
const registering: boolean = stage === "register";
const { data, error } = await apiRequest<Session>({
const { data, error } = await apiRequest<UserAuthResponse>({
endpoint: `/auth/${registering ? "register" : "login"}`,
method: "POST",
body: registering
@ -132,13 +142,18 @@ const AuthForm = (): ReactElement => {
turnstile.reset();
} else {
// Otherwise store the session and redirect to the dashboard
cookies.set("session", JSON.stringify(data), {
cookies.set("session", JSON.stringify(data?.session), {
expires:
((data?.expires as number) - Date.now()) / 86_400_000,
((data?.session.expires as number) - Date.now()) /
86_400_000,
secure: true,
sameSite: "lax",
});
router.push("/dashboard");
router.push(
hasFlag(data?.user as User, UserFlag.COMPLETED_ONBOARDING)
? "/dashboard"
: "/dashboard/onboarding"
);
return;
}
}
@ -238,7 +253,7 @@ const AuthForm = (): ReactElement => {
{/* Submit Form */}
<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"
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 { Button } from "@/components/ui/button";
import { SocialIcon } from "react-social-icons";
import Link from "next/link";
import Image from "next/image";
/**
* The props for this oauth provider.
@ -26,14 +26,14 @@ type OAuthProviderProps = {
const OAuthProvider = ({ name, link }: OAuthProviderProps): ReactElement => (
<Link className="cursor-not-allowed" href={link}>
<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
>
<SocialIcon
className="w-10 h-10"
as="div"
bgColor="transparent"
network={name.toLowerCase()}
<Image
src={`/media/platforms/${name.toLowerCase()}.svg`}
alt={`${name}'s Logo`}
width={24}
height={24}
/>
<span>{name}</span>
</Button>

@ -1,7 +1,11 @@
"use client";
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 { Button } from "@/components/ui/button";
import { useForm } from "react-hook-form";
@ -21,21 +25,24 @@ import { User } from "@/app/types/user/user";
* Define the various stages of onboarding.
*/
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[] = [
{
name: "Onboarding",
name: "Create a new organization",
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({
organizationName,
organizationSlug,
}),
},
{
name: "Status Page",
name: "Create a new status page",
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({
organizationName,
organizationSlug,
statusPageName: z
.string()
.min(2, "You need a longer status page name!!!"),
@ -64,15 +71,28 @@ const OnboardingForm = (): ReactElement => {
const {
register,
handleSubmit,
watch,
formState: { errors },
} = useForm({
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.
*/
const onSubmit = async ({ organizationName, statusPageName }: any) => {
const onSubmit = async ({
organizationName,
organizationSlug,
statusPageName,
}: any) => {
// Completed onboarding
if (stage === stages[stages.length - 1]) {
const { data, error } = await apiRequest<void>({
@ -81,6 +101,7 @@ const OnboardingForm = (): ReactElement => {
session,
body: {
organizationName,
organizationSlug,
statusPageName,
},
});
@ -103,64 +124,98 @@ const OnboardingForm = (): ReactElement => {
return (
<motion.div
key={stage.name}
className="flex flex-col gap-3"
className="w-96 flex flex-col gap-2.5"
initial={{ x: -50, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
transition={{ duration: 0.4 }}
>
{/* Header */}
<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>
<p className="max-w-[25rem] opacity-65">{stage.description}</p>
</div>
<form
className="flex flex-col gap-2"
className="flex flex-col gap-0.5"
onSubmit={handleSubmit(onSubmit)}
>
{/* Header */}
<div className="flex flex-col gap-1 select-none pointer-events-none">
<h1 className="text-3xl font-bold">{stage.name}</h1>
<p className="max-w-[20rem] opacity-65">
{stage.description}
<div className="my-3 p-6 pb-3.5 flex flex-col gap-3.5 justify-center bg-zinc-900 rounded-lg">
{/* Organization Name */}
{stage === stages[0] && (
<div className="flex flex-col gap-1.5">
<p className="text-sm font-medium">
Organization Name
</p>
<div className="relative">
<BriefcaseIcon className="absolute left-2 top-[0.6rem] w-[1.15rem] h-[1.15rem]" />
<Input
className="pl-8 rounded-lg"
defaultValue={defaultOrgSlug}
{...register("organizationName")}
/>
</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 */}
{stage === stages[1] && (
<div className="flex flex-col gap-1.5">
<p className="text-sm font-medium">
Status Page Name
</p>
<div className="relative">
<ClipboardIcon className="absolute left-2 top-[0.6rem] w-[1.15rem] h-[1.15rem]" />
<Input
className="pl-8 rounded-lg"
defaultValue={`${user?.username}'s Status Page`}
{...register("statusPageName")}
/>
</div>
</div>
)}
{/* Display the global error if it exists, otherwise show the first field error */}
<p className="text-red-500">
{error
? error
: Object.values(errors).find(
(err: any) => err?.message
) &&
Object.values(errors)
.find((err: any) => err?.message)
?.message?.toString()}
</p>
</div>
{/* Organization Name */}
{stage === stages[0] && (
<div className="relative">
<BriefcaseIcon className="absolute left-2 top-[0.6rem] w-[1.15rem] h-[1.15rem]" />
<Input
className="pl-8 rounded-lg"
placeholder="Organization Name"
{...register("organizationName")}
/>
</div>
)}
{/* Status Page Name */}
{stage === stages[1] && (
<div className="relative">
<ClipboardIcon className="absolute left-2 top-[0.6rem] w-[1.15rem] h-[1.15rem]" />
<Input
className="pl-8 rounded-lg"
placeholder="Status Page Name"
{...register("statusPageName")}
/>
</div>
)}
{/* Display the global error if it exists, otherwise show the first field error */}
<p className="text-red-500">
{error
? error
: Object.values(errors).find(
(err: any) => err?.message
) &&
Object.values(errors)
.find((err: any) => err?.message)
?.message?.toString()}
</p>
{/* Back/Next Buttons */}
<div className="mt-1.5 flex justify-between">
<div className="flex justify-between">
<Button
className="bg-white"
className="text-white"
type="button"
color={stage === stages[0] ? "secondary" : "primary"}
disabled={stage === stages[0]}
onClick={() =>
setStage(stages[stages.indexOf(stage) - 1])
@ -168,7 +223,7 @@ const OnboardingForm = (): ReactElement => {
>
Back
</Button>
<Button className="bg-white" type="submit">
<Button className="text-white" type="submit">
{stage === stages[stages.length - 1]
? "Finish"
: "Next"}
@ -178,4 +233,12 @@ const OnboardingForm = (): ReactElement => {
</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;

@ -4,6 +4,11 @@ import Link from "next/link";
import { ReactElement } from "react";
import Branding from "@/components/branding";
/**
* The greeting for the landing page.
*
* @return the greeting jsx
*/
const Greeting = (): ReactElement => (
<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">
@ -11,7 +16,7 @@ const Greeting = (): ReactElement => (
<Branding className="animate-pulse" size="lg" />
{/* 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">
A lightweight service monitoring solution for tracking the
availability of whatever service your heart desires!
@ -27,7 +32,7 @@ const Greeting = (): ReactElement => (
variant="ghost"
>
<Image
src="/media/github.png"
src="/media/platforms/github.svg"
alt="GitHub Logo"
width={32}
height={32}

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