diff --git a/apps/auth-proxy/routes/r/[...auth].ts b/apps/auth-proxy/routes/r/[...auth].ts
index bb57830..ae13825 100644
--- a/apps/auth-proxy/routes/r/[...auth].ts
+++ b/apps/auth-proxy/routes/r/[...auth].ts
@@ -17,9 +17,9 @@ export default eventHandler(async (event) =>
callbacks: {
async signIn({ user }) {
const email = user?.email;
-
+
if (!email?.endsWith("@husky.neu.edu")) {
- return false
+ return false;
}
return true;
},
diff --git a/apps/web/package.json b/apps/web/package.json
index 7453554..4404c5e 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -25,6 +25,7 @@
"@trpc/react-query": "11.0.0-rc.441",
"@trpc/server": "11.0.0-rc.441",
"dayjs": "^1.11.13",
+ "fuse.js": "^7.0.0",
"geist": "^1.3.0",
"lucide-react": "^0.436.0",
"next": "^14.2.4",
diff --git a/apps/web/public/svg/hidingLogo.svg b/apps/web/public/svg/hidingLogo.svg
index bcefdd5..70e2a5b 100644
--- a/apps/web/public/svg/hidingLogo.svg
+++ b/apps/web/public/svg/hidingLogo.svg
@@ -1,28 +1,21 @@
-
\ No newline at end of file
+
diff --git a/apps/web/src/app/(pages)/(dashboard)/companies/page.tsx b/apps/web/src/app/(pages)/(dashboard)/companies/page.tsx
index 41d2419..f8659bf 100644
--- a/apps/web/src/app/(pages)/(dashboard)/companies/page.tsx
+++ b/apps/web/src/app/(pages)/(dashboard)/companies/page.tsx
@@ -1,5 +1,6 @@
import { unstable_noStore as noStore } from "next/cache";
+import NoResults from "~/app/_components/no-results";
import { RoleReviewCard } from "~/app/_components/reviews/role-review-card";
import SearchFilter from "~/app/_components/search/search-filter";
import { api } from "~/trpc/server";
@@ -20,13 +21,17 @@ export default async function Companies() {
return (
<>
-
- {roles.map((role) => {
- return (
-
- );
- })}
-
+ {roles.length > 0 ? (
+
+ {roles.map((role) => {
+ return (
+
+ );
+ })}
+
+ ) : (
+
+ )}
>
);
}
diff --git a/apps/web/src/app/(pages)/(dashboard)/page.tsx b/apps/web/src/app/(pages)/(dashboard)/page.tsx
index 45b8310..c660326 100644
--- a/apps/web/src/app/(pages)/(dashboard)/page.tsx
+++ b/apps/web/src/app/(pages)/(dashboard)/page.tsx
@@ -4,7 +4,7 @@ export default function Home() {
return (
);
diff --git a/apps/web/src/app/(pages)/(dashboard)/redirection/page.tsx b/apps/web/src/app/(pages)/(dashboard)/redirection/page.tsx
index b40ad36..f2d04f6 100644
--- a/apps/web/src/app/(pages)/(dashboard)/redirection/page.tsx
+++ b/apps/web/src/app/(pages)/(dashboard)/redirection/page.tsx
@@ -1,15 +1,17 @@
import React from "react";
export default function ErrorPage() {
-
return (
-
-
-
Authentication Error
-
You must log in with husky.neu.edu
-
Click the sign in button to try again
-
+
+
+
+ Authentication Error
+
+
You must log in with husky.neu.edu
+
+ Click the sign in button to try again
+
+
);
}
-
\ No newline at end of file
diff --git a/apps/web/src/app/(pages)/(dashboard)/roles/page.tsx b/apps/web/src/app/(pages)/(dashboard)/roles/page.tsx
index 59a827b..e47adbe 100644
--- a/apps/web/src/app/(pages)/(dashboard)/roles/page.tsx
+++ b/apps/web/src/app/(pages)/(dashboard)/roles/page.tsx
@@ -3,16 +3,17 @@
import { useEffect, useState } from "react";
import { z } from "zod";
-import {
+import type {
ReviewType,
- WorkEnvironment,
WorkEnvironmentType,
- WorkTerm,
WorkTermType,
} from "@cooper/db/schema";
+import { WorkEnvironment, WorkTerm } from "@cooper/db/schema";
import { cn } from "@cooper/ui";
import { useToast } from "@cooper/ui/hooks/use-toast";
+import LoadingResults from "~/app/_components/loading-results";
+import NoResults from "~/app/_components/no-results";
import { ReviewCard } from "~/app/_components/reviews/review-card";
import { ReviewCardPreview } from "~/app/_components/reviews/review-card-preview";
import SearchFilter from "~/app/_components/search/search-filter";
@@ -22,6 +23,7 @@ export default function Roles({
searchParams,
}: {
searchParams?: {
+ search?: string;
cycle?: WorkTermType;
term?: WorkEnvironmentType;
};
@@ -47,33 +49,39 @@ export default function Roles({
useEffect(() => {
setMounted(true);
}, []);
+
useEffect(() => {
- if (!mounted) {
- return;
- }
- if (!validationResult.success) {
+ if (mounted && !validationResult.success) {
toast({
- title: "Invalid search params",
+ title: "Invalid Search Parameters",
description: validationResult.error.issues
.map((issue) => issue.message)
.join(", "),
variant: "destructive",
});
+ setMounted(false);
}
- }, [toast, mounted]);
+ }, [toast, mounted, validationResult]);
const reviews = api.review.list.useQuery({
+ search: searchParams?.search,
options: validationResult.success ? validationResult.data : {},
});
const [selectedReview, setSelectedReview] = useState
(
- reviews.data ? reviews.data[0] : undefined,
+ reviews.isSuccess ? reviews.data[0] : undefined,
);
+ useEffect(() => {
+ if (reviews.isSuccess) {
+ setSelectedReview(reviews.data[0]);
+ }
+ }, [reviews.isSuccess, reviews.data]);
+
return (
<>
-
- {reviews.data && (
+
+ {reviews.isSuccess && reviews.data.length > 0 && (
{reviews.data.map((review, i) => {
@@ -100,6 +108,8 @@ export default function Roles({
)}
+ {reviews.isSuccess && reviews.data.length === 0 && }
+ {reviews.isPending && }
>
);
}
diff --git a/apps/web/src/app/_components/auth/login-button.tsx b/apps/web/src/app/_components/auth/login-button.tsx
index 81c8697..ebecdc1 100644
--- a/apps/web/src/app/_components/auth/login-button.tsx
+++ b/apps/web/src/app/_components/auth/login-button.tsx
@@ -8,7 +8,7 @@ export default function LoginButton() {
className="rounded-xl border-cooper-blue-400 bg-cooper-blue-400 px-5 py-2.5 text-sm font-semibold text-white hover:border-cooper-blue-200 hover:bg-cooper-blue-200 hover:text-cooper-blue-600 focus:outline-none focus:ring-4 focus:ring-white"
formAction={async () => {
"use server";
- await signIn("google");
+ await signIn("google", { redirectTo: "/" });
}}
>
Sign In
diff --git a/apps/web/src/app/_components/auth/logout-button.tsx b/apps/web/src/app/_components/auth/logout-button.tsx
index 9c48a2a..1159b0e 100644
--- a/apps/web/src/app/_components/auth/logout-button.tsx
+++ b/apps/web/src/app/_components/auth/logout-button.tsx
@@ -8,7 +8,7 @@ export default function LogoutButton() {
className="rounded-xl border-cooper-blue-400 bg-cooper-blue-400 px-5 py-2.5 text-sm font-semibold text-white hover:border-cooper-blue-200 hover:bg-cooper-blue-200 hover:text-cooper-blue-600 focus:outline-none focus:ring-4 focus:ring-white"
formAction={async () => {
"use server";
- await signOut();
+ await signOut({ redirectTo: "/" });
}}
>
Sign Out
diff --git a/apps/web/src/app/_components/cooper-logo.tsx b/apps/web/src/app/_components/cooper-logo.tsx
new file mode 100644
index 0000000..5339fbc
--- /dev/null
+++ b/apps/web/src/app/_components/cooper-logo.tsx
@@ -0,0 +1,16 @@
+import Image from "next/image";
+
+interface CooperLogoProps {
+ width?: number;
+}
+
+export default function CooperLogo({ width }: CooperLogoProps) {
+ return (
+
+ );
+}
diff --git a/apps/web/src/app/_components/header.tsx b/apps/web/src/app/_components/header.tsx
index 1b02b41..cb6d287 100644
--- a/apps/web/src/app/_components/header.tsx
+++ b/apps/web/src/app/_components/header.tsx
@@ -1,6 +1,5 @@
"use client";
-import Image from "next/image";
import Link from "next/link";
import { usePathname } from "next/navigation";
@@ -9,6 +8,7 @@ import { cn } from "@cooper/ui";
import { NewReviewDialog } from "~/app/_components/reviews/new-review-dialogue";
import { altivoFont } from "~/app/styles/font";
+import CooperLogo from "./cooper-logo";
interface HeaderProps {
session: Session | null;
@@ -28,13 +28,8 @@ export default function Header({ session, auth }: HeaderProps) {
{/* Logo + Cooper */}
-
-
+
+
{/* Centered Links */}
-
+
- {" "}
- Jobs{" "}
+ Jobs
Companies
diff --git a/apps/web/src/app/_components/loading-results.tsx b/apps/web/src/app/_components/loading-results.tsx
new file mode 100644
index 0000000..925b50e
--- /dev/null
+++ b/apps/web/src/app/_components/loading-results.tsx
@@ -0,0 +1,12 @@
+import CooperLogo from "./cooper-logo";
+
+export default function LoadingResults() {
+ return (
+
+ );
+}
diff --git a/apps/web/src/app/_components/no-results.tsx b/apps/web/src/app/_components/no-results.tsx
new file mode 100644
index 0000000..43c8650
--- /dev/null
+++ b/apps/web/src/app/_components/no-results.tsx
@@ -0,0 +1,33 @@
+"use client";
+
+import { usePathname, useRouter } from "next/navigation";
+
+import { Button } from "@cooper/ui/button";
+
+import CooperLogo from "./cooper-logo";
+
+export default function NoResults() {
+ const router = useRouter();
+ const pathname = usePathname();
+
+ function clearFilters() {
+ router.push(pathname);
+ }
+
+ return (
+
+
+
+
No Results Found
+
+
+
+ );
+}
diff --git a/apps/web/src/app/_components/search/search-bar.tsx b/apps/web/src/app/_components/search/search-bar.tsx
index 6ca060e..1c0ba5e 100644
--- a/apps/web/src/app/_components/search/search-bar.tsx
+++ b/apps/web/src/app/_components/search/search-bar.tsx
@@ -1,46 +1,144 @@
+import { useState } from "react";
import { useFormContext } from "react-hook-form";
+import { Button } from "@cooper/ui/button";
import { FormControl, FormField, FormItem } from "@cooper/ui/form";
import { Input } from "@cooper/ui/input";
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectSeparator,
+ SelectTrigger,
+ SelectValue,
+} from "@cooper/ui/select";
-export function SearchBar() {
+interface SearchBarProps {
+ cycle?: "FALL" | "SPRING" | "SUMMER";
+ term?: "INPERSON" | "HYBRID" | "REMOTE";
+}
+
+/**
+ * This Search Bar employs filtering and fuzzy searching.
+ *
+ * NOTE: Cycle and Term only make sense for Roles
+ *
+ * @param param0 Cycle and Term to be set as default values for their respective dropdowns
+ * @returns A search bar which is connected to a parent 'useForm'
+ */
+export function SearchBar({ cycle, term }: SearchBarProps) {
const form = useFormContext();
+ const [selectedCycle, setSelectedCycle] = useState(cycle);
+ const [selectedTerm, setSelectedTerm] = useState(term);
+
return (
-
+
);
}
diff --git a/apps/web/src/app/_components/search/search-filter.tsx b/apps/web/src/app/_components/search/search-filter.tsx
index fe32850..0d2224d 100644
--- a/apps/web/src/app/_components/search/search-filter.tsx
+++ b/apps/web/src/app/_components/search/search-filter.tsx
@@ -1,49 +1,86 @@
"use client";
import { useCallback } from "react";
-import { usePathname, useRouter, useSearchParams } from "next/navigation";
+import { usePathname, useRouter } from "next/navigation";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
+import { WorkEnvironment, WorkTerm } from "@cooper/db/schema";
import { Form } from "@cooper/ui/form";
import { SearchBar } from "~/app/_components/search/search-bar";
const formSchema = z.object({
searchText: z.string(),
+ searchCycle: z
+ .nativeEnum(WorkTerm, {
+ message: "Invalid cycle type",
+ })
+ .optional(),
+ searchTerm: z
+ .nativeEnum(WorkEnvironment, {
+ message: "Invalid cycle type",
+ })
+ .optional(),
});
export type SearchFilterFormType = typeof formSchema;
-export default function SearchFilter() {
+interface SearchFilterProps {
+ search?: string;
+ cycle?: "FALL" | "SPRING" | "SUMMER";
+ term?: "INPERSON" | "HYBRID" | "REMOTE";
+ alternatePathname?: string;
+}
+
+/**
+ * Handles searching logic, updates the search param base on user search and passes the text to backend with fuzzy searching.
+ * @param param0 user input text that's passed to the fuzzy search, cycle filter, and term filter.
+ * - alternatePathname example: "/roles" or "/companies"
+ * @returns the search bar with the user inputted text
+ */
+export default function SearchFilter({
+ search,
+ cycle,
+ term,
+ alternatePathname,
+}: SearchFilterProps) {
const form = useForm
>({
resolver: zodResolver(formSchema),
defaultValues: {
- searchText: "",
+ searchText: search ?? "",
+ searchCycle: cycle,
+ searchTerm: term,
},
});
- const searchParams = useSearchParams();
const router = useRouter();
const pathName = usePathname();
const createQueryString = useCallback(
- (name: string, value: string) => {
- const params = new URLSearchParams(searchParams);
- params.set(name, value);
- return params.toString();
+ ({ searchText, searchCycle, searchTerm }: z.infer) => {
+ // Initialize URLSearchParams with the required searchText
+ const params = new URLSearchParams({ search: searchText });
+
+ // Conditionally add searchCycle and searchTerm if they have values
+ if (searchCycle) {
+ params.set("cycle", searchCycle);
+ }
+ if (searchTerm) {
+ params.set("term", searchTerm);
+ }
+
+ return params.toString(); // Returns a query string, e.g., "search=yo&cycle=SPRING"
},
- [searchParams],
+ [],
);
function onSubmit(values: z.infer) {
- if (values.searchText != "") {
- router.push(
- pathName + `/?${createQueryString("search", values.searchText)}`,
- );
+ if (alternatePathname) {
+ router.push(alternatePathname + "?" + createQueryString(values));
} else {
- router.push(pathName);
+ router.push(pathName + "?" + createQueryString(values));
}
}
@@ -51,7 +88,7 @@ export default function SearchFilter() {
diff --git a/packages/api/package.json b/packages/api/package.json
index dfca51e..e5d57ef 100644
--- a/packages/api/package.json
+++ b/packages/api/package.json
@@ -13,6 +13,7 @@
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
+ "test": "vitest",
"clean": "rm -rf .turbo dist node_modules",
"format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint",
diff --git a/packages/api/src/router/review.ts b/packages/api/src/router/review.ts
index 0cec664..fdf7053 100644
--- a/packages/api/src/router/review.ts
+++ b/packages/api/src/router/review.ts
@@ -1,4 +1,6 @@
import type { TRPCRouterRecord } from "@trpc/server";
+import { TRPCError } from "@trpc/server";
+import Fuse from "fuse.js";
import { z } from "zod";
import { and, desc, eq } from "@cooper/db";
@@ -10,6 +12,7 @@ export const reviewRouter = {
list: publicProcedure
.input(
z.object({
+ search: z.string().optional(),
options: z
.object({
cycle: z.enum(["SPRING", "FALL", "SUMMER"]).optional(),
@@ -18,7 +21,7 @@ export const reviewRouter = {
.optional(),
}),
)
- .query(({ ctx, input }) => {
+ .query(async ({ ctx, input }) => {
const { options } = input;
const conditions = [
@@ -26,10 +29,22 @@ export const reviewRouter = {
options?.term && eq(Review.workEnvironment, options.term),
].filter(Boolean);
- return ctx.db.query.Review.findMany({
+ const reviews = await ctx.db.query.Review.findMany({
orderBy: desc(Review.id),
where: conditions.length > 0 ? and(...conditions) : undefined,
});
+
+ if (!input.search) {
+ return reviews;
+ }
+
+ const fuseOptions = {
+ keys: ["reviewHeadline", "textReview", "location"],
+ };
+
+ const fuse = new Fuse(reviews, fuseOptions);
+
+ return fuse.search(input.search).map((result) => result.item);
}),
getByRole: publicProcedure
@@ -58,7 +73,33 @@ export const reviewRouter = {
create: protectedProcedure
.input(CreateReviewSchema)
- .mutation(({ ctx, input }) => {
+ .mutation(async ({ ctx, input }) => {
+ if (!input.profileId) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "You must be logged in to leave a review",
+ });
+ }
+ const reviews = await ctx.db.query.Review.findMany({
+ where: eq(Review.profileId, input.profileId),
+ });
+ if (reviews.length >= 5) {
+ throw new TRPCError({
+ code: "PRECONDITION_FAILED",
+ message: "You can only leave 5 reviews",
+ });
+ }
+ const reviewsInSameCycle = reviews.filter(
+ (review) =>
+ review.workTerm === input.workTerm &&
+ review.workYear === input.workYear,
+ );
+ if (reviewsInSameCycle.length >= 2) {
+ throw new TRPCError({
+ code: "PRECONDITION_FAILED",
+ message: "You can only leave 2 reviews per cycle",
+ });
+ }
return ctx.db.insert(Review).values(input);
}),
diff --git a/packages/api/tests/mocks/review.ts b/packages/api/tests/mocks/review.ts
new file mode 100644
index 0000000..4cdce65
--- /dev/null
+++ b/packages/api/tests/mocks/review.ts
@@ -0,0 +1,5 @@
+export const data = [
+ { id: "1", workTerm: "SPRING", workEnvironment: "REMOTE" },
+ { id: "2", workTerm: "FALL", workEnvironment: "INPERSON" },
+ { id: "3", workTerm: "SUMMER", workEnvironment: "HYBRID" },
+];
diff --git a/packages/api/tests/review.test.ts b/packages/api/tests/review.test.ts
new file mode 100644
index 0000000..6016eb5
--- /dev/null
+++ b/packages/api/tests/review.test.ts
@@ -0,0 +1,102 @@
+/* eslint-disable @typescript-eslint/no-unsafe-assignment */
+/* eslint-disable @typescript-eslint/unbound-method */
+import { beforeEach, describe, expect, test, vi } from "vitest";
+
+import type { Session } from "@cooper/auth";
+import type { ReviewType } from "@cooper/db/schema";
+import { and, eq } from "@cooper/db";
+import { db } from "@cooper/db/client";
+import { Review } from "@cooper/db/schema";
+
+import { appRouter } from "../src/root";
+import { createCallerFactory, createTRPCContext } from "../src/trpc";
+import { data } from "./mocks/review";
+
+vi.mock("@cooper/db/client", () => ({
+ db: {
+ query: {
+ Review: {
+ findMany: vi.fn(),
+ },
+ },
+ },
+}));
+
+vi.mock("@cooper/auth", () => ({
+ auth: vi.fn(),
+}));
+
+describe("Review Router", async () => {
+ beforeEach(() => {
+ vi.restoreAllMocks();
+ vi.mocked(db.query.Review.findMany).mockResolvedValue(data as ReviewType[]);
+ });
+
+ const session: Session = {
+ user: {
+ id: "1",
+ },
+ expires: "1",
+ };
+
+ const ctx = await createTRPCContext({
+ session,
+ headers: new Headers(),
+ });
+
+ const caller = createCallerFactory(appRouter)(ctx);
+
+ test("list endpoint returns all reviews", async () => {
+ const reviews = await caller.review.list({});
+
+ expect(reviews).toEqual(data);
+
+ expect(db.query.Review.findMany).toHaveBeenCalledWith({
+ orderBy: expect.anything(),
+ where: undefined,
+ });
+ });
+
+ test("list endpoint with cycle filter", async () => {
+ await caller.review.list({
+ options: {
+ cycle: "SPRING",
+ },
+ });
+
+ expect(db.query.Review.findMany).toHaveBeenCalledWith({
+ orderBy: expect.anything(),
+ where: and(eq(Review.workTerm, "SPRING")),
+ });
+ });
+
+ test("list endpoint with term filter", async () => {
+ await caller.review.list({
+ options: {
+ term: "REMOTE",
+ },
+ });
+
+ expect(db.query.Review.findMany).toHaveBeenCalledWith({
+ orderBy: expect.anything(),
+ where: and(eq(Review.workEnvironment, "REMOTE")),
+ });
+ });
+
+ test("list endpoint with cycle and term filter", async () => {
+ await caller.review.list({
+ options: {
+ cycle: "SPRING",
+ term: "REMOTE",
+ },
+ });
+
+ expect(db.query.Review.findMany).toHaveBeenCalledWith({
+ orderBy: expect.anything(),
+ where: and(
+ eq(Review.workTerm, "SPRING"),
+ eq(Review.workEnvironment, "REMOTE"),
+ ),
+ });
+ });
+});
diff --git a/packages/api/tsconfig.json b/packages/api/tsconfig.json
index c97abb4..15703de 100644
--- a/packages/api/tsconfig.json
+++ b/packages/api/tsconfig.json
@@ -4,6 +4,6 @@
"outDir": "dist",
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
- "include": ["src"],
+ "include": ["src", "tests"],
"exclude": ["node_modules"]
}
diff --git a/packages/auth/src/config.ts b/packages/auth/src/config.ts
index be11247..410e845 100644
--- a/packages/auth/src/config.ts
+++ b/packages/auth/src/config.ts
@@ -62,9 +62,9 @@ export const authConfig = {
},
async signIn({ user }) {
const email = user?.email;
-
+
if (!email?.endsWith("@husky.neu.edu")) {
- return '/redirection'
+ return "/redirection";
}
return true;
},
diff --git a/packages/ui/package.json b/packages/ui/package.json
index cdcde64..f1c076b 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -27,6 +27,7 @@
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-popover": "^1.1.1",
"@radix-ui/react-radio-group": "^1.2.0",
+ "@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-toast": "^1.2.2",
"class-variance-authority": "^0.7.0",
diff --git a/packages/ui/src/select.tsx b/packages/ui/src/select.tsx
new file mode 100644
index 0000000..d45f1a8
--- /dev/null
+++ b/packages/ui/src/select.tsx
@@ -0,0 +1,160 @@
+"use client";
+
+import * as React from "react";
+import * as SelectPrimitive from "@radix-ui/react-select";
+import { Check, ChevronDown, ChevronUp } from "lucide-react";
+
+import { cn } from "@cooper/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/pnpm-lock.yaml b/pnpm-lock.yaml
index 358988a..36531fd 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -173,6 +173,9 @@ importers:
dayjs:
specifier: ^1.11.13
version: 1.11.13
+ fuse.js:
+ specifier: ^7.0.0
+ version: 7.0.0
geist:
specifier: ^1.3.0
version: 1.3.1(next@14.2.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1))
@@ -408,6 +411,9 @@ importers:
'@radix-ui/react-radio-group':
specifier: ^1.2.0
version: 1.2.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-select':
+ specifier: ^2.1.2
+ version: 2.1.2(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-slot':
specifier: ^1.1.0
version: 1.1.0(@types/react@18.3.3)(react@18.3.1)
@@ -511,7 +517,7 @@ importers:
version: 7.35.0(eslint@9.7.0)
eslint-plugin-react-hooks:
specifier: rc
- version: 5.1.0-rc-cae764ce-20241025(eslint@9.7.0)
+ version: 5.1.0-rc-7c8e5e7a-20241101(eslint@9.7.0)
eslint-plugin-turbo:
specifier: ^2.0.6
version: 2.0.9(eslint@9.7.0)
@@ -1585,11 +1591,9 @@ packages:
'@esbuild-kit/core-utils@3.3.2':
resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==}
- deprecated: 'Merged into tsx: https://tsx.is'
'@esbuild-kit/esm-loader@2.6.5':
resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==}
- deprecated: 'Merged into tsx: https://tsx.is'
'@esbuild/aix-ppc64@0.19.12':
resolution: {integrity: sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==}
@@ -2456,6 +2460,9 @@ packages:
'@polka/url@1.0.0-next.25':
resolution: {integrity: sha512-j7P6Rgr3mmtdkeDGTe0E/aYyWEWVtc5yFXtHCRHs28/jptDEWfaVOc5T7cblqy1XKPPfCxJc/8DwQ5YgLOZOVQ==}
+ '@radix-ui/number@1.1.0':
+ resolution: {integrity: sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ==}
+
'@radix-ui/primitive@1.0.1':
resolution: {integrity: sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw==}
@@ -2651,6 +2658,15 @@ packages:
'@types/react':
optional: true
+ '@radix-ui/react-focus-guards@1.1.1':
+ resolution: {integrity: sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
'@radix-ui/react-focus-scope@1.0.4':
resolution: {integrity: sha512-sL04Mgvf+FmyvZeYfNu1EPAaaxD+aw7cYeIB9L9Fvq8+urhltTRaEo5ysKOpHuKPclsZcSUMKlN05x4u+CINpA==}
peerDependencies:
@@ -2882,6 +2898,19 @@ packages:
'@types/react-dom':
optional: true
+ '@radix-ui/react-select@2.1.2':
+ resolution: {integrity: sha512-rZJtWmorC7dFRi0owDmoijm6nSJH1tVw64QGiNIZ9PNLyBDtG+iAq+XGsya052At4BfarzY/Dhv9wrrUr6IMZA==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@radix-ui/react-slot@1.0.2':
resolution: {integrity: sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==}
peerDependencies:
@@ -5107,9 +5136,8 @@ packages:
engines: {node: '>=4.0'}
peerDependencies:
eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8
-
- eslint-plugin-react-hooks@5.1.0-rc-cae764ce-20241025:
- resolution: {integrity: sha512-T0dII19NLMIDsd/kbOrPSVnbr0IhlFITOQoCVZuHDBatzGUY6p9wFPuAei7tQYpOnBhdywGIdx2nR4w/cfkcsA==}
+ eslint-plugin-react-hooks@5.1.0-rc-7c8e5e7a-20241101:
+ resolution: {integrity: sha512-90qwhATd1dlXHJ7uNmAIelLkfG3XnxR4vdbgG9WUbd81DT0Na+mXg9VVDYG+3/OQSdkAQlac24/hVQevRHMOpw==}
engines: {node: '>=10'}
peerDependencies:
eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0
@@ -7785,6 +7813,16 @@ packages:
'@types/react':
optional: true
+ react-remove-scroll@2.6.0:
+ resolution: {integrity: sha512-I2U4JVEsQenxDAKaVa3VZ/JeJZe0/2DxPWL8Tj8yLKctQJQiZM52pn/GWFpSp8dftjM3pSAHVJZscAnC/y+ySQ==}
+ engines: {node: '>=10'}
+ peerDependencies:
+ '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
react-router-config@5.1.1:
resolution: {integrity: sha512-DuanZjaD8mQp1ppHjgnnUnyOlqYXZVjnov/JzFhjLEwd3Z4dYjMSnqrEzzGThH47vpCOqPPwJM2FtthLeJ8Pbg==}
peerDependencies:
@@ -11613,6 +11651,8 @@ snapshots:
'@polka/url@1.0.0-next.25': {}
+ '@radix-ui/number@1.1.0': {}
+
'@radix-ui/primitive@1.0.1':
dependencies:
'@babel/runtime': 7.25.4
@@ -11807,6 +11847,12 @@ snapshots:
optionalDependencies:
'@types/react': 18.3.3
+ '@radix-ui/react-focus-guards@1.1.1(@types/react@18.3.3)(react@18.3.1)':
+ dependencies:
+ react: 18.3.1
+ optionalDependencies:
+ '@types/react': 18.3.3
+
'@radix-ui/react-focus-scope@1.0.4(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@babel/runtime': 7.25.4
@@ -12040,6 +12086,35 @@ snapshots:
'@types/react': 18.3.3
'@types/react-dom': 18.3.0
+ '@radix-ui/react-select@2.1.2(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/number': 1.1.0
+ '@radix-ui/primitive': 1.1.0
+ '@radix-ui/react-collection': 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.3)(react@18.3.1)
+ '@radix-ui/react-context': 1.1.1(@types/react@18.3.3)(react@18.3.1)
+ '@radix-ui/react-direction': 1.1.0(@types/react@18.3.3)(react@18.3.1)
+ '@radix-ui/react-dismissable-layer': 1.1.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-focus-guards': 1.1.1(@types/react@18.3.3)(react@18.3.1)
+ '@radix-ui/react-focus-scope': 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-id': 1.1.0(@types/react@18.3.3)(react@18.3.1)
+ '@radix-ui/react-popper': 1.2.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-portal': 1.1.2(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-slot': 1.1.0(@types/react@18.3.3)(react@18.3.1)
+ '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.3.3)(react@18.3.1)
+ '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.3)(react@18.3.1)
+ '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.3.3)(react@18.3.1)
+ '@radix-ui/react-use-previous': 1.1.0(@types/react@18.3.3)(react@18.3.1)
+ '@radix-ui/react-visually-hidden': 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ aria-hidden: 1.2.4
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ react-remove-scroll: 2.6.0(@types/react@18.3.3)(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.3
+ '@types/react-dom': 18.3.0
+
'@radix-ui/react-slot@1.0.2(@types/react@18.3.3)(react@18.3.1)':
dependencies:
'@babel/runtime': 7.25.4
@@ -14518,8 +14593,8 @@ snapshots:
object.fromentries: 2.0.8
safe-regex-test: 1.0.3
string.prototype.includes: 2.0.0
-
- eslint-plugin-react-hooks@5.1.0-rc-cae764ce-20241025(eslint@9.7.0):
+
+ eslint-plugin-react-hooks@5.1.0-rc-7c8e5e7a-20241101(eslint@9.7.0):
dependencies:
eslint: 9.7.0
@@ -17728,6 +17803,17 @@ snapshots:
optionalDependencies:
'@types/react': 18.3.3
+ react-remove-scroll@2.6.0(@types/react@18.3.3)(react@18.3.1):
+ dependencies:
+ react: 18.3.1
+ react-remove-scroll-bar: 2.3.6(@types/react@18.3.3)(react@18.3.1)
+ react-style-singleton: 2.2.1(@types/react@18.3.3)(react@18.3.1)
+ tslib: 2.6.3
+ use-callback-ref: 1.3.2(@types/react@18.3.3)(react@18.3.1)
+ use-sidecar: 1.1.2(@types/react@18.3.3)(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.3
+
react-router-config@5.1.1(react-router@5.3.4(react@18.3.1))(react@18.3.1):
dependencies:
'@babel/runtime': 7.25.4
diff --git a/turbo.json b/turbo.json
index 7d054a4..7b739a4 100644
--- a/turbo.json
+++ b/turbo.json
@@ -60,6 +60,10 @@
"start": {
"cache": false,
"interactive": true
+ },
+ "test": {
+ "cache": true,
+ "interactive": true
}
},
"globalEnv": [