diff --git a/.github/workflows/bun-tests.yml b/.github/workflows/bun-tests.yml index d7ff7f3..30f7843 100644 --- a/.github/workflows/bun-tests.yml +++ b/.github/workflows/bun-tests.yml @@ -27,7 +27,9 @@ jobs: - uses: ./.github/actions/setup-bun - name: Apply database schema - run: bun db:push + run: | + bun prisma db push \ + --schema=./packages/db/prisma/schema.prisma - name: Run tests run: bun test diff --git a/apps/web/app/(registration)/login/page.tsx b/apps/web/app/(registration)/login/page.tsx index ff4636a..4784778 100644 --- a/apps/web/app/(registration)/login/page.tsx +++ b/apps/web/app/(registration)/login/page.tsx @@ -1,5 +1,8 @@ -import { SignInForm } from "@good-dog/components/registration"; +import { + RegistrationPageLayout, + SignInForm, +} from "@good-dog/components/registration"; export default function Page() { - return ; + return } formLocation="right" />; } diff --git a/apps/web/app/(registration)/signup/page.tsx b/apps/web/app/(registration)/signup/page.tsx index 9983bf1..05779bd 100644 --- a/apps/web/app/(registration)/signup/page.tsx +++ b/apps/web/app/(registration)/signup/page.tsx @@ -1,5 +1,8 @@ -import { SignUpForm } from "@good-dog/components/registration"; +import { + RegistrationPageLayout, + SignUpForm, +} from "@good-dog/components/registration"; export default function Page() { - return ; + return } formLocation="left" />; } diff --git a/apps/web/package.json b/apps/web/package.json index 3f1f7d4..01c900f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -14,6 +14,7 @@ "dependencies": { "@good-dog/components": "workspace:*", "@good-dog/env": "workspace:*", + "@good-dog/tailwind": "workspace:*", "@good-dog/trpc": "workspace:*", "@good-dog/ui": "workspace:*", "next": "14.2.18", diff --git a/apps/web/public/icons/back_button.svg b/apps/web/public/icons/back_button.svg new file mode 100644 index 0000000..48bffe1 --- /dev/null +++ b/apps/web/public/icons/back_button.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/bun.lockb b/bun.lockb index b2798fe..e095bd2 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/packages/components/package.json b/packages/components/package.json index f36b710..070b4fb 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -17,6 +17,7 @@ "dependencies": { "@good-dog/trpc": "workspace:*", "@hookform/resolvers": "^3.9.1", + "clsx": "^2.1.1", "next": "14.2.18", "react": "18.3.1", "react-dom": "18.3.1", diff --git a/packages/components/src/CheckerColumn.tsx b/packages/components/src/CheckerColumn.tsx new file mode 100644 index 0000000..1128a05 --- /dev/null +++ b/packages/components/src/CheckerColumn.tsx @@ -0,0 +1,22 @@ +const svgPattern = (sqSize: number) => { + return ``; +}; + +export default function CheckerColumn( + props: Readonly<{ + squareSize?: number; + numSquares: number; + className?: string; + }>, +) { + const sqSize = props.squareSize ?? 20; + const encodedPattern = `data:image/svg+xml;base64,${btoa(svgPattern(sqSize))}`; + + const style = { + width: `${sqSize * props.numSquares}px`, + backgroundImage: `url(${encodedPattern})`, + backgroundRepeat: "repeat", + }; + + return
; +} diff --git a/packages/components/src/Footer.tsx b/packages/components/src/Footer.tsx index f8ba7a6..d10b18c 100644 --- a/packages/components/src/Footer.tsx +++ b/packages/components/src/Footer.tsx @@ -14,30 +14,30 @@ export default function Footer() { />
, + "viewBox" | "fill" | "xmlns" | "transform" +> & { + variant: "dark" | "light"; + facingDirection: "left" | "right"; +}; + +const GoodDogLogo = React.forwardRef, GoodDogLogoProps>( + ({ variant, facingDirection, ...props }, ref) => { + const primaryColor = variant === "dark" ? "#0D0039" : "#ACDD92"; + const secondaryColor = variant === "dark" ? "#ACDD92" : "#0D0039"; + + return ( + + + + + + + + + + + + ); + }, +); + +GoodDogLogo.displayName = "GoodDogLogo"; + +export default GoodDogLogo; diff --git a/packages/components/src/registration/EmailVerifyModal.tsx b/packages/components/src/registration/EmailVerifyModal.tsx index 7a5a1f5..4231d6b 100644 --- a/packages/components/src/registration/EmailVerifyModal.tsx +++ b/packages/components/src/registration/EmailVerifyModal.tsx @@ -1,7 +1,7 @@ "use client"; import { zodResolver } from "@hookform/resolvers/zod"; -import { useForm } from "react-hook-form"; +import { Controller, useForm, useFormContext } from "react-hook-form"; import { z } from "zod"; import { trpc } from "@good-dog/trpc/client"; @@ -14,8 +14,7 @@ import { DialogHeader, DialogTitle, } from "@good-dog/ui/dialog"; -import { Input } from "@good-dog/ui/input"; -import { Label } from "@good-dog/ui/label"; +import { InputOTP, InputOTPGroup, InputOTPSlot } from "@good-dog/ui/input-otp"; const zConfirmEmail = z.object({ code: z @@ -27,20 +26,30 @@ const zConfirmEmail = z.object({ export default function EmailVerifyModal({ email = "", isOpen = true, + close, }: { email: string; isOpen?: boolean; + close: () => void; }) { const confirmEmailForm = useForm>({ resolver: zodResolver(zConfirmEmail), + defaultValues: { + code: "", + }, }); + const signUpFormContext = useFormContext<{ + emailConfirmed: string; + }>(); const confirmEmailMutation = trpc.confirmEmail.useMutation({ onSuccess: (data) => { switch (data.status) { case "SUCCESS": + signUpFormContext.setValue("emailConfirmed", data.email); + close(); // TODO - // When the email is confirmed, show an toast or something + // When the email is confirmed, show a toast or something break; case "RESENT": // TODO @@ -61,6 +70,29 @@ export default function EmailVerifyModal({ }, }); + const resendVerificationEmailMutation = + trpc.sendEmailVerification.useMutation({ + onSuccess: (data) => { + switch (data.status) { + case "EMAIL_SENT": + // TODO + // alert somehow that a verification email was sent + break; + case "ALREADY_VERIFIED": + signUpFormContext.setValue("emailConfirmed", data.email); + close(); + // TODO + // alert somehow that the email has already been verified + break; + } + }, + onError: (err) => { + // TODO + // Alert toast to the user that there was an error sending the verification email + console.error(err); + }, + }); + const onSubmit = confirmEmailForm.handleSubmit((values) => { confirmEmailMutation.mutate({ ...values, @@ -68,32 +100,68 @@ export default function EmailVerifyModal({ }); }); + const enteredCode = confirmEmailForm.watch("code"); + return ( - - + { + if (!open) { + close(); + } + }} + > + - Email Verification - - Enter the 6-digit code sent to your email address. + Verify Email + + A 6-digit code has been sent to your email. Please enter the code + below. +
-
-
- - -
+ {confirmEmailMutation.isError && ( +

+ Invalid code: {confirmEmailMutation.error.message} +

+ )} +
+ ( + + + + + + + + + + + )} + />
- diff --git a/packages/components/src/registration/GenericRegistrationForm.tsx b/packages/components/src/registration/GenericRegistrationForm.tsx new file mode 100644 index 0000000..1082460 --- /dev/null +++ b/packages/components/src/registration/GenericRegistrationForm.tsx @@ -0,0 +1,88 @@ +import type { ReactNode } from "react"; +import Link from "next/link"; +import clsx from "clsx"; + +import { Button } from "@good-dog/ui/button"; + +export default function GenericRegistrationForm({ + title, + variant, + children, + error, + ctaTitle, + onSubmit, + disabled, + secondaryAction, + secondaryActionLink, + secondaryActionUrl, +}: Readonly<{ + title: string; + variant: "light" | "dark"; + error?: ReactNode; + children: ReactNode; + ctaTitle: string; + onSubmit: () => void; + disabled: boolean; + secondaryAction?: string; + secondaryActionLink?: string; + secondaryActionUrl?: string; +}>) { + return ( + +

+ {title} +

+ {error} +
+ {children} +
+ + {!!secondaryAction && !!secondaryActionUrl && !!secondaryActionLink && ( +
+ + {secondaryAction} + + + {secondaryActionLink} + +
+ )} + + ); +} diff --git a/packages/components/src/registration/RegistrationPageLayout.tsx b/packages/components/src/registration/RegistrationPageLayout.tsx new file mode 100644 index 0000000..46dea7b --- /dev/null +++ b/packages/components/src/registration/RegistrationPageLayout.tsx @@ -0,0 +1,71 @@ +import type { ReactNode } from "react"; +import Image from "next/image"; +import Link from "next/link"; +import clsx from "clsx"; + +import CheckerColumn from "../CheckerColumn"; +import GoodDogLogo from "../GoodDogLogo"; + +const Side = ({ + children, + color, +}: Readonly<{ + children: ReactNode; + color: "bg-good-dog-celadon" | "bg-good-dog-violet"; +}>) => ( +
+ {children} +
+); + +export default function RegistrationPageLayout(props: { + form: ReactNode; + formLocation: "left" | "right"; +}) { + return ( +
+ + {props.formLocation === "left" ? ( +
{props.form}
+ ) : ( + + )} +
+ + + {props.formLocation === "right" ? ( +
{props.form}
+ ) : ( + + )} +
+ + + back button + +
+ ); +} + +const Logo = (props: { side: "left" | "right" }) => ( + +); diff --git a/packages/components/src/registration/SignInForm.tsx b/packages/components/src/registration/SignInForm.tsx index 39401db..c655b97 100644 --- a/packages/components/src/registration/SignInForm.tsx +++ b/packages/components/src/registration/SignInForm.tsx @@ -1,13 +1,15 @@ "use client"; +import Link from "next/link"; import { useRouter } from "next/navigation"; import { zodResolver } from "@hookform/resolvers/zod"; import { FormProvider, useForm } from "react-hook-form"; import { z } from "zod"; import { trpc } from "@good-dog/trpc/client"; -import { Button } from "@good-dog/ui/button"; +import { Checkbox } from "@good-dog/ui/checkbox"; +import GenericRegistrationForm from "./GenericRegistrationForm"; import RegistrationInput from "./inputs/RegistrationInput"; const zSignInValues = z.object({ @@ -51,32 +53,50 @@ export default function SignInForm() { return ( -
- - - - -
- {signInMutation.isError && ( -

- Error signing in: {signInMutation.error.message} -

- )} -
+ + Error signing in: {signInMutation.error.message} +

+ ) + } + ctaTitle="Continue" + onSubmit={onSubmit} + disabled={signInMutation.isPending} + secondaryAction="Don't have an account?" + secondaryActionLink="Sign up" + secondaryActionUrl="/signup" + > +
+ + + +
+
+ +

Remember me

+
+ + + Forgot password? + +
+
+
); } diff --git a/packages/components/src/registration/SignUpForm.tsx b/packages/components/src/registration/SignUpForm.tsx index c578927..25c6da2 100644 --- a/packages/components/src/registration/SignUpForm.tsx +++ b/packages/components/src/registration/SignUpForm.tsx @@ -1,5 +1,6 @@ "use client"; +import { useState } from "react"; import { useRouter } from "next/navigation"; import { zodResolver } from "@hookform/resolvers/zod"; import { FormProvider, useForm } from "react-hook-form"; @@ -9,9 +10,11 @@ import { trpc } from "@good-dog/trpc/client"; import { Button } from "@good-dog/ui/button"; import EmailVerifyModal from "./EmailVerifyModal"; +import GenericRegistrationForm from "./GenericRegistrationForm"; import RegistrationInput from "./inputs/RegistrationInput"; const zSignUpValues = z.object({ + emailConfirmed: z.string(), email: z.string().email(), password: z .string() @@ -43,10 +46,13 @@ export default function SignUpForm() { ), }); - const verifyEmailMutation = trpc.sendEmailVerification.useMutation({ + const [isEmailVerificationModalOpen, setIsEmailVerificationModalOpen] = + useState(false); + const sendVerificationEmailMutation = trpc.sendEmailVerification.useMutation({ onSuccess: (data) => { switch (data.status) { case "EMAIL_SENT": + setIsEmailVerificationModalOpen(true); // TODO // alert somehow that a verification email was sent break; @@ -90,70 +96,93 @@ export default function SignUpForm() { }); const email = signUpForm.watch("email"); + const isEmailVerified = + sendVerificationEmailMutation.isSuccess && + signUpForm.watch("emailConfirmed") === email; return ( -
+ {isEmailVerificationModalOpen && ( { + setIsEmailVerificationModalOpen(false); + }} /> -
- - {verifyEmailMutation.isSuccess && ( -

Email verified

- )} - - + )} + + {sendVerificationEmailMutation.isError && ( +

+ Failed to send verification email:{" "} + {sendVerificationEmailMutation.error.message} +

+ )} + {signUpMutation.isError && ( +

+ Failed to ssign up: {signUpMutation.error.message} +

+ )} +
+ } + > + + + {isEmailVerified &&

Email verified

} + + +
- - - -
+
+ ); } diff --git a/packages/components/src/registration/index.tsx b/packages/components/src/registration/index.tsx index f3bf969..cce6a85 100644 --- a/packages/components/src/registration/index.tsx +++ b/packages/components/src/registration/index.tsx @@ -1,5 +1,11 @@ import OnboardingFormSwitcher from "./onboarding/OnboardingFormSwitcher"; +import RegistrationPageLayout from "./RegistrationPageLayout"; import SignInForm from "./SignInForm"; import SignUpForm from "./SignUpForm"; -export { SignUpForm, SignInForm, OnboardingFormSwitcher as OnboardingForm }; +export { + SignUpForm, + SignInForm, + OnboardingFormSwitcher as OnboardingForm, + RegistrationPageLayout, +}; diff --git a/packages/components/src/registration/inputs/RegistrationCheckbox.tsx b/packages/components/src/registration/inputs/RegistrationCheckbox.tsx index ed38129..f024ff5 100644 --- a/packages/components/src/registration/inputs/RegistrationCheckbox.tsx +++ b/packages/components/src/registration/inputs/RegistrationCheckbox.tsx @@ -3,7 +3,7 @@ import type { FieldPath, FieldValues } from "react-hook-form"; import { Controller, useFormContext } from "react-hook-form"; -import { Input } from "@good-dog/ui/input"; +import { Checkbox } from "@good-dog/ui/checkbox"; import { Label } from "@good-dog/ui/label"; export default function RegistrationCheckbox( @@ -16,22 +16,21 @@ export default function RegistrationCheckbox( const errors = formState.errors[props.fieldName]; return ( -
- +
( - field.onChange(e.target.checked)} + onChange={field.onChange} /> )} /> + {typeof errors?.message === "string" && ( -

{errors.message}

+

{errors.message}

)}
); diff --git a/packages/components/src/registration/inputs/RegistrationInput.tsx b/packages/components/src/registration/inputs/RegistrationInput.tsx index 11a1128..12e5be8 100644 --- a/packages/components/src/registration/inputs/RegistrationInput.tsx +++ b/packages/components/src/registration/inputs/RegistrationInput.tsx @@ -10,21 +10,28 @@ export default function RegistrationInput( props: Readonly<{ fieldName: FieldPath; placeholder?: string; + label: string; type: HTMLInputTypeAttribute; + classname?: string; }>, ) { const { register, formState } = useFormContext(); const errors = formState.errors[props.fieldName]; return ( -
+
+ {typeof errors?.message === "string" && ( -

{errors.message}

+

{errors.message}

)}
); diff --git a/packages/components/src/registration/onboarding/DiscoveryDropdown.tsx b/packages/components/src/registration/onboarding/DiscoveryDropdown.tsx new file mode 100644 index 0000000..a2c4fd9 --- /dev/null +++ b/packages/components/src/registration/onboarding/DiscoveryDropdown.tsx @@ -0,0 +1,43 @@ +import { Controller, useFormContext } from "react-hook-form"; + +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@good-dog/ui/select"; + +export default function DiscoveryDropdown() { + const { control } = useFormContext<{ + discovery?: string; + }>(); + + return ( +
+

How did you hear about Good Dog?

+ ( + + )} + /> +
+ ); +} diff --git a/packages/components/src/registration/onboarding/MediaMakerForm.tsx b/packages/components/src/registration/onboarding/MediaMakerForm.tsx index 1b3da43..9d75057 100644 --- a/packages/components/src/registration/onboarding/MediaMakerForm.tsx +++ b/packages/components/src/registration/onboarding/MediaMakerForm.tsx @@ -5,6 +5,7 @@ import { z } from "zod"; import { zPreProcessEmptyString } from "@good-dog/trpc/utils"; import RegistrationInput from "../inputs/RegistrationInput"; +import DiscoveryDropdown from "./DiscoveryDropdown"; import OnboardingFormProvider from "./OnboardingFormProvider"; const Schema = z.object({ @@ -31,16 +32,26 @@ export default function MediaMakerForm( firstName={props.firstName} lastName={props.lastName} > - - +

+ A Media Maker is a Lorem ipsum dolor sit amet, consectetur adipiscing + elit. Mauris pharetra lacus sit amet turpis suscipit, eget convallis + elit. Etiam ac tortor ac lectus scelerisque mollis. +

+
+ + +
+ ); } diff --git a/packages/components/src/registration/onboarding/MusicianForm.tsx b/packages/components/src/registration/onboarding/MusicianForm.tsx index b9c1615..fae6df9 100644 --- a/packages/components/src/registration/onboarding/MusicianForm.tsx +++ b/packages/components/src/registration/onboarding/MusicianForm.tsx @@ -5,10 +5,10 @@ import { z } from "zod"; import { zPreProcessEmptyString } from "@good-dog/trpc/utils"; import { Button } from "@good-dog/ui/button"; -import { Label } from "@good-dog/ui/label"; import RegistrationCheckbox from "../inputs/RegistrationCheckbox"; import RegistrationInput from "../inputs/RegistrationInput"; +import DiscoveryDropdown from "./DiscoveryDropdown"; import OnboardingFormProvider from "./OnboardingFormProvider"; const Schema = z.object({ @@ -55,39 +55,55 @@ export default function MusicianForm( firstName={user.firstName} lastName={user.lastName} > - - +

+ A Media Maker is a Lorem ipsum dolor sit amet, consectetur adipiscing + elit. Mauris pharetra lacus sit amet turpis suscipit, eget convallis + elit. Etiam ac tortor ac lectus scelerisque mollis. +

+
+ + + +
- - - +
+ + + +
+
+ ); } @@ -101,61 +117,107 @@ const GroupMemberForm = () => { return (
- {groupMembersFieldArray.fields.map((field, index) => (
+
+
+

+ First Name* +

+ +
+
+
+

+ Last Name* +

+ +
+ + +
+
- - - - - - +
+ + + +
))} - +
+ +

Add a Group Member

+
); }; diff --git a/packages/components/src/registration/onboarding/OnboardingFormProvider.tsx b/packages/components/src/registration/onboarding/OnboardingFormProvider.tsx index 0179a2b..71286c5 100644 --- a/packages/components/src/registration/onboarding/OnboardingFormProvider.tsx +++ b/packages/components/src/registration/onboarding/OnboardingFormProvider.tsx @@ -76,14 +76,14 @@ export default function OnboardingFormProvider< return ( -
- {props.children} + +
{props.children}
diff --git a/packages/components/src/registration/onboarding/OnboardingFormSwitcher.tsx b/packages/components/src/registration/onboarding/OnboardingFormSwitcher.tsx index 604eb20..d3806d9 100644 --- a/packages/components/src/registration/onboarding/OnboardingFormSwitcher.tsx +++ b/packages/components/src/registration/onboarding/OnboardingFormSwitcher.tsx @@ -1,10 +1,14 @@ "use client"; import { useState } from "react"; +import Image from "next/image"; +import Link from "next/link"; import { Label } from "@good-dog/ui/label"; import { Switch } from "@good-dog/ui/switch"; +import CheckerColumn from "../../CheckerColumn"; +import GoodDogLogo from "../../GoodDogLogo"; import MediaMakerForm from "./MediaMakerForm"; import MusicianForm from "./MusicianForm"; @@ -21,8 +25,23 @@ export default function OnboardingFormSwitcher( const FormComponent = isChecked ? MusicianForm : MediaMakerForm; return ( -
-
+
+ + back button + + + +
+

Sign up as a...

- +
); } diff --git a/packages/trpc/src/procedures/email-verification.ts b/packages/trpc/src/procedures/email-verification.ts index e496dc9..42c6081 100644 --- a/packages/trpc/src/procedures/email-verification.ts +++ b/packages/trpc/src/procedures/email-verification.ts @@ -23,12 +23,28 @@ export const sendEmailVerificationProcedure = notAuthenticatedProcedureBuilder ) .mutation(async ({ ctx, input }) => { // Check if there is already an email verification code for the given email - const existingEmailVerificationCode = - await ctx.prisma.emailVerificationCode.findUnique({ + const [existingUser, existingEmailVerificationCode] = await Promise.all([ + ctx.prisma.user.findUnique({ + where: { + email: input.email, + }, + }), + ctx.prisma.emailVerificationCode.findUnique({ where: { email: input.email, }, + }), + ]); + + if (existingUser) { + // TODO + // we don't want to leak details that the email already exists for a user, + // but we should still fail + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Email confirmation to ${input.email} failed to send.`, }); + } // If email already verified, throw error if (existingEmailVerificationCode?.emailConfirmed) { @@ -55,7 +71,7 @@ export const sendEmailVerificationProcedure = notAuthenticatedProcedureBuilder } } // Create/update the email verification code in the database - await ctx.prisma.emailVerificationCode.upsert({ + const result = await ctx.prisma.emailVerificationCode.upsert({ where: { email: input.email, }, @@ -71,9 +87,9 @@ export const sendEmailVerificationProcedure = notAuthenticatedProcedureBuilder }); return { - email: input.email, + email: result.email, status: "EMAIL_SENT" as const, - message: `Email verification code sent to ${input.email}`, + message: `Email verification code sent to ${result.email}`, }; }); @@ -102,17 +118,18 @@ export const confirmEmailProcedure = notAuthenticatedProcedureBuilder // If user already exists if (existingUser) { throw new TRPCError({ - code: "CONFLICT", - message: `User already exists with email ${input.email}`, + code: "UNAUTHORIZED", + message: `${input.email} is not verified.`, }); } // If email already verified if (existingEmailVerificationCode?.emailConfirmed) { - throw new TRPCError({ - code: "CONFLICT", - message: `Email already verified.`, - }); + return { + status: "SUCCESS" as const, + email: existingEmailVerificationCode.email, + message: `Email was successfully verified. Email: ${input.email}.`, + }; } // If email verification not found or given code is wrong, throw UNAUTHORIZED error @@ -144,7 +161,7 @@ export const confirmEmailProcedure = notAuthenticatedProcedureBuilder } } // Create/update the email verification code in the database - await ctx.prisma.emailVerificationCode.update({ + const result = await ctx.prisma.emailVerificationCode.update({ where: { email: input.email, }, @@ -156,12 +173,13 @@ export const confirmEmailProcedure = notAuthenticatedProcedureBuilder return { status: "RESENT" as const, + email: result.email, message: `Code is expired. A new code was sent to ${input.email}.`, }; } // If here, the given code was valid, so we can update the emailVerificationCode. - await ctx.prisma.emailVerificationCode.update({ + const result = await ctx.prisma.emailVerificationCode.update({ where: { email: input.email, }, @@ -173,6 +191,7 @@ export const confirmEmailProcedure = notAuthenticatedProcedureBuilder return { status: "SUCCESS" as const, + email: result.email, message: `Email was successfully verified. Email: ${input.email}.`, }; }); diff --git a/packages/ui/package.json b/packages/ui/package.json index 920809e..703fa24 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -34,15 +34,18 @@ "dependencies": { "@radix-ui/react-alert-dialog": "^1.1.1", "@radix-ui/react-avatar": "^1.1.0", + "@radix-ui/react-checkbox": "^1.1.2", "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-select": "^2.1.2", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-switch": "^1.1.1", "@radix-ui/react-tabs": "^1.1.1", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "date-fns": "^4.1.0", + "input-otp": "^1.4.1", "react-day-picker": "8.10.1", "tailwind-merge": "^2.5.2" }, diff --git a/packages/ui/shad/checkbox.tsx b/packages/ui/shad/checkbox.tsx new file mode 100644 index 0000000..9dad2ab --- /dev/null +++ b/packages/ui/shad/checkbox.tsx @@ -0,0 +1,28 @@ +import React from "react"; +import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; +import { CheckIcon } from "@radix-ui/react-icons"; + +import { cn } from "@good-dog/ui"; + +const Checkbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)); +Checkbox.displayName = CheckboxPrimitive.Root.displayName; + +export { Checkbox }; diff --git a/packages/ui/shad/input-otp.tsx b/packages/ui/shad/input-otp.tsx new file mode 100644 index 0000000..2b60a10 --- /dev/null +++ b/packages/ui/shad/input-otp.tsx @@ -0,0 +1,74 @@ +import React from "react"; +import { MinusIcon } from "@radix-ui/react-icons"; +import { OTPInput, OTPInputContext } from "input-otp"; + +import { cn } from "@good-dog/ui"; + +const InputOTP = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, containerClassName, ...props }, ref) => ( + +)); +InputOTP.displayName = "InputOTP"; + +const InputOTPGroup = React.forwardRef< + React.ElementRef<"div">, + React.ComponentPropsWithoutRef<"div"> +>(({ className, ...props }, ref) => ( +
+)); +InputOTPGroup.displayName = "InputOTPGroup"; + +const InputOTPSlot = React.forwardRef< + React.ElementRef<"div">, + React.ComponentPropsWithoutRef<"div"> & { index: number } +>(({ index, className, ...props }, ref) => { + const inputOTPContext = React.useContext(OTPInputContext); + + const slot = inputOTPContext.slots[index]; + if (!slot) { + return null; + } + const { char, hasFakeCaret, isActive } = slot; + + return ( +
+ {char} + {hasFakeCaret && ( +
+
+
+ )} +
+ ); +}); +InputOTPSlot.displayName = "InputOTPSlot"; + +const InputOTPSeparator = React.forwardRef< + React.ElementRef<"div">, + React.ComponentPropsWithoutRef<"div"> +>(({ ...props }, ref) => ( +
+ +
+)); +InputOTPSeparator.displayName = "InputOTPSeparator"; + +export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }; diff --git a/packages/ui/shad/select.tsx b/packages/ui/shad/select.tsx new file mode 100644 index 0000000..47a236e --- /dev/null +++ b/packages/ui/shad/select.tsx @@ -0,0 +1,161 @@ +import React from "react"; +import { + CheckIcon, + ChevronDownIcon, + ChevronUpIcon, +} from "@radix-ui/react-icons"; +import * as SelectPrimitive from "@radix-ui/react-select"; + +import { cn } from "@good-dog/ui"; + +const Select = SelectPrimitive.Root; + +const SelectGroup = SelectPrimitive.Group; + +const SelectValue = SelectPrimitive.Value; + +const SelectTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + span]:line-clamp-1", + className, + )} + {...props} + > + {children} + + + + +)); +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName; + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +SelectScrollDownButton.displayName = + SelectPrimitive.ScrollDownButton.displayName; + +const SelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = "popper", ...props }, ref) => ( + + + + + {children} + + + + +)); +SelectContent.displayName = SelectPrimitive.Content.displayName; + +const SelectLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SelectLabel.displayName = SelectPrimitive.Label.displayName; + +const SelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); +SelectItem.displayName = SelectPrimitive.Item.displayName; + +const SelectSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SelectSeparator.displayName = SelectPrimitive.Separator.displayName; + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +}; diff --git a/tests/api/email-verification.test.ts b/tests/api/email-verification.test.ts index 024bc5b..e6dddc4 100644 --- a/tests/api/email-verification.test.ts +++ b/tests/api/email-verification.test.ts @@ -155,14 +155,12 @@ describe("email-verification", () => { test("Email already verified", async () => { await createEmailVerificationCode(true); - expect( - $trpcCaller.confirmEmail({ - email: "damian@gmail.com", - code: "019821", - }), - ).rejects.toThrow("Email already verified."); + const response = await $trpcCaller.confirmEmail({ + email: "damian@gmail.com", + code: "019821", + }); - await cleanupEmailVerificationCode(); + expect(response.status).toBe("SUCCESS"); }); test("No email verification code entry", async () => { diff --git a/tests/frontend/signin.test.tsx b/tests/frontend/signin.test.tsx index 299c5e1..dfcd60f 100644 --- a/tests/frontend/signin.test.tsx +++ b/tests/frontend/signin.test.tsx @@ -1,6 +1,6 @@ import React from "react"; import { fireEvent, screen } from "@testing-library/react"; -import { afterEach, beforeAll, expect, test } from "bun:test"; +import { afterEach, beforeAll, describe, expect, test } from "bun:test"; import { SignInForm } from "@good-dog/components/registration"; @@ -9,35 +9,37 @@ import { renderWithShell } from "./util"; const mockNavigation = new MockNextNavigation(); -beforeAll(async () => { - await mockNavigation.apply(); -}); +describe("SignInForm", () => { + beforeAll(async () => { + await mockNavigation.apply(); + }); -afterEach(() => { - mockNavigation.clear(); -}); + afterEach(() => { + mockNavigation.clear(); + }); -test("Renders the sign in form with email and password fields", () => { - renderWithShell(); + test("Renders the sign in form with email and password fields", () => { + renderWithShell(); - const signInForm = screen.getByTestId("sign-in-form"); - expect(signInForm).toBeInTheDocument(); + const signInForm = screen.getByRole("form"); + expect(signInForm).toBeInTheDocument(); - const emailField = screen.getByPlaceholderText(/email/i); - expect(emailField).toBeInTheDocument(); + const emailField = screen.getByLabelText(/email/i); + expect(emailField).toBeInTheDocument(); - const passwordField = screen.getByPlaceholderText(/password/i); - expect(passwordField).toBeInTheDocument(); + const passwordField = screen.getByLabelText(/password/i); + expect(passwordField).toBeInTheDocument(); - const submitButton = screen.getByRole("button", { name: /sign in/i }); + const submitButton = screen.getByRole("button", { name: /continue/i }); - fireEvent.change(emailField, { target: { value: "test@example.com" } }); - fireEvent.change(passwordField, { target: { value: "password123" } }); + fireEvent.change(emailField, { target: { value: "test@example.com" } }); + fireEvent.change(passwordField, { target: { value: "password123" } }); - expect(emailField).toHaveValue("test@example.com"); - expect(passwordField).toHaveValue("password123"); + expect(emailField).toHaveValue("test@example.com"); + expect(passwordField).toHaveValue("password123"); - fireEvent.click(submitButton); + fireEvent.click(submitButton); - // TODO: we need to build out an app shell that allows us to test trpc mutations/queries + // TODO: we need to build out an app shell that allows us to test trpc mutations/queries + }); }); diff --git a/tests/mocks/MockNextNavigation.ts b/tests/mocks/MockNextNavigation.ts index 44cc6ce..52159de 100644 --- a/tests/mocks/MockNextNavigation.ts +++ b/tests/mocks/MockNextNavigation.ts @@ -10,11 +10,21 @@ export class MockNextNavigation { this.useParams.mockClear(); this.usePathname.mockClear(); this.useSearchParams.mockClear(); - this.useRouter.mockClear(); + this.mockRouter.clear(); } + readonly mockRouter = new MockRouter(); + readonly useSearchParams = mock(); readonly usePathname = mock(); - readonly useRouter = mock(); + readonly useRouter = () => this.mockRouter; readonly useParams = mock(); } + +class MockRouter { + clear() { + this.push.mockClear(); + } + + readonly push = mock(); +} diff --git a/tests/package.json b/tests/package.json index 535efda..56d89cb 100644 --- a/tests/package.json +++ b/tests/package.json @@ -24,6 +24,7 @@ "@testing-library/react": "^16.0.1", "@types/bun": "^1.1.10", "eslint": "9.10.0", + "next": "14.2.18", "prettier": "3.2.5", "typescript": "5.4.5", "zod": "3.23.8" diff --git a/tooling/tailwind/web.ts b/tooling/tailwind/web.ts index 1c073ec..1db0e46 100644 --- a/tooling/tailwind/web.ts +++ b/tooling/tailwind/web.ts @@ -33,6 +33,7 @@ export default { "good-dog-celadon": "#ACDD92", "good-dog-orange": "#EF946C", "good-dog-purple": "#574AE2", + "good-dog-error": "#800000", border: "hsl(var(--border))", input: "hsl(var(--input))", ring: "hsl(var(--ring))", diff --git a/turbo.json b/turbo.json index 98a1f1e..aabd571 100644 --- a/turbo.json +++ b/turbo.json @@ -43,7 +43,7 @@ }, "push": { "cache": false, - "interactive": false + "interactive": true }, "generate": { "cache": false,