diff --git a/app/hire/dashboard/applicant/page.tsx b/app/hire/dashboard/applicant/page.tsx index f34531af..aa9191c9 100644 --- a/app/hire/dashboard/applicant/page.tsx +++ b/app/hire/dashboard/applicant/page.tsx @@ -1,10 +1,13 @@ "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 { useEmployerApplications } from "@/hooks/use-employer-api"; +import { type DropdownMenuItem } from "@/components/ui/dropdown-menu"; +import { + useEmployerApplications, + useOwnedJobs, +} from "@/hooks/use-employer-api"; import { UserService } from "@/lib/api/services"; import { useDbRefs } from "@/lib/db/use-refs"; import { useSearchParams } from "next/navigation"; @@ -16,6 +19,7 @@ function ApplicantPageContent() { const applicationId = searchParams.get("applicationId"); const [loading, setLoading] = useState(true); const applications = useEmployerApplications(); + const jobs = useOwnedJobs(); const { app_statuses } = useDbRefs(); const { triggerAction } = useApplicationActions(applications.review); @@ -23,9 +27,10 @@ function ApplicantPageContent() { const userApplication = applications?.employer_applications.find( (a) => applicationId === a.id, ); - const otherApplications = applications?.employer_applications.filter( + const otherUserApplications = applications?.employer_applications.filter( (a) => a.user_id === userApplication?.user_id, ); + const jobId = userApplication?.job_id; const userId = userApplication?.user_id; useEffect(() => { @@ -59,32 +64,29 @@ 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, }; }); }; return ( - +
{ if (!userApplication) return; if (userApplication.visibility === "archived") { diff --git a/app/hire/dashboard/manage/page.tsx b/app/hire/dashboard/manage/page.tsx index 4983b6c0..e9ba02af 100644 --- a/app/hire/dashboard/manage/page.tsx +++ b/app/hire/dashboard/manage/page.tsx @@ -54,7 +54,11 @@ function ManageContent() { return (
- +
diff --git a/app/hire/dashboard/page.tsx b/app/hire/dashboard/page.tsx index 622d676a..f0dacec7 100644 --- a/app/hire/dashboard/page.tsx +++ b/app/hire/dashboard/page.tsx @@ -13,7 +13,7 @@ import { import { useMobile } from "@/hooks/use-mobile"; import { cn } from "@/lib/utils"; import { Briefcase, Plus } from "lucide-react"; -import { useState, useRef, useEffect } from "react"; +import { useRef, useEffect } from "react"; import { useAuthContext } from "../authctx"; import { Job } from "@/lib/db/db.types"; import { HeaderTitle } from "@/components/ui/text"; @@ -25,17 +25,15 @@ const NORMAL_LISTING_CREATE_PATH = "/listings/create"; function DashboardContent() { const { isMobile } = useMobile(); - const { isAuthenticated, redirectIfNotLoggedIn, loading } = useAuthContext(); + const { isAuthenticated, redirectIfNotLoggedIn } = useAuthContext(); const router = useRouter(); const superListingTapState = useRef({ count: 0, lastTapMs: 0 }); const profile = useProfile(); const applications = useEmployerApplications(); - const { ownedJobs, update_job, delete_job } = useOwnedJobs(); + const { ownedJobs, update_job, loading } = useOwnedJobs(); const activeJobs = ownedJobs.filter((job) => job.is_active); const inactiveJobs = ownedJobs.filter((job) => !job.is_active); - const [isLoading, setLoading] = useState(true); - redirectIfNotLoggedIn(); const handleAddListingClick = () => { @@ -65,14 +63,6 @@ function DashboardContent() { return result; }; - useEffect(() => { - if (!ownedJobs) { - setLoading(true); - } else { - setLoading(false); - } - }, [ownedJobs, activeJobs, inactiveJobs]); - if (loading || !isAuthenticated()) { return ( @@ -114,7 +104,7 @@ function DashboardContent() { employerId={profile.data?.id || ""} updateJob={handleUpdateJob} onAddListingClick={handleAddListingClick} - isLoading={isLoading} + isLoading={loading} /> {isMobile && (
- ) -} \ No newline at end of file + ); +} 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 }) => {
+
+
+ + ) : ( + +

+ 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: isCurrentUserSigningParty(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: isCurrentUserSigningParty(party.title), + }))} + isConfirmingRecipients + /> + +
+ )} + {statusType !== "idle" && ( +

+ {statusMessage} +

+ )} + +
+ {isConfirming && ( + + )} + +
+
+
+ )} + + ); +} diff --git a/app/student/search/page.tsx b/app/student/search/page.tsx index c4a9d653..56875364 100644 --- a/app/student/search/page.tsx +++ b/app/student/search/page.tsx @@ -30,6 +30,7 @@ import { Loader } from "@/components/ui/loader"; import { motion, AnimatePresence } from "framer-motion"; import type { ApplyPayload } from "@/components/modals/components/ApplyModal"; import { toast } from "sonner"; +import { SearchCommandBar } from "@/components/features/student/search/SearchCommandBar"; export default function SearchPage() { const searchParams = useSearchParams(); @@ -331,73 +332,22 @@ export default function SearchPage() { UI ====================================================================================== */ - // page toolbar (floating action bar) - const FloatingActionBar = ( - - {selectMode && ( - -
- - - - {selectedIds.size} selected - -
- -
- -
- - -
- - )} - - ); - return ( <> {/* Floating action bar */} - {!isMobile && FloatingActionBar} + { + setSelectMode(false); + clearSelection(); + }} + onUnselectPage={unselectAllOnPage} + onSelectPage={selectAllOnPage} + onApply={openMassApply} + onToggleSelect={toggleSelect} + />
{jobs.isPending ? ( @@ -448,7 +398,7 @@ export default function SearchPage() {
)} -
+
- - {/* Mobile mass apply toolbar */} - {selectMode && ( - -
- - {selectedIds.size} selected - -
- - -
-
-
- )}
) : ( // Desktop split view @@ -563,7 +475,7 @@ export default function SearchPage() { )} -
+
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({
+ +
+ +
+ ); + } + + const prevApplicantId = otherApplicants[applicantIndex + 1]?.id; + const nextApplicantId = otherApplicants[applicantIndex - 1]?.id; + return ( - <> +
+ - + + ) : ( + + )} + {nextApplicantId ? ( + + + + + ) : ( + + )} +
+
- - -
-
- {/* "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. +
+
+
+ )} - +
); } diff --git a/components/features/hire/dashboard/ApplicationRow.tsx b/components/features/hire/dashboard/ApplicationRow.tsx index 45eab9df..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; @@ -300,7 +287,7 @@ export function ApplicationRow({ defaultItem={defaultStatus} /> ) : ( - + )} diff --git a/components/features/hire/dashboard/ApplicationsCommandBar.tsx b/components/features/hire/dashboard/ApplicationsCommandBar.tsx index fab5f137..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,126 +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 ef5314d4..797e4040 100644 --- a/components/features/hire/dashboard/ApplicationsContent.tsx +++ b/components/features/hire/dashboard/ApplicationsContent.tsx @@ -24,22 +24,20 @@ 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 { DropdownMenu } from "@/components/ui/dropdown-menu"; -import { useRouter, useSearchParams } from "next/navigation"; +import { type DropdownMenuItem } from "@/components/ui/dropdown-menu"; +import { ActionButton } from "@/components/ui/action-button"; 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,39 +71,30 @@ 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; - 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); @@ -120,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); @@ -132,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, }; }); }; @@ -173,6 +148,7 @@ export const ApplicationsContent = forwardRef< const { selectedApplications, + selectedApplicationsData, toggleSelect, selectAll, unselectAll, @@ -194,17 +170,28 @@ 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"), ); - const command_bar_visibility: ActionItem[] = [ - { - id: "archive", - label: activeFilter === "archived" ? "Unarchive" : "Archive", - icon: activeFilter === "archived" ? ArchiveRestore : Archive, - onClick: () => { + // separate statuses and visibility in the command bar and remove unused ones. + const command_bar_statuses = selectedAcceptedOrRejected + ? [""] + : 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 = [ + { const apps = Array.from(selectedApplications) .map((id) => sortedApplications.find((app) => app.id === id)) .filter((app): app is EmployerApplication => !!app); @@ -213,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. @@ -300,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 ? ( @@ -357,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/features/hire/dashboard/JobHeader.tsx b/components/features/hire/dashboard/JobHeader.tsx index 1eddecc8..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; @@ -137,7 +144,7 @@ export default function JobHeader({ ); return ( -
+
{isMobile ? (
@@ -145,7 +152,7 @@ export default function JobHeader({ + )} + + ); +} + +export function SearchCommandBar({ + visible, + selected, + selectedCount, + onCancel, + onUnselectPage, + onSelectPage, + onApply, + onToggleSelect, +}: SearchCommandBarProps) { + const { isMobile } = useAppContext(); + const [isOpen, setIsOpen] = useState(false); + + // 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()} + + {/* Mobile bottom bar */} + {isMobile ? ( + + {visible && ( + <> + + + + , + , + , + ], + ]} + /> + + + )} + + ) : ( + // Desktop bottom bar + + {visible && ( + <> + + + + , + , + , + ], + + [ + , + ], + [ + , + ], + ]} + /> + + + )} + + )} + + ); +} 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 44c0597f..008f8b33 100644 --- a/components/ui/command-menu.tsx +++ b/components/ui/command-menu.tsx @@ -1,73 +1,41 @@ -import React, { useState, useEffect } from "react"; +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" && - typeof x.onClick === "function"; + const groups = + items && items.length > 0 && Array.isArray(items[0]) + ? (items as React.ReactNode[][]) + : [items as React.ReactNode[]]; - const groups = (items && items.length > 0 && Array.isArray(items[0])) - ? (items as Array>) - : [items as Array]; - - const renderGroup = (group: Array, idx: number) => { + const renderGroup = (group: React.ReactNode[], idx: number) => { return ( - {idx > 0 && ( -
- )} - {group.map((item, idx) => - isActionItem(item) ? ( - - ) : ( + {idx > 0 &&
} + {group.map((item, idx) => + typeof item === "string" ? ( - {item as React.ReactNode} + {item} - ), + ) : ( + {item} + ) )} - ) + ); }; return ( @@ -75,7 +43,7 @@ export const CommandMenu = ({ role="toolbar" onClick={(e) => e.stopPropagation()} className={cn( - "flex gap-4 px-6 py-2 justify-center items-stretch text-xs bg-white border border-gray-300 rounded-[0.33em] transition bg-clip-padding 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)} + + ); } 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 +} diff --git a/hooks/use-employer-api.tsx b/hooks/use-employer-api.tsx index 6dc31042..8f414470 100644 --- a/hooks/use-employer-api.tsx +++ b/hooks/use-employer-api.tsx @@ -225,7 +225,6 @@ export function useOwnedJobs( useEffect(() => { fetchOwnedJobs(); - setLoading(false); }, [fetchOwnedJobs]); // Client-side filtering 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/consts/application.ts b/lib/consts/application.ts index d84a1f0f..0f770020 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" }, ], ]); @@ -89,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], +]); 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 = () => 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: {