From 2125e14144db792b4a66a0683106f9288519f90f Mon Sep 17 00:00:00 2001 From: jayylmao <73204320+jayylmao@users.noreply.github.com> Date: Thu, 28 May 2026 18:21:43 +0800 Subject: [PATCH 01/24] fix: colors not showing up in applications command bar on hire site --- tailwind.config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tailwind.config.ts b/tailwind.config.ts index 8a3db84c..2fee6f6e 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -6,6 +6,7 @@ const config: Config = { "./pages/**/*.{js,ts,jsx,tsx,mdx}", "./components/**/*.{js,ts,jsx,tsx,mdx}", "./app/**/*.{js,ts,jsx,tsx,mdx}", + "./lib/consts/**/*.{js,ts,jsx,tsx,mdx}", "*.{js,ts,jsx,tsx,mdx}", ], theme: { From dcabc4bc14596e9b7997fa355eb07486850ac9eb Mon Sep 17 00:00:00 2001 From: jayylmao <73204320+jayylmao@users.noreply.github.com> Date: Thu, 28 May 2026 18:22:22 +0800 Subject: [PATCH 02/24] style: update status colors --- lib/consts/application.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/lib/consts/application.ts b/lib/consts/application.ts index d84a1f0f..077f67fe 100644 --- a/lib/consts/application.ts +++ b/lib/consts/application.ts @@ -47,31 +47,34 @@ export const UI_STATUS_MAP = new Map([ "pending", { icon: Clock, - bgColor: "bg-orange-100", - fgColor: "text-orange-900", + bgColor: "bg-orange-700/10", + fgColor: "text-orange-700", }, ], [ "shortlisted", - { icon: Star, bgColor: "bg-amber-100", fgColor: "text-amber-900" }, + { icon: Star, bgColor: "bg-amber-700/10", fgColor: "text-amber-700" }, ], [ "accepted", - { icon: Check, bgColor: "bg-green-100", fgColor: "text-green-900" }, + { icon: Check, bgColor: "bg-green-700/10", fgColor: "text-green-700" }, ], [ "deleted", { icon: Trash, - bgColor: "bg-stone-200", - fgColor: "text-stone-900", + bgColor: "bg-stone-700/10", + fgColor: "text-stone-700", destructive: true, }, ], - ["rejected", { icon: Ban, bgColor: "bg-red-100", fgColor: "text-red-900" }], + [ + "rejected", + { icon: Ban, bgColor: "bg-red-700/10", fgColor: "text-red-700" }, + ], [ "archived", - { icon: Archive, bgColor: "bg-stone-200", fgColor: "text-stone-900" }, + { icon: Archive, bgColor: "bg-stone-700/10", fgColor: "text-stone-700" }, ], ]); From cf9c36ecda2c824f28fc84e40dc04b99e8069077 Mon Sep 17 00:00:00 2001 From: jayylmao <73204320+jayylmao@users.noreply.github.com> Date: Thu, 28 May 2026 18:22:58 +0800 Subject: [PATCH 03/24] fix: ts errors in command menu component --- components/ui/command-menu.tsx | 31 +++++++++++++------------------ 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/components/ui/command-menu.tsx b/components/ui/command-menu.tsx index 44c0597f..d90ac06c 100644 --- a/components/ui/command-menu.tsx +++ b/components/ui/command-menu.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from "react"; +import React from "react"; import { cn } from "@/lib/utils"; import { ActionItem } from "./action-item"; @@ -19,22 +19,18 @@ export const CommandMenu = ({ className?: string; }) => { const isActionItem = (x: any): x is ActionItem => - x && - typeof x === "object" && - typeof x.id === "string" && - typeof x.onClick === "function"; + x && typeof x === "object" && typeof x.id === "string"; - const groups = (items && items.length > 0 && Array.isArray(items[0])) - ? (items as Array>) - : [items as Array]; + const groups = + items && items.length > 0 && Array.isArray(items[0]) + ? (items as Array>) + : [items as Array]; const renderGroup = (group: Array, idx: number) => { return ( - {idx > 0 && ( -
- )} - {group.map((item, idx) => + {idx > 0 &&
} + {group.map((item, idx) => isActionItem(item) ? ( - - - {selectedIds.size} selected - -
- -
- -
- - -
- - )} - - ); - return ( <> {/* Floating action bar */} - {!isMobile && FloatingActionBar} + { + setSelectMode(false); + clearSelection(); + }} + onUnselectPage={unselectAllOnPage} + onSelectPage={selectAllOnPage} + onApply={openMassApply} + />
{jobs.isPending ? ( @@ -459,44 +408,6 @@ export default function SearchPage() { />
- - {/* Mobile mass apply toolbar */} - {selectMode && ( - -
- - {selectedIds.size} selected - -
- - -
-
-
- )}
) : ( // Desktop split view diff --git a/components/features/student/search/SearchCommandBar.tsx b/components/features/student/search/SearchCommandBar.tsx new file mode 100644 index 00000000..2d5ba9e8 --- /dev/null +++ b/components/features/student/search/SearchCommandBar.tsx @@ -0,0 +1,118 @@ +import { ActionItem } from "@/components/ui/action-item"; +import { CommandMenu } from "@/components/ui/command-menu"; +import { useAppContext } from "@/lib/ctx-app"; +import { Job } from "@/lib/db/db.types"; +import { motion, AnimatePresence } from "framer-motion"; +import { Check, CheckSquare, Square, X } from "lucide-react"; + +interface SearchCommandBarProps { + visible: boolean; + selected: Job[]; + selectedCount: number; + onCancel: () => void; + onUnselectPage: () => void; + onSelectPage: () => void; + onApply: () => void; +} + +export function SearchCommandBar({ + visible, + selected, + selectedCount, + onCancel, + onUnselectPage, + onSelectPage, + onApply, +}: SearchCommandBarProps) { + const { isMobile } = useAppContext(); + + return isMobile ? ( + + {visible && ( + <> + + + + + )} + + ) : ( + + {visible && ( + <> + + + + + )} + + ); +} From ff30e391eb3c7919d2409b85ee7f37b6678c5f86 Mon Sep 17 00:00:00 2001 From: jayylmao <73204320+jayylmao@users.noreply.github.com> Date: Thu, 28 May 2026 20:06:51 +0800 Subject: [PATCH 06/24] feat: add selected jobs list to search command bar --- .../student/search/SearchCommandBar.tsx | 302 +++++++++++++----- 1 file changed, 214 insertions(+), 88 deletions(-) diff --git a/components/features/student/search/SearchCommandBar.tsx b/components/features/student/search/SearchCommandBar.tsx index 2d5ba9e8..414050bb 100644 --- a/components/features/student/search/SearchCommandBar.tsx +++ b/components/features/student/search/SearchCommandBar.tsx @@ -1,9 +1,20 @@ +import { useState, useEffect } from "react"; import { ActionItem } from "@/components/ui/action-item"; import { CommandMenu } from "@/components/ui/command-menu"; import { useAppContext } from "@/lib/ctx-app"; import { Job } from "@/lib/db/db.types"; import { motion, AnimatePresence } from "framer-motion"; -import { Check, CheckSquare, Square, X } from "lucide-react"; +import { + Check, + CheckSquare, + Square, + X, + PanelRight, + PanelRightClose, + List, +} from "lucide-react"; +import { useBlurTransition } from "@/components/animata/blur"; +import { cn } from "@/lib/utils"; interface SearchCommandBarProps { visible: boolean; @@ -13,6 +24,46 @@ interface SearchCommandBarProps { onUnselectPage: () => void; onSelectPage: () => void; onApply: () => void; + onToggleSelect?: (job: Job) => void; +} + +interface SelectedJobRowProps { + job: Job; + index: number; + onToggleSelect?: (job: Job) => void; +} + +function SelectedJobRow({ job, index, onToggleSelect }: SelectedJobRowProps) { + const staggerDelay = index < 50 ? index * 0.05 : 0; + const blurTransition = useBlurTransition({ delay: staggerDelay }); + + return ( + +
+

+ {job.title} +

+

+ {job.employer?.name || "Unknown Company"} +

+
+ {onToggleSelect && ( + + )} +
+ ); } export function SearchCommandBar({ @@ -23,96 +74,171 @@ export function SearchCommandBar({ onUnselectPage, onSelectPage, onApply, + onToggleSelect, }: SearchCommandBarProps) { const { isMobile } = useAppContext(); + const [isOpen, setIsOpen] = useState(false); - return isMobile ? ( - - {visible && ( - <> - - - - - )} - - ) : ( - - {visible && ( - <> - - - - + // Auto-close sidebar if command bar becomes invisible or count goes to 0 + useEffect(() => { + if (!visible || selectedCount === 0) { + setIsOpen(false); + } + }, [visible, selectedCount]); + + const renderSidebar = () => ( + + {/* Sidebar Header */} +
+
+

+ Selected Jobs +

+

+ {selectedCount} listing{selectedCount !== 1 ? "s" : ""} selected +

+
+ +
+ + {/* Sidebar Content */} +
+ {selected.map((job, index) => ( + + ))} +
+
+ ); + + return ( + <> + {visible && isOpen && renderSidebar()} + + {isMobile ? ( + + {visible && ( + <> + + setIsOpen(!isOpen), + highlighted: isOpen, + highlightColor: + "bg-primary/10 text-primary font-semibold", + }, + ], + ]} + /> + + + )} + + ) : ( + + {visible && ( + <> + + setIsOpen(!isOpen), + highlighted: isOpen, + highlightColor: "bg-muted", + }, + ], + ]} + /> + + + )} + )} -
+ ); } From 31acce5cdccdb044f1745e9acd6e5b07bf5df649 Mon Sep 17 00:00:00 2001 From: jayylmao <73204320+jayylmao@users.noreply.github.com> Date: Thu, 28 May 2026 20:07:22 +0800 Subject: [PATCH 07/24] fix: offset paginator bottom padding for command bar --- app/student/search/page.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/student/search/page.tsx b/app/student/search/page.tsx index 4822fb6d..56875364 100644 --- a/app/student/search/page.tsx +++ b/app/student/search/page.tsx @@ -346,6 +346,7 @@ export default function SearchPage() { onUnselectPage={unselectAllOnPage} onSelectPage={selectAllOnPage} onApply={openMassApply} + onToggleSelect={toggleSelect} />
@@ -397,7 +398,7 @@ export default function SearchPage() {
)} -
+
)} -
+
Date: Thu, 28 May 2026 20:07:46 +0800 Subject: [PATCH 08/24] style: make command bar border consistent with other components --- components/ui/command-menu.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/ui/command-menu.tsx b/components/ui/command-menu.tsx index d90ac06c..31789c63 100644 --- a/components/ui/command-menu.tsx +++ b/components/ui/command-menu.tsx @@ -70,7 +70,7 @@ export const CommandMenu = ({ role="toolbar" onClick={(e) => e.stopPropagation()} className={cn( - "flex gap-4 px-6 py-2 w-full justify-center items-stretch text-xs bg-white border border-gray-300 transition bg-clip-border", + "flex gap-4 px-6 py-2 w-full justify-center items-stretch text-xs bg-white border border-gray-200 transition bg-clip-border", className, )} > From 0f806fb83d439abb6126ef99ccae5ef9589e4e65 Mon Sep 17 00:00:00 2001 From: jayylmao <73204320+jayylmao@users.noreply.github.com> Date: Thu, 28 May 2026 22:03:05 +0800 Subject: [PATCH 09/24] feat: added maps for status labels to id --- lib/consts/application.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/lib/consts/application.ts b/lib/consts/application.ts index 077f67fe..0f770020 100644 --- a/lib/consts/application.ts +++ b/lib/consts/application.ts @@ -92,3 +92,21 @@ export const DB_STATUS_MAP: Record = { 6: { key: "rejected", action: "REJECT" }, 7: { key: "archived", action: "ARCHIVE" }, }; + +export const LABEL_ID_STRING_MAP = new Map([ + ["pending", "0"], + ["shortlisted", "1"], + ["accepted", "4"], + ["deleted", "5"], + ["rejected", "6"], + ["archived", "7"], +]); + +export const LABEL_ID_MAP = new Map([ + ["pending", 0], + ["shortlisted", 1], + ["accepted", 4], + ["deleted", 5], + ["rejected", 6], + ["archived", 7], +]); From dd121aee2249479f45e2dcce8d56e4624924e460 Mon Sep 17 00:00:00 2001 From: jayylmao <73204320+jayylmao@users.noreply.github.com> Date: Thu, 28 May 2026 22:04:10 +0800 Subject: [PATCH 10/24] fix: disable mass hire app action when accepted/rejected app selected --- .../hire/dashboard/ApplicationsContent.tsx | 32 ++++++++------ hooks/use-application-selection.ts | 43 +++++++++++-------- 2 files changed, 44 insertions(+), 31 deletions(-) diff --git a/components/features/hire/dashboard/ApplicationsContent.tsx b/components/features/hire/dashboard/ApplicationsContent.tsx index ef5314d4..170c75c1 100644 --- a/components/features/hire/dashboard/ApplicationsContent.tsx +++ b/components/features/hire/dashboard/ApplicationsContent.tsx @@ -27,19 +27,17 @@ import { UI_STATUS_MAP, ApplicationAction, ApplicationFilter, + LABEL_ID_STRING_MAP, + LABEL_ID_MAP, } from "@/lib/consts/application"; import { type ActionItem } from "@/components/ui/action-item"; import { ApplicationsCommandBar } from "./ApplicationsCommandBar"; import { FormCheckbox } from "@/components/EditForm"; -import { DropdownMenu } from "@/components/ui/dropdown-menu"; -import { useRouter, useSearchParams } from "next/navigation"; interface ApplicationsContentProps { applications: EmployerApplication[]; isSuperListing?: boolean; - statusId: number[]; isLoading?: boolean; - openChatModal: () => void; onApplicationClick: (application: EmployerApplication) => void; setSelectedApplication: (application: EmployerApplication) => void; onAction: ( @@ -47,8 +45,6 @@ interface ApplicationsContentProps { apps: EmployerApplication[], status?: number, ) => void; - applicantToDelete: EmployerApplication | null; - applicantToArchive: EmployerApplication | null; } export const ApplicationsContent = forwardRef< @@ -59,7 +55,6 @@ export const ApplicationsContent = forwardRef< applications, isSuperListing = false, isLoading, - openChatModal, onApplicationClick, setSelectedApplication, onAction, @@ -67,9 +62,6 @@ export const ApplicationsContent = forwardRef< ref, ) { const { isMobile } = useAppContext(); - const router = useRouter(); - const searchParams = useSearchParams(); - const selectedJobId = searchParams.get("jobId"); const [commandBarsVisible, setCommandBarsVisible] = useState(false); const [activeFilter, setActiveFilter] = useState("all"); @@ -79,7 +71,7 @@ export const ApplicationsContent = forwardRef< new Date(a.applied_at ?? "").getTime(), ); - const { app_statuses, get_app_status } = useDbRefs(); + const { app_statuses } = useDbRefs(); if (!app_statuses) return null; @@ -173,6 +165,7 @@ export const ApplicationsContent = forwardRef< const { selectedApplications, + selectedApplicationsData, toggleSelect, selectAll, unselectAll, @@ -194,11 +187,22 @@ export const ApplicationsContent = forwardRef< const someVisibleSelected = numVisibleSelected > 0 && numVisibleSelected < visibleApplications.length; - // separate statuses and visibility in the command bar and remove unused ones. - const command_bar_statuses = statuses.filter( - (status) => status.id !== "5" && status.id !== "7" && status.id !== "0", + const selectedAcceptedOrRejected = selectedApplicationsData.find( + (app) => + app.status === LABEL_ID_MAP.get("accepted") || + app.status === LABEL_ID_MAP.get("rejected"), ); + // separate statuses and visibility in the command bar and remove unused ones. + const command_bar_statuses = selectedAcceptedOrRejected + ? ["Status locked for accepted/rejected applications."] + : statuses.filter( + (status) => + status.id !== LABEL_ID_STRING_MAP.get("archived") && + status.id !== LABEL_ID_STRING_MAP.get("deleted") && + status.id !== LABEL_ID_STRING_MAP.get("pending"), + ); + const command_bar_visibility: ActionItem[] = [ { id: "archive", diff --git a/hooks/use-application-selection.ts b/hooks/use-application-selection.ts index 14de2994..a02e862b 100644 --- a/hooks/use-application-selection.ts +++ b/hooks/use-application-selection.ts @@ -6,26 +6,32 @@ import { EmployerApplication } from "@/lib/db/db.types"; * @param applications Visible applications */ export function useApplicationSelection(applications: EmployerApplication[]) { - const [selectedApplications, setSelectedApplications] = useState>(new Set()); + const [selectedApplications, setSelectedApplications] = useState>( + new Set(), + ); + + const selectedApplicationsData = applications.filter((app) => + selectedApplications.has(app.id!), + ); const toggleSelect = (id: string, next?: boolean) => { - setSelectedApplications((prev) => { - const nextSet = new Set(prev); - if (typeof next === "boolean") { - next ? nextSet.add(id) : nextSet.delete(id); - } else { - nextSet.has(id) ? nextSet.delete(id) : nextSet.add(id); - } - - return nextSet; - }); - }; - + setSelectedApplications((prev) => { + const nextSet = new Set(prev); + if (typeof next === "boolean") { + next ? nextSet.add(id) : nextSet.delete(id); + } else { + nextSet.has(id) ? nextSet.delete(id) : nextSet.add(id); + } + + return nextSet; + }); + }; + const selectAll = () => { // only select all visible applications. setSelectedApplications( - new Set(applications.map((application) => application.id!)) - ) + new Set(applications.map((application) => application.id!)), + ); }; const unselectAll = () => { @@ -33,14 +39,17 @@ export function useApplicationSelection(applications: EmployerApplication[]) { }; const toggleSelectAll = () => { - selectedApplications.size === applications.length ? unselectAll() : selectAll(); + selectedApplications.size === applications.length + ? unselectAll() + : selectAll(); }; return { selectedApplications, + selectedApplicationsData, toggleSelect, selectAll, unselectAll, toggleSelectAll, }; -} \ No newline at end of file +} From 8530748496b0db57e222868f1efeaa6347a4194a Mon Sep 17 00:00:00 2001 From: jayylmao <73204320+jayylmao@users.noreply.github.com> Date: Thu, 28 May 2026 22:16:25 +0800 Subject: [PATCH 11/24] style: match accept/reject status badge height to status dropdown --- components/features/hire/dashboard/ApplicationRow.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/features/hire/dashboard/ApplicationRow.tsx b/components/features/hire/dashboard/ApplicationRow.tsx index 45eab9df..e34f7c49 100644 --- a/components/features/hire/dashboard/ApplicationRow.tsx +++ b/components/features/hire/dashboard/ApplicationRow.tsx @@ -300,7 +300,7 @@ export function ApplicationRow({ defaultItem={defaultStatus} /> ) : ( - + )} From 057c485e136b01f907cde75534baec90352c6478 Mon Sep 17 00:00:00 2001 From: jayylmao <73204320+jayylmao@users.noreply.github.com> Date: Thu, 28 May 2026 22:44:12 +0800 Subject: [PATCH 12/24] style: make hire site header border more visible --- components/features/hire/header.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/features/hire/header.tsx b/components/features/hire/header.tsx index 8dc64a83..2e445c83 100644 --- a/components/features/hire/header.tsx +++ b/components/features/hire/header.tsx @@ -52,7 +52,7 @@ export const Header: React.FC = () => {
Date: Thu, 28 May 2026 22:46:05 +0800 Subject: [PATCH 13/24] style: remove shadow from job header --- components/features/hire/dashboard/JobHeader.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/features/hire/dashboard/JobHeader.tsx b/components/features/hire/dashboard/JobHeader.tsx index 1eddecc8..761f5004 100644 --- a/components/features/hire/dashboard/JobHeader.tsx +++ b/components/features/hire/dashboard/JobHeader.tsx @@ -137,7 +137,7 @@ export default function JobHeader({ ); return ( -
+
{isMobile ? (
From 3bb49311761a8b94929cdc3ad1e693f3c18cc5cc Mon Sep 17 00:00:00 2001 From: jayylmao <73204320+jayylmao@users.noreply.github.com> Date: Thu, 28 May 2026 22:46:46 +0800 Subject: [PATCH 14/24] refactor: use unified job header in applicant page --- app/hire/dashboard/applicant/page.tsx | 2 +- .../features/hire/dashboard/ApplicantPage.tsx | 757 +++++++++--------- 2 files changed, 372 insertions(+), 387 deletions(-) diff --git a/app/hire/dashboard/applicant/page.tsx b/app/hire/dashboard/applicant/page.tsx index f34531af..f85b1ae6 100644 --- a/app/hire/dashboard/applicant/page.tsx +++ b/app/hire/dashboard/applicant/page.tsx @@ -79,7 +79,7 @@ function ApplicantPageContent() { }; return ( - +
{ - setExitingBack(true); - if (window.history.length > 1) { - router.back(); - } else { - router.push("/dashboard"); - } - }; - useEffect(() => { if (application?.user_id) { void syncResumeURL(); @@ -127,8 +119,13 @@ export function ApplicantPage({ return Getting applicant information...; } + if (!application) { + return
Application not found.
; + } + return ( - <> +
+ - -
-
- {/* "header" ish portion */} -
-
-
-
- + {/* "header" ish portion */} +
+
+
+
+ +
+
+
+

+ {getFullName(application?.user)} +

+ {internshipPreferences?.internship_type === "credited" ? ( + + +
+ + Credited +
+
+ +

+ This applicant is looking for internships for credit +

+
+
+ ) : ( + + +
+ + Voluntary +
+
+ +

+ This applicant is looking for internships + voluntarily +

+
+
+ )}
-
-
-

- {getFullName(application?.user)} -

- {internshipPreferences?.internship_type === "credited" ? ( - - -
- - Credited -
-
- -

- This applicant is looking for internships for - credit -

-
-
- ) : ( - - -
- - Voluntary -
-
- -

- This applicant is looking for internships - voluntarily -

-
-
- )} +
+ {/* COntact info */} +
+ +

+ {application?.user?.phone_number} +

-
- {/* COntact info */} -
- -

- {application?.user?.phone_number} -

-
- {!isMobile && ( -

|

- )} -
- -

- {application?.user?.edu_verification_email} -

-
+ {!isMobile &&

|

} +
+ +

+ {application?.user?.edu_verification_email} +

- {/* links */} -
-
- - - {user?.portfolio_link ? ( - - - - ) : ( -

- -

- )} -
- -

- Applicant Portfolio +

+ {/* links */} +
+
+ + + {user?.portfolio_link ? ( + + + + ) : ( +

+

- -
-
+ )} + + +

+ Applicant Portfolio +

+
+ +
-
- - - {user?.github_link ? ( - - - - ) : ( -

- -

- )} -
- -

- Applicant Github +

+ + + {user?.github_link ? ( + + + + ) : ( +

+

- -
-
+ )} + + +

+ Applicant Github +

+
+
+
-
- - - {user?.linkedin_link ? ( - - - - ) : ( -

- -

- )} -
- -

- Applicant Linkedin +

+ + + {user?.linkedin_link ? ( + + + + ) : ( +

+

- -
-
+ )} + + +

+ Applicant Linkedin +

+
+
+
- {isMobile ? ( - <> - {hasChallengeSubmission && ( - - - {challengeSubmission} - - - )} - -
-
-

- Program / Degree -

-

- {user?.degree} -

-
-
-

Institution

-

- {to_university_name(user?.university)} -

-
-
-

- Expected Graduation Date -

-

- {formatMonth(user?.expected_graduation_date)} -

-
-
-
- + {isMobile ? ( + <> + {hasChallengeSubmission && ( -
-
-
-

- Expected Start Date -

-

- {formatOptionalTimestampDate( - internshipPreferences?.expected_start_date, - )} -

-
-
-

- Expected Duration (Hours) -

-

- {internshipPreferences?.expected_duration_hours} -

-
-
-
+ + {challengeSubmission} +
- - ) : ( - <> - {hasChallengeSubmission && ( - - - {challengeSubmission} - - - )} -
- {application?.user?.bio ? ( -
-

{application?.user?.bio}

- -
- ) : ( -
-

Applicant has not added a bio.

- -
- )} -
-

- Applicant Information -

- {application?.job && ( -

- Applying for: {application?.job?.title} -

- )} + )} + +
+
+

+ Program / Degree +

+

+ {user?.degree} +

- -
-
-

Education

-

- {to_university_name(user?.university)} -

-

{user?.degree}

-
-
-

- Expected Graduation Date -

-

- {formatMonth(user?.expected_graduation_date)} -

-
+
+

Institution

+

+ {to_university_name(user?.university)} +

- -
-

- Internship Requirements -

+
+

+ Expected Graduation Date +

+

+ {formatMonth(user?.expected_graduation_date)} +

+
+ + + +
-

+

Expected Start Date

-

+

{formatOptionalTimestampDate( internshipPreferences?.expected_start_date, )}

-
+

Expected Duration (Hours)

-

+

{internshipPreferences?.expected_duration_hours}

+ + + ) : ( + <> + {hasChallengeSubmission && ( + + + {challengeSubmission} + + + )} +
+ {application?.user?.bio ? ( +
+

{application?.user?.bio}

+ +
+ ) : ( +
+

Applicant has not added a bio.

+ +
+ )} +
+

+ Applicant Information +

+ {application?.job && ( +

+ Applying for: {application?.job?.title} +

+ )} +
- {/* other roles *note: will make this look better */} -
-
- {application?.job ? ( -

- Other Applied Roles -

- ) : ( -

- Applied Roles -

- )} +
+
+

Education

+

+ {to_university_name(user?.university)} +

+

{user?.degree}

-
- {userApplications?.length !== 0 ? ( - userApplications?.map((a) => ( - -

- {a.job?.title} -

-
- )) - ) : ( - <> - {application?.job ? ( -

- {" "} - No applied roles -

- ) : ( -

- {" "} - No other applied roles -

- )} - - )} +
+

+ Expected Graduation Date +

+

+ {formatMonth(user?.expected_graduation_date)} +

- - )} -
+ +
+

+ Internship Requirements +

+
+
+
+

+ Expected Start Date +

+

+ {formatOptionalTimestampDate( + internshipPreferences?.expected_start_date, + )} +

+
+
+

+ Expected Duration (Hours) +

+

+ {internshipPreferences?.expected_duration_hours} +

+
+
+
- {/* resume */} - {application?.resume_id ? ( -
- -
- ) : ( -
-
- -

- No Resume Available -

-
- This applicant has not uploaded a resume yet. + {/* other roles *note: will make this look better */} +
+
+ {application?.job ? ( +

+ Other Applied Roles +

+ ) : ( +

+ Applied Roles +

+ )} +
+
+ {userApplications?.length !== 0 ? ( + userApplications?.map((a) => ( + +

+ {a.job?.title} +

+
+ )) + ) : ( + <> + {application?.job ? ( +

+ {" "} + No applied roles +

+ ) : ( +

+ {" "} + No other applied roles +

+ )} + + )}
-
+ )}
+ + {/* resume */} + {application?.resume_id ? ( +
+ +
+ ) : ( +
+
+ +

+ No Resume Available +

+
+ This applicant has not uploaded a resume yet. +
+
+
+ )} - +
); } From dd87a0866ed10cf0571f91a0bb49ce2167576898 Mon Sep 17 00:00:00 2001 From: Jana Marie Bantolino Date: Fri, 29 May 2026 17:55:19 +0800 Subject: [PATCH 15/24] fix: reset password reset btn not clicking --- app/hire/reset-password/[hash]/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/hire/reset-password/[hash]/page.tsx b/app/hire/reset-password/[hash]/page.tsx index 77e1d3dc..e54e2db5 100644 --- a/app/hire/reset-password/[hash]/page.tsx +++ b/app/hire/reset-password/[hash]/page.tsx @@ -153,7 +153,7 @@ const ResetPasswordForm = ({ hash }: { hash: string }) => {
+ +
+ +
+ ); } + const prevApplicantId = otherApplicants[applicantIndex + 1]?.id; + const nextApplicantId = otherApplicants[applicantIndex - 1]?.id; + return (
- + +
+ {prevApplicantId ? ( + + + + + ) : ( + + )} + {nextApplicantId ? ( + + + + + ) : ( + + )} +
{/* "header" ish portion */} @@ -533,7 +611,7 @@ export function ApplicantPage({
) : ( -
+

diff --git a/components/features/hire/dashboard/JobHeader.tsx b/components/features/hire/dashboard/JobHeader.tsx index 761f5004..50f42631 100644 --- a/components/features/hire/dashboard/JobHeader.tsx +++ b/components/features/hire/dashboard/JobHeader.tsx @@ -19,14 +19,21 @@ import { toast } from "sonner"; export default function JobHeader({ job, onJobUpdate, + backHref, }: { job: Job; onJobUpdate?: (updates: Partial) => void; + backHref?: string; }) { const router = useRouter(); const { ownedJobs, update_job, delete_job } = useOwnedJobs(); const { saving } = useListingsBusinessLogic(ownedJobs); + const handleBack = () => { + if (backHref) return router.replace(backHref); + router.back(); + }; + const handleToggleActive = async () => { if (!job.id) return; @@ -145,7 +152,7 @@ export default function JobHeader({ +

+
+ + ) : ( + +

+ Step {isConfirming ? 2 : 1} of 2 +

+

{formLabel}

+

+ {isConfirming + ? "Please review the updated recipient details before submitting." + : "Update the email for this signing step so we can resend the request."} +

+ + {!isConfirming && ( +
+ ({ + id: party.id, + title: party.title, + email: party.email, + isMe: /student|initiator/i.test(party.title), + isEditable: party.id === targetSigningPartyId, + }))} + oldEmail={oldEmail} + editableEmail={recipientEmail} + onEditableEmailChange={setRecipientEmail} + editableDisabled={submitting} + editableError={editableError} + /> +
+ )} + +
+ {isConfirming && ( +
+
+ + + Please check if all your inputs are correct + +
+ ({ + id: party.id, + title: party.title, + email: + party.id === targetSigningPartyId + ? recipientEmail + : party.email, + isMe: /student|initiator/i.test(party.title), + }))} + isConfirmingRecipients + /> + +
+ )} + {statusType !== "idle" && ( +

+ {statusMessage} +

+ )} + +
+ {isConfirming && ( + + )} + +
+
+
+ )} +
+ ); +} diff --git a/lib/api/services.ts b/lib/api/services.ts index 099e449f..fa32d4f3 100644 --- a/lib/api/services.ts +++ b/lib/api/services.ts @@ -342,6 +342,40 @@ export const UserService = { ); }, + async correctFormRecipient(eventId: string, recipientEmail: string) { + return APIClient.post( + APIRouteBuilder("users").r("me", "edit-recipient").build(), + { + eventId, + recipientEmail, + }, + ); + }, + + async getCorrectFormRecipientContext(eventId: string) { + return APIClient.get< + FetchResponse & { + context?: { + eventId: string; + formLabel: string; + signingPartyTitle: string; + oldEmail: string; + targetSigningPartyId: string; + signingParties: { + id: string; + title: string; + email: string; + }[]; + }; + } + >( + APIRouteBuilder("users") + .r("me", "edit-recipient") + .p({ eventId }) + .build(), + ); + }, + async getMyResumes() { return APIClient.get( APIRouteBuilder("users").r("me", "resumes").build(), diff --git a/lib/ctx-auth.tsx b/lib/ctx-auth.tsx index 0b6e8d4a..c7939099 100644 --- a/lib/ctx-auth.tsx +++ b/lib/ctx-auth.tsx @@ -60,6 +60,17 @@ export const AuthContextProvider = ({ return response.user; }; + useEffect(() => { + if (isLoading || !isAuthenticated) return; + if (typeof window === "undefined") return; + + const redirectPath = sessionStorage.getItem("post_login_redirect"); + if (!redirectPath) return; + + sessionStorage.removeItem("post_login_redirect"); + router.replace(redirectPath); + }, [isAuthenticated, isLoading, router]); + useEffect(() => { refreshAuthentication(); }, []); @@ -115,8 +126,13 @@ export const AuthContextProvider = ({ const redirectIfNotLoggedIn = () => useEffect(() => { - if (!isLoading && !isAuthenticated) + if (!isLoading && !isAuthenticated) { + if (typeof window !== "undefined") { + const redirectPath = `${window.location.pathname}${window.location.search}`; + sessionStorage.setItem("post_login_redirect", redirectPath); + } router.push(`${process.env.NEXT_PUBLIC_API_URL}/auth/google`); + } }, [isAuthenticated, isLoading]); const redirectIfLoggedIn = () => From 5dae0262928862db34e0fbd11a14c9597eb54562 Mon Sep 17 00:00:00 2001 From: anaj00 Date: Tue, 2 Jun 2026 03:48:26 +0800 Subject: [PATCH 20/24] chore: close SOFI AI listing --- app/student/companies/sofi-ai/page.tsx | 2 +- .../components/ApplyPanel.tsx | 18 ++++++- .../super-listing/sofi-ai-marketing/page.tsx | 48 ++++++++++++++++++- 3 files changed, 64 insertions(+), 4 deletions(-) diff --git a/app/student/companies/sofi-ai/page.tsx b/app/student/companies/sofi-ai/page.tsx index b1506e7b..f107dd37 100644 --- a/app/student/companies/sofi-ai/page.tsx +++ b/app/student/companies/sofi-ai/page.tsx @@ -775,7 +775,7 @@ function FeaturedInternship() { { href: "/super-listing/sofi-ai-marketing", title: "Marketing Intern", - closed: false, + closed: true, icon: , }, ] as const; diff --git a/app/student/super-listing/sofi-ai-marketing/components/ApplyPanel.tsx b/app/student/super-listing/sofi-ai-marketing/components/ApplyPanel.tsx index b86ab477..b77fefa5 100644 --- a/app/student/super-listing/sofi-ai-marketing/components/ApplyPanel.tsx +++ b/app/student/super-listing/sofi-ai-marketing/components/ApplyPanel.tsx @@ -14,6 +14,8 @@ import type { SofiAiSubmissionForm } from "./types"; type ApplyPanelProps = { form: SofiAiSubmissionForm; + submissionsDisabled?: boolean; + submissionsDisabledMessage?: string; hasSubmitted: boolean; submittedEmail: string; isSubmitting: boolean; @@ -47,6 +49,8 @@ function AsteriskList({ items }: { items: readonly string[] }) { export function ApplyPanel({ form, + submissionsDisabled = false, + submissionsDisabledMessage = "Submissions are currently closed for this listing.", hasSubmitted, submittedEmail, isSubmitting, @@ -194,6 +198,11 @@ export function ApplyPanel({

Submission Form

+ {submissionsDisabled ? ( +
+ {submissionsDisabledMessage} +
+ ) : null}
void onSubmit(e)}>
@@ -202,6 +211,7 @@ export function ApplyPanel({
- {isDevelopment ? ( + {submissionsDisabled ? null : isDevelopment ? (

Captcha disabled in development.

@@ -300,7 +314,7 @@ export function ApplyPanel({
+ )} - )} - -
- - +
+ + )}
); From 993fa7a6f2ab5042347bb367f13283ee3ad6e822 Mon Sep 17 00:00:00 2001 From: Jana Marie Bantolino Date: Fri, 5 Jun 2026 03:13:23 +0800 Subject: [PATCH 23/24] chore: refactored command-menu to accept `React.Node`s --- .../student/search/SearchCommandBar.tsx | 146 ++++++++++-------- components/ui/command-menu.tsx | 45 ++---- 2 files changed, 89 insertions(+), 102 deletions(-) diff --git a/components/features/student/search/SearchCommandBar.tsx b/components/features/student/search/SearchCommandBar.tsx index 414050bb..d35aafb4 100644 --- a/components/features/student/search/SearchCommandBar.tsx +++ b/components/features/student/search/SearchCommandBar.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from "react"; -import { ActionItem } from "@/components/ui/action-item"; import { CommandMenu } from "@/components/ui/command-menu"; +import { Button } from "@/components/ui/button"; import { useAppContext } from "@/lib/ctx-app"; import { Job } from "@/lib/db/db.types"; import { motion, AnimatePresence } from "framer-motion"; @@ -14,7 +14,6 @@ import { List, } from "lucide-react"; import { useBlurTransition } from "@/components/animata/blur"; -import { cn } from "@/lib/utils"; interface SearchCommandBarProps { visible: boolean; @@ -88,7 +87,7 @@ export function SearchCommandBar({ const renderSidebar = () => ( {visible && isOpen && renderSidebar()} + {/* Mobile bottom bar */} {isMobile ? ( {visible && ( <> setIsOpen(!isOpen), - highlighted: isOpen, - highlightColor: - "bg-primary/10 text-primary font-semibold", - }, + , + , + , ], ]} /> @@ -178,59 +180,71 @@ export function SearchCommandBar({ )} ) : ( + // Desktop bottom bar {visible && ( <> + + , + , + , ], + [ - { - id: "apply", - label: `Apply (${selectedCount})`, - icon: Check, - onClick: onApply, - bgColor: "bg-primary/10", - fgColor: "text-primary", - }, + , ], [ - { - id: "job_list", - label: "Selected jobs", - icon: isOpen ? PanelRightClose : PanelRight, - onClick: () => setIsOpen(!isOpen), - highlighted: isOpen, - highlightColor: "bg-muted", - }, + , ], ]} /> diff --git a/components/ui/command-menu.tsx b/components/ui/command-menu.tsx index 31789c63..17134681 100644 --- a/components/ui/command-menu.tsx +++ b/components/ui/command-menu.tsx @@ -1,65 +1,38 @@ import React from "react"; import { cn } from "@/lib/utils"; -import { ActionItem } from "./action-item"; /** * A CommandMenu is a bar containing controls and other elements. * @param items (optional) Buttons and other elements to be stored in the CommandMenu. - * @param buttonLayout (optional) Buttons and other elements to be stored in the CommandMenu. * @param className (optional) Custom styling. */ export const CommandMenu = ({ items, - buttonLayout = "horizontal", className, }: { - // ActionItems are for buttons, but you can also put text. - items?: Array | Array>; - buttonLayout?: "vertical" | "horizontal"; + items?: React.ReactNode[] | React.ReactNode[][]; className?: string; }) => { - const isActionItem = (x: any): x is ActionItem => - x && typeof x === "object" && typeof x.id === "string"; - const groups = items && items.length > 0 && Array.isArray(items[0]) - ? (items as Array>) - : [items as Array]; + ? (items as React.ReactNode[][]) + : [items as React.ReactNode[]]; - const renderGroup = (group: Array, idx: number) => { + const renderGroup = (group: React.ReactNode[], idx: number) => { return ( {idx > 0 &&
} {group.map((item, idx) => - isActionItem(item) ? ( - - ) : ( + typeof item === "string" ? ( {item} - ), + ) : ( + {item} + ) )} ); From f8957142cfbd3a3c1c8f068c70a041185c35483f Mon Sep 17 00:00:00 2001 From: Jana Marie Bantolino Date: Fri, 5 Jun 2026 04:16:09 +0800 Subject: [PATCH 24/24] refactor: replace ActionItem with DropdownMenuItem across components --- app/hire/dashboard/applicant/page.tsx | 10 +- .../features/hire/dashboard/ApplicantPage.tsx | 21 +- .../hire/dashboard/ApplicationRow.tsx | 25 +-- .../hire/dashboard/ApplicationsCommandBar.tsx | 182 ++++++++++-------- .../hire/dashboard/ApplicationsContent.tsx | 121 +++++------- components/ui/action-item.tsx | 30 --- components/ui/command-menu.tsx | 4 +- components/ui/dropdown-menu.tsx | 119 +++++++++--- components/ui/status-badge.tsx | 46 ++++- 9 files changed, 291 insertions(+), 267 deletions(-) delete mode 100644 components/ui/action-item.tsx diff --git a/app/hire/dashboard/applicant/page.tsx b/app/hire/dashboard/applicant/page.tsx index dc6c9a09..aa9191c9 100644 --- a/app/hire/dashboard/applicant/page.tsx +++ b/app/hire/dashboard/applicant/page.tsx @@ -1,9 +1,9 @@ "use client"; -import { DB_STATUS_MAP, UI_STATUS_MAP } from "@/lib/consts/application"; +import { DB_STATUS_MAP } from "@/lib/consts/application"; import ContentLayout from "@/components/features/hire/content-layout"; import { ApplicantPage } from "@/components/features/hire/dashboard/ApplicantPage"; -import { type ActionItem } from "@/components/ui/action-item"; +import { type DropdownMenuItem } from "@/components/ui/dropdown-menu"; import { useEmployerApplications, useOwnedJobs, @@ -64,21 +64,17 @@ function ApplicantPageContent() { const getStatuses = (applicationId: string) => { return unique_app_statuses .filter((status) => status.id !== 7 && status.id !== 5 && status.id !== 0) - .map((status): ActionItem => { + .map((status): DropdownMenuItem => { const config = DB_STATUS_MAP[status.id]; - const uiProps = UI_STATUS_MAP.get(config?.key || "pending"); return { id: status.id.toString(), - label: status.name, - icon: uiProps?.icon, onClick: () => triggerAction( config?.action || "CHANGE_STATUS", [application], status.id, ), - destructive: uiProps?.destructive, }; }); }; diff --git a/components/features/hire/dashboard/ApplicantPage.tsx b/components/features/hire/dashboard/ApplicantPage.tsx index 41cdf661..f4deb5d9 100644 --- a/components/features/hire/dashboard/ApplicantPage.tsx +++ b/components/features/hire/dashboard/ApplicantPage.tsx @@ -1,8 +1,9 @@ -import { DB_STATUS_MAP, UI_STATUS_MAP } from "@/lib/consts/application"; import { PDFPreview } from "@/components/shared/pdf-preview"; import { UserPfp } from "@/components/shared/pfp"; -import { ActionItem } from "@/components/ui/action-item"; -import { DropdownMenu } from "@/components/ui/dropdown-menu"; +import { + DropdownMenu, + type DropdownMenuItem, +} from "@/components/ui/dropdown-menu"; import { HorizontalCollapsible } from "@/components/ui/horizontal-collapse"; import { useFile } from "@/hooks/use-file"; import { UserService } from "@/lib/api/services"; @@ -58,7 +59,7 @@ interface ApplicantPageProps { jobId: string | undefined; application: EmployerApplication | undefined; userApplications?: EmployerApplication[] | undefined; - statuses: ActionItem[]; + statuses: DropdownMenuItem[]; onArchive?: () => void; onDelete?: () => void; } @@ -93,20 +94,12 @@ export function ApplicantPage({ const [exitingBack, setExitingBack] = useState(false); - const { to_university_name, get_app_status } = useDbRefs(); + const { to_university_name } = useDbRefs(); const currentStatusId = application?.status?.toString() ?? "0"; - const config = DB_STATUS_MAP[application?.status || 0]; - const filterKey = config?.key || "pending"; - const defaultStatus: ActionItem = { + const defaultStatus: DropdownMenuItem = { id: currentStatusId, - label: get_app_status(application?.status || 0)?.name, - active: true, - disabled: false, - destructive: false, - highlighted: true, - highlightColor: UI_STATUS_MAP.get(filterKey)?.bgColor, }; const blurTransition = useBlurTransition(); diff --git a/components/features/hire/dashboard/ApplicationRow.tsx b/components/features/hire/dashboard/ApplicationRow.tsx index e34f7c49..2823bb91 100644 --- a/components/features/hire/dashboard/ApplicationRow.tsx +++ b/components/features/hire/dashboard/ApplicationRow.tsx @@ -1,7 +1,6 @@ // Single row component for the applications table // Props in (application data), events out (onView, onNotes, etc.) // No business logic - just presentation and event emission -import { ActionItem } from "@/components/ui/action-item"; import { Card } from "@/components/ui/card"; import { useAppContext } from "@/lib/ctx-app"; import { EmployerApplication } from "@/lib/db/db.types"; @@ -11,11 +10,7 @@ import { formatDateWithoutTime, formatTimestampDateWithoutTime, } from "@/lib/utils/date-utils"; -import { - DB_STATUS_MAP, - UI_STATUS_MAP, - ApplicationAction, -} from "@/lib/consts/application"; +import { ApplicationAction, DB_STATUS_MAP } from "@/lib/consts/application"; import { motion } from "framer-motion"; import { Archive, @@ -28,7 +23,7 @@ import { } from "lucide-react"; import { ActionButton } from "@/components/ui/action-button"; import { FormCheckbox } from "@/components/EditForm"; -import { DropdownMenu } from "@/components/ui/dropdown-menu"; +import { DropdownMenu, type DropdownMenuItem } from "@/components/ui/dropdown-menu"; import StatusBadge from "@/components/ui/status-badge"; import { useBlurTransition } from "@/components/animata/blur"; @@ -41,7 +36,7 @@ interface ApplicationRowProps { setSelectedApplication: (app: EmployerApplication) => void; checkboxSelected?: boolean; onToggleSelect?: (next: boolean) => void; - statuses: ActionItem[]; + statuses: DropdownMenuItem[]; } interface InternshipPreferences { @@ -63,7 +58,7 @@ export function ApplicationRow({ onAction, statuses, }: ApplicationRowProps) { - const { to_university_name, get_app_status } = useDbRefs(); + const { to_university_name } = useDbRefs(); const { isMobile } = useAppContext(); const preferences = (application.user?.internship_preferences || {}) as InternshipPreferences; @@ -73,18 +68,10 @@ export function ApplicationRow({ const staggerDelay = index < MAX_STAGGER_ROWS ? index * 0.05 : 0; const currentStatusId = application.status?.toString() ?? "0"; + const filterKey = DB_STATUS_MAP[application.status || 0]?.key || "pending"; - const config = DB_STATUS_MAP[application.status || 0]; - const filterKey = config?.key || "pending"; - - const defaultStatus: ActionItem = { + const defaultStatus: DropdownMenuItem = { id: currentStatusId, - label: get_app_status(application.status)?.name, - active: true, - disabled: false, - destructive: false, - highlighted: true, - highlightColor: UI_STATUS_MAP.get(filterKey)?.bgColor, }; const challengeSubmission = application.challenge_submission?.trim() ?? ""; const hasChallengeSubmission = challengeSubmission.length > 0; diff --git a/components/features/hire/dashboard/ApplicationsCommandBar.tsx b/components/features/hire/dashboard/ApplicationsCommandBar.tsx index 81e156fe..b145f3b2 100644 --- a/components/features/hire/dashboard/ApplicationsCommandBar.tsx +++ b/components/features/hire/dashboard/ApplicationsCommandBar.tsx @@ -1,8 +1,12 @@ -import { ActionItem } from "@/components/ui/action-item"; +import { Button } from "@/components/ui/button"; import { CommandMenu } from "@/components/ui/command-menu"; +import { + DropdownMenu, + type DropdownMenuItem, +} from "@/components/ui/dropdown-menu"; import { useAppContext } from "@/lib/ctx-app"; import { motion, AnimatePresence } from "framer-motion"; -import { CheckSquare, X } from "lucide-react"; +import { CheckSquare, Square, X } from "lucide-react"; interface ApplicationsCommandBarProps { visible: boolean; @@ -10,105 +14,121 @@ interface ApplicationsCommandBarProps { allVisibleSelected: boolean; someVisibleSelected: boolean; visibleApplicationsCount: number; - statuses: ActionItem[]; - applicationVisibility: ActionItem[]; + statuses: Array; + applicationVisibility: React.ReactNode[]; onUnselectAll: () => void; onSelectAll: () => void; - onDelete: () => void; - onStatusChange: () => void; } export function ApplicationsCommandBar({ visible, selectedCount, + allVisibleSelected, statuses, applicationVisibility, onUnselectAll, onSelectAll, }: ApplicationsCommandBarProps) { const { isMobile } = useAppContext(); + const statusItems = statuses.filter( + (status): status is DropdownMenuItem => typeof status !== "string", + ); + const statusMessage = statuses.find( + (status): status is string => typeof status === "string", + ); + const defaultStatusItem = statusItems[0] ?? { id: "0" }; + const selectAllLabel = allVisibleSelected ? "Unselect all" : "Select all"; + const SelectAllIcon = allVisibleSelected ? Square : CheckSquare; + const renderStatusControl = (className?: string) => + statusItems.length > 0 ? ( + + ) : statusMessage ? ( + {statusMessage} + ) : null; + const mobileStatusControl = renderStatusControl( + "h-9 w-full rounded-[0.33em]", + ); + const desktopStatusControl = renderStatusControl("h-9 rounded-[0.33em]"); - return isMobile ? ( - - {visible && ( - <> - - - - - - - - )} - - ) : ( + return ( {visible && ( <> - + {isMobile ? ( +
e.stopPropagation()} + className="flex w-full flex-col gap-2" + > +
+ + {mobileStatusControl && ( +
{mobileStatusControl}
+ )} +
+ {applicationVisibility} +
+
+
+ ) : ( + + + , + + {selectedCount} selected + , + , + ], + [desktopStatusControl, ...applicationVisibility], + ]} + /> + )}
)} diff --git a/components/features/hire/dashboard/ApplicationsContent.tsx b/components/features/hire/dashboard/ApplicationsContent.tsx index 170c75c1..797e4040 100644 --- a/components/features/hire/dashboard/ApplicationsContent.tsx +++ b/components/features/hire/dashboard/ApplicationsContent.tsx @@ -24,15 +24,15 @@ import { import { useEffect } from "react"; import { DB_STATUS_MAP, - UI_STATUS_MAP, ApplicationAction, ApplicationFilter, LABEL_ID_STRING_MAP, LABEL_ID_MAP, } from "@/lib/consts/application"; -import { type ActionItem } from "@/components/ui/action-item"; import { ApplicationsCommandBar } from "./ApplicationsCommandBar"; import { FormCheckbox } from "@/components/EditForm"; +import { type DropdownMenuItem } from "@/components/ui/dropdown-menu"; +import { ActionButton } from "@/components/ui/action-button"; interface ApplicationsContentProps { applications: EmployerApplication[]; @@ -75,35 +75,26 @@ export const ApplicationsContent = forwardRef< if (!app_statuses) return null; - const statuses = app_statuses - .map((status): ActionItem => { + const bulkStatusItems = app_statuses + .map((status): DropdownMenuItem => { // look up config for db id const config = DB_STATUS_MAP[status.id]; - // get ui properties using mapped key - const filterKey = - config?.key || (status.name.toLowerCase() as ApplicationFilter); - const uiProps = UI_STATUS_MAP.get(filterKey); + const handleClick = () => { + const applicationsToUpdate = Array.from(selectedApplications) + .map((id) => sortedApplications.find((app) => app.id === id)) + .filter((app): app is EmployerApplication => !!app); + + onAction( + config.action || "CHANGE_STATUS", + applicationsToUpdate, + status.id, + ); + }; return { id: status.id.toString(), - label: status.name, - icon: uiProps?.icon, - onClick: () => { - const applicationsToUpdate = Array.from(selectedApplications) - .map((id) => sortedApplications.find((app) => app.id === id)) - .filter((app): app is EmployerApplication => !!app); - - onAction( - config.action || "CHANGE_STATUS", - applicationsToUpdate, - status.id, - ); - }, - destructive: uiProps?.destructive, - highlightColor: uiProps?.fgColor, - bgColor: uiProps?.bgColor, - fgColor: uiProps?.fgColor, + onClick: handleClick, }; }) .filter(Boolean); @@ -112,11 +103,8 @@ export const ApplicationsContent = forwardRef< const getRowStatuses = (application: EmployerApplication) => { return app_statuses .filter((status) => status.id !== 7 && status.id !== 5 && status.id !== 0) - .map((status): ActionItem => { + .map((status): DropdownMenuItem => { const config = DB_STATUS_MAP[status.id]; - const filterKey = - config?.key || (status.name.toLowerCase() as ApplicationFilter); - const uiProps = UI_STATUS_MAP.get(filterKey); const handleClick = () => { onAction(config.action || "CHANGE_STATUS", [application], status.id); @@ -124,12 +112,7 @@ export const ApplicationsContent = forwardRef< return { id: status.id.toString(), - label: status.name, - icon: uiProps?.icon, onClick: handleClick, - destructive: uiProps?.destructive, - bgColor: uiProps?.bgColor, - fgColor: uiProps?.fgColor, }; }); }; @@ -195,20 +178,20 @@ export const ApplicationsContent = forwardRef< // separate statuses and visibility in the command bar and remove unused ones. const command_bar_statuses = selectedAcceptedOrRejected - ? ["Status locked for accepted/rejected applications."] - : statuses.filter( + ? [""] + : bulkStatusItems.filter( (status) => status.id !== LABEL_ID_STRING_MAP.get("archived") && status.id !== LABEL_ID_STRING_MAP.get("deleted") && status.id !== LABEL_ID_STRING_MAP.get("pending"), ); - const command_bar_visibility: ActionItem[] = [ - { - id: "archive", - label: activeFilter === "archived" ? "Unarchive" : "Archive", - icon: activeFilter === "archived" ? ArchiveRestore : Archive, - onClick: () => { + const command_bar_visibility = [ + { const apps = Array.from(selectedApplications) .map((id) => sortedApplications.find((app) => app.id === id)) .filter((app): app is EmployerApplication => !!app); @@ -217,24 +200,28 @@ export const ApplicationsContent = forwardRef< onAction(activeFilter === "archived" ? "UNARCHIVE" : "ARCHIVE", apps); unselectAll(); } - }, - }, - { - id: "delete", - label: "Delete", - icon: Trash2, - destructive: true, - onClick: () => { - const apps = Array.from(selectedApplications) - .map((id) => sortedApplications.find((app) => app.id === id)) - .filter((app): app is EmployerApplication => !!app); - - if (apps.length > 0) { - onAction("DELETE", apps); - unselectAll(); - } - }, - }, + }} + />, + ...(activeFilter === "archived" + ? [ + { + const apps = Array.from(selectedApplications) + .map((id) => sortedApplications.find((app) => app.id === id)) + .filter((app): app is EmployerApplication => !!app); + + if (apps.length > 0) { + onAction("DELETE", apps); + unselectAll(); + } + }} + />, + ] + : []), ]; // make command bars visible when an applicant is selected. @@ -304,13 +291,6 @@ export const ApplicationsContent = forwardRef< applicationVisibility={command_bar_visibility} onUnselectAll={unselectAll} onSelectAll={selectAll} - onDelete={() => { - const appToDelete = Array.from(selectedApplications) - .map((id) => sortedApplications.find((app) => app.id === id)) - .find((app): app is EmployerApplication => !!app); - if (appToDelete) onAction("DELETE", [appToDelete]); - }} - onStatusChange={() => {}} />
{visibleApplications.length ? ( @@ -361,13 +341,6 @@ export const ApplicationsContent = forwardRef< applicationVisibility={command_bar_visibility} onUnselectAll={unselectAll} onSelectAll={selectAll} - onDelete={() => { - const appToDelete = Array.from(selectedApplications) - .map((id) => sortedApplications.find((app) => app.id === id)) - .find((app): app is EmployerApplication => !!app); - if (appToDelete) onAction("DELETE", [appToDelete]); - }} - onStatusChange={() => {}} /> {isLoading ? (
diff --git a/components/ui/action-item.tsx b/components/ui/action-item.tsx deleted file mode 100644 index 089a6655..00000000 --- a/components/ui/action-item.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { LucideIcon } from "lucide-react"; - -/** - * An ActionItem is a button that performs some action. - * An example is a button that deletes an applicant from a list. - * @param id Identifier for internal use. - * @param label Text label that describes the button. - * @param icon (optional) An icon accompanying the text label that describes the button. - * @param onClick A function that performs some action upon clicking the button. - * @param active (optional) Track if the item is active. - * @param disabled (optional) Conditionally disable the button. - * @param destructive (optional) Descriptor of an action like "delete" that should be highlighted to emphasize its destructive nature. - * @param highlighted (optional) Emphasize a button if it corresponds to a current state, such as an active filter. - * @param highlightColor (optional) Specify a color for the highlighted button. - * @param bgColor (optional) Specify a background color for the button when it is hovered or pressed. - * @param fgColor (optional) Specify a foreground color for the button. - */ -export type ActionItem = { - id: string; - label?: string; - icon?: LucideIcon; - onClick?: () => void; - active?: boolean; - disabled?: boolean; - destructive?: boolean; - highlighted?: boolean; - highlightColor?: string; - bgColor?: string; - fgColor?: string; -}; diff --git a/components/ui/command-menu.tsx b/components/ui/command-menu.tsx index 17134681..008f8b33 100644 --- a/components/ui/command-menu.tsx +++ b/components/ui/command-menu.tsx @@ -21,7 +21,7 @@ export const CommandMenu = ({ const renderGroup = (group: React.ReactNode[], idx: number) => { return ( - {idx > 0 &&
} + {idx > 0 &&
} {group.map((item, idx) => typeof item === "string" ? ( e.stopPropagation()} className={cn( - "flex gap-4 px-6 py-2 w-full justify-center items-stretch text-xs bg-white border border-gray-200 transition bg-clip-border", + "flex gap-4 px-6 py-2 w-full justify-center items-center text-xs bg-white border border-gray-200 transition bg-clip-border", className, )} > diff --git a/components/ui/dropdown-menu.tsx b/components/ui/dropdown-menu.tsx index 89850489..3e4aa137 100644 --- a/components/ui/dropdown-menu.tsx +++ b/components/ui/dropdown-menu.tsx @@ -1,51 +1,79 @@ -import { useEffect, useState, useRef } from "react"; +import { useEffect, useState, useRef, type ReactNode } from "react"; import { cn } from "@/lib/utils"; import { ChevronDown, ChevronUp } from "lucide-react"; -import { ActionItem } from "./action-item"; -import StatusBadge from "./status-badge"; +import StatusBadge, { + getStatusFilterKey, + STATUS_COLOR_CLASSES, + STATUS_HOVER_CLASSES, +} from "./status-badge"; import { createPortal } from "react-dom"; import { AnimatePresence, motion } from "framer-motion"; +export type DropdownMenuItem = { + id: string; + onClick?: () => void; +}; + export const DropdownMenu = ({ className, items, defaultItem, enabled = true, + placement = "bottom", + placeholder, }: { className?: string; - items: ActionItem[]; - defaultItem: ActionItem; + items: DropdownMenuItem[]; + defaultItem: DropdownMenuItem; enabled?: boolean; + placement?: "top" | "bottom"; + placeholder?: ReactNode; }) => { const [isOpen, setIsOpen] = useState(false); - const [activeItem, setActiveItem] = useState(defaultItem); + const [activeItem, setActiveItem] = useState(defaultItem); + const [hasSelection, setHasSelection] = useState(!placeholder); + const activeStatusClass = hasSelection + ? STATUS_COLOR_CLASSES[getStatusFilterKey(parseInt(activeItem.id))] + : "border-gray-300 bg-background text-gray-700"; const menuRef = useRef(null); - const [pos, setPos] = useState<{ top: number; left: number; width: number }>({ - top: 0, - left: 0, - width: 0, - }); + const [pos, setPos] = useState<{ + top?: number; + bottom?: number; + left: number; + width: number; + }>({ left: 0, width: 0 }); useEffect(() => { if (!isOpen || !menuRef.current) return; - const rect = menuRef.current.getBoundingClientRect(); - setPos({ top: rect.bottom + 4, left: rect.left, width: rect.width }); - const handleScroll = () => { + const updatePosition = () => { const r = menuRef.current?.getBoundingClientRect(); - if (r) setPos({ top: r.bottom + 4, left: r.left, width: r.width }); + if (!r) return; + + setPos( + placement === "top" + ? { + bottom: window.innerHeight - r.top + 4, + left: r.left, + width: r.width, + } + : { top: r.bottom + 4, left: r.left, width: r.width }, + ); }; - window.addEventListener("scroll", handleScroll, true); - window.addEventListener("resize", handleScroll); + + updatePosition(); + window.addEventListener("scroll", updatePosition, true); + window.addEventListener("resize", updatePosition); return () => { - window.removeEventListener("scroll", handleScroll, true); - window.removeEventListener("resize", handleScroll); + window.removeEventListener("scroll", updatePosition, true); + window.removeEventListener("resize", updatePosition); }; - }, [isOpen]); + }, [isOpen, placement]); useEffect(() => { setActiveItem(defaultItem); - }, [defaultItem]); + setHasSelection(!placeholder); + }, [defaultItem, placeholder]); useEffect(() => { const handleClickOut = (e: MouseEvent) => { @@ -66,55 +94,84 @@ export const DropdownMenu = ({ ref={menuRef} aria-disabled={!enabled} className={cn( + "relative inline-flex min-w-32 overflow-hidden rounded-[0.33em] border transition aria-disabled:cursor-not-allowed aria-disabled:pointer-events-none aria-disabled:opacity-50", + activeStatusClass, className, - "relative border border-gray-300 rounded-[0.33em] bg-white inline-flex aria-disabled:text-muted-foreground/50 aria-disabled:bg-muted/50 aria-disabled:cursor-not-allowed aria-disabled:pointer-events-none", )} >
{ e.stopPropagation(); if (!enabled) return; setIsOpen((prev) => !prev); }} > -
- {isOpen ? : } - +
+ {hasSelection ? ( + + ) : ( + {placeholder} + )} + {isOpen ? ( + placement === "top" ? ( + + ) : ( + + ) + ) : placement === "top" ? ( + + ) : ( + + )}
{createPortal( {isOpen && ( e.stopPropagation()} > {items.map((item, idx) => { + const itemFilterKey = getStatusFilterKey(parseInt(item.id)); + const itemStatusClass = STATUS_COLOR_CLASSES[itemFilterKey]; + const itemHoverClass = STATUS_HOVER_CLASSES[itemFilterKey]; + return (
{ e.stopPropagation(); setActiveItem(item); + setHasSelection(true); setIsOpen(false); item.onClick?.(); }} > - +
); })} diff --git a/components/ui/status-badge.tsx b/components/ui/status-badge.tsx index 1888e04d..d09e57a0 100644 --- a/components/ui/status-badge.tsx +++ b/components/ui/status-badge.tsx @@ -1,15 +1,40 @@ import { cn } from "@/lib/utils"; import { useDbRefs } from "@/lib/db/use-refs"; import { DB_STATUS_MAP, UI_STATUS_MAP } from "@/lib/consts/application"; +import { Button } from "./button"; interface StatusBadgeProps { statusId: number; className?: string; } -export default function StatusBadge({ statusId, className }: StatusBadgeProps) { +export const getStatusFilterKey = (statusId: number) => { const config = DB_STATUS_MAP[statusId]; - const filterKey = config?.key || "pending"; + return config?.key || "pending"; +}; + +export const STATUS_COLOR_CLASSES: Record = { + all: "border-primary/30 bg-primary text-primary", + pending: "border-warning/40 bg-warning/90 text-white", + shortlisted: "border-blue-500/40 bg-blue-600/80 text-white", + accepted: "border-supportive/40 bg-supportive/80 text-white", + deleted: "border-destructive/40 bg-destructive/80 text-white", + rejected: "border-destructive/40 bg-destructive/80 text-white", + archived: "border-warning/40 bg-warning/80 text-white", +}; + +export const STATUS_HOVER_CLASSES: Record = { + all: "hover:bg-primary", + pending: "hover:bg-warning", + shortlisted: "hover:bg-blue-600", + accepted: "hover:bg-supportive", + deleted: "hover:bg-destructive", + rejected: "hover:bg-destructive", + archived: "hover:bg-warning", +}; + +export default function StatusBadge({ statusId, className }: StatusBadgeProps) { + const filterKey = getStatusFilterKey(statusId); const status = UI_STATUS_MAP.get(filterKey); const { to_app_status_name } = useDbRefs(); @@ -18,16 +43,19 @@ export default function StatusBadge({ statusId, className }: StatusBadgeProps) { } return ( -
- - {to_app_status_name(statusId)} -
+ + + {to_app_status_name(statusId)} + + ); }