From 7306d1618d3fa74076b8cb35f7ae773e6c0b0a83 Mon Sep 17 00:00:00 2001 From: Mo David Date: Tue, 15 Sep 2026 12:28:27 +0800 Subject: [PATCH 01/93] feat: finalize applicant statuses when arching applicants and stuff --- app/student/applications/page.tsx | 14 +++- .../hire/account/digest-optout-dialog.tsx | 5 ++ .../features/hire/dashboard/ApplicantPage.tsx | 24 ++++++ .../hire/dashboard/ApplicationRow.tsx | 14 ++++ .../hire/dashboard/ApplicationsContent.tsx | 36 +++++++- .../features/hire/dashboard/JobHeader.tsx | 50 ++++++++++- components/modals/CloseListingModal.tsx | 76 +++++++++++++++++ components/modals/DeleteJobListingModal.tsx | 14 +++- components/modals/modal-registry.tsx | 44 ++++++++++ components/ui/action-button.tsx | 84 ++++++++++++------- hooks/use-application-actions.ts | 19 ++++- 11 files changed, 340 insertions(+), 40 deletions(-) create mode 100644 components/modals/CloseListingModal.tsx diff --git a/app/student/applications/page.tsx b/app/student/applications/page.tsx index de37b75d7..26aa90931 100644 --- a/app/student/applications/page.tsx +++ b/app/student/applications/page.tsx @@ -426,9 +426,17 @@ const ApplicationCard = ({ challengeTitleFromJoin.trim().length > 0; const isUnavailable = !job?.is_active || job?.is_deleted; const canOpenListing = !!job?.id && !isUnavailable; - const statusLabel = to_app_status_name(application.status) ?? "Pending"; - let statusBadgeType: "destructive" | "supportive" | "warning" = "warning"; - if (statusLabel === "Rejected") statusBadgeType = "destructive"; + // A still-pending application whose listing has since closed reads as + // "Pending" forever otherwise — no stored state needed, since is_active/ + // is_deleted already ride along on application.job (plan D7). + const isClosedWhilePending = application.status === 0 && isUnavailable; + const statusLabel = isClosedWhilePending + ? "Listing closed" + : (to_app_status_name(application.status) ?? "Pending"); + let statusBadgeType: "default" | "destructive" | "supportive" | "warning" = + "warning"; + if (isClosedWhilePending) statusBadgeType = "default"; + else if (statusLabel === "Rejected") statusBadgeType = "destructive"; else if (statusLabel === "Accepted" || statusLabel === "Hired") statusBadgeType = "supportive"; else statusBadgeType = "warning"; diff --git a/components/features/hire/account/digest-optout-dialog.tsx b/components/features/hire/account/digest-optout-dialog.tsx index ff47ef6cc..f2f92bdce 100644 --- a/components/features/hire/account/digest-optout-dialog.tsx +++ b/components/features/hire/account/digest-optout-dialog.tsx @@ -147,6 +147,11 @@ export function DigestOptoutModalContent({ +

+ Pending applicants on the listings you close will be notified that the + listing has closed. +

+
+ {onArchive && ( + + )} diff --git a/components/features/hire/dashboard/ApplicationRow.tsx b/components/features/hire/dashboard/ApplicationRow.tsx index 1a7f58512..24e94305b 100644 --- a/components/features/hire/dashboard/ApplicationRow.tsx +++ b/components/features/hire/dashboard/ApplicationRow.tsx @@ -74,6 +74,15 @@ export function ApplicationRow({ const challengeSubmission = application.challenge_submission?.trim() ?? ""; const hasChallengeSubmission = challengeSubmission.length > 0; + // An unfinalized applicant (pending/shortlisted) can't be archived — they'd + // vanish from the employer's own view while the student keeps waiting, and + // no notification can fix that (plan D3/D4). Unarchiving is never gated. + const isArchived = application.visibility === "archived"; + const isFinalized = application.status === 4 || application.status === 6; + const canArchive = isArchived || isFinalized; + const ARCHIVE_DISABLED_LABEL = + "Accept or reject this applicant before archiving them."; + return isMobile ? (
)} {application.visibility === "archived" && ( @@ -307,9 +319,11 @@ export function ApplicationRow({ onAction("ARCHIVE", [application]); } }} + enabled={canArchive} label={ application.visibility === "archived" ? "Unarchive" : "Archive" } + disabledLabel={ARCHIVE_DISABLED_LABEL} /> )} {application.visibility === "archived" && ( diff --git a/components/features/hire/dashboard/ApplicationsContent.tsx b/components/features/hire/dashboard/ApplicationsContent.tsx index be39a87b9..6fce07ef9 100644 --- a/components/features/hire/dashboard/ApplicationsContent.tsx +++ b/components/features/hire/dashboard/ApplicationsContent.tsx @@ -3,6 +3,7 @@ "use client"; import { forwardRef, useImperativeHandle } from "react"; +import { useSearchParams } from "next/navigation"; import { useApplicationSelection } from "@/hooks/use-application-selection"; import { Badge, @@ -71,8 +72,28 @@ export const ApplicationsContent = forwardRef< ) { const { isMobile } = useAppContext(); + // Seeds the initial tab from ?filter=, so "Review them first" on the + // close-listing warning can deep-link straight to the Pending tab + // (plan §4.3). activeFilter still lives as component state afterwards — + // the URL only sets where it starts. + const searchParams = useSearchParams(); + const VALID_FILTERS: ApplicationFilter[] = [ + "all", + "pending", + "shortlisted", + "accepted", + "rejected", + "archived", + ]; + const requestedFilter = searchParams.get("filter"); + const initialFilter = + requestedFilter && (VALID_FILTERS as string[]).includes(requestedFilter) + ? (requestedFilter as ApplicationFilter) + : "all"; + const [commandBarsVisible, setCommandBarsVisible] = useState(false); - const [activeFilter, setActiveFilter] = useState("all"); + const [activeFilter, setActiveFilter] = + useState(initialFilter); const sortedApplications = applications.toSorted( (a, b) => new Date(b.applied_at ?? "").getTime() - @@ -184,6 +205,17 @@ export const ApplicationsContent = forwardRef< app.status === LABEL_ID_MAP.get("rejected"), ); + // Archiving an unfinalized applicant hides them from view while the + // student keeps waiting, so bulk-archive is blocked while any selected row + // is still pending/shortlisted (plan D3/D4/D5). Unarchiving is never + // gated — only the selection contents are inspected, never the checkboxes + // themselves. + const unfinalizedSelectedCount = selectedApplicationsData.filter( + (app) => app.status === 0 || app.status === 1, + ).length; + const bulkArchiveDisabled = + activeFilter !== "archived" && unfinalizedSelectedCount > 0; + // separate statuses and visibility in the command bar and remove unused ones. const command_bar_statuses = selectedAcceptedOrRejected ? [""] @@ -199,6 +231,8 @@ export const ApplicationsContent = forwardRef< key="archive" icon={activeFilter === "archived" ? ArchiveRestore : Archive} label={activeFilter === "archived" ? "Unarchive" : "Archive"} + enabled={!bulkArchiveDisabled} + disabledLabel={`${unfinalizedSelectedCount} of ${selectedApplications.size} selected haven't been accepted or rejected yet.`} onClick={() => { const apps = Array.from(selectedApplications) .map((id) => sortedApplications.find((app) => app.id === id)) diff --git a/components/features/hire/dashboard/JobHeader.tsx b/components/features/hire/dashboard/JobHeader.tsx index 1cf044672..8a9e8634b 100644 --- a/components/features/hire/dashboard/JobHeader.tsx +++ b/components/features/hire/dashboard/JobHeader.tsx @@ -1,7 +1,10 @@ import { useState } from "react"; import { Button } from "@betterinternship/components"; import { Toggle } from "@/components/ui/toggle"; -import { useOwnedJobs } from "@/hooks/use-employer-api"; +import { + useEmployerApplications, + useOwnedJobs, +} from "@/hooks/use-employer-api"; import { Job } from "@/lib/db/db.types"; import { formatDateWithoutTime } from "@/lib/utils"; import { cn } from "@betterinternship/components"; @@ -35,13 +38,24 @@ export default function JobHeader({ const [togglingActive, setTogglingActive] = useState(false); const openNotificationsRequiredModal = useNotificationsRequiredModal(); + // Counts feed the close-listing warning (plan §4.3) — already loaded here, + // no new endpoint needed. + const { employer_applications } = useEmployerApplications(); + const jobApplications = employer_applications.filter( + (app) => app.job_id === job.id && app.visibility === "visible", + ); + const pendingCount = jobApplications.filter((app) => app.status === 0).length; + const shortlistedCount = jobApplications.filter( + (app) => app.status === 1, + ).length; + const handleBack = () => { if (backHref) return router.replace(backHref); router.back(); }; - const handleToggleActive = async () => { - if (!job.id || job.paused) return; + const performToggleActive = async () => { + if (!job.id) return; setTogglingActive(true); try { const updates = { is_active: !job.is_active }; @@ -59,6 +73,32 @@ export default function JobHeader({ } }; + const handleToggleActive = async () => { + if (!job.id || job.paused) return; + + // Closing (active -> inactive) with unanswered applicants gets a warning + // first — not a gate, and reopening never shows it (plan §4.3/D3). + if (job.is_active && pendingCount > 0) { + modalRegistry.closeListing.open({ + jobTitle: job.title ?? "this listing", + pendingCount, + shortlistedCount, + isProcessing: togglingActive, + onConfirm: () => { + modalRegistry.closeListing.close(); + void performToggleActive(); + }, + onReviewFirst: () => { + modalRegistry.closeListing.close(); + router.push(`/dashboard/manage?jobId=${job.id}&filter=pending`); + }, + }); + return; + } + + await performToggleActive(); + }; + const handleReEnable = async () => { if (!job.id) return; setReEnabling(true); @@ -88,6 +128,10 @@ export default function JobHeader({ const handleDelete = () => { modalRegistry.deleteListing.open({ job, + // Deleting an already-inactive listing is not a transition and + // notifies no one (D10), so the harder copy only names a count while + // the listing is still active. + pendingApplicantCount: job.is_active ? pendingCount : 0, isProcessing: saving, onConfirm: () => { if (job.id) { diff --git a/components/modals/CloseListingModal.tsx b/components/modals/CloseListingModal.tsx new file mode 100644 index 000000000..f6d843614 --- /dev/null +++ b/components/modals/CloseListingModal.tsx @@ -0,0 +1,76 @@ +import { Ban } from "lucide-react"; +import { HeaderIcon } from "../ui/text"; +import { Button } from "@betterinternship/components"; + +interface CloseListingModalProps { + jobTitle: string; + pendingCount: number; + shortlistedCount: number; + isProcessing: boolean; + onConfirm: () => void; + onReviewFirst: () => void; + onCancel: () => void; +} + +/** + * Warns before closing a listing leaves its pending applicants without a + * decision — not a gate, just a notice: closing proceeds either way, but the + * employer can detour into the Pending tab first + * (Docs/plans/APPLICANT_STATUS_FINALIZATION_PLAN.md §4.3, D3/D11). + */ +export default function CloseListingModal({ + jobTitle, + pendingCount, + shortlistedCount, + isProcessing, + onConfirm, + onReviewFirst, + onCancel, +}: CloseListingModalProps) { + return ( +
+
+ +

Close {jobTitle}?

+
+ + {pendingCount} applicant{pendingCount === 1 ? "" : "s"} haven't + heard back from you. They'll be told this listing has closed. + + {shortlistedCount > 0 && ( + + {shortlistedCount} shortlisted applicant + {shortlistedCount === 1 ? "" : "s"} won't be notified — accept or + reject them if you're done with them. + + )} + + {/* action buttons */} +
+ + + +
+
+ ); +} diff --git a/components/modals/DeleteJobListingModal.tsx b/components/modals/DeleteJobListingModal.tsx index acfd2dbcc..62237f30a 100644 --- a/components/modals/DeleteJobListingModal.tsx +++ b/components/modals/DeleteJobListingModal.tsx @@ -8,6 +8,13 @@ interface DeleteJobListingProps { isProcessing: boolean; onConfirm: () => void; onCancel: () => void; + /** + * Pending, visible applicants who'd be told this listing closed — 0 when + * the listing is already inactive, since deleting it then is not a + * transition and notifies no one + * (Docs/plans/APPLICANT_STATUS_FINALIZATION_PLAN.md D10/§4.3). + */ + pendingApplicantCount?: number; } export default function DeleteJobListingModal({ @@ -15,6 +22,7 @@ export default function DeleteJobListingModal({ isProcessing, onConfirm, onCancel, + pendingApplicantCount = 0, }: DeleteJobListingProps) { if (!job) return null; @@ -24,7 +32,11 @@ export default function DeleteJobListingModal({

Delete {job.title}?

- This action is permanent and cannot be undone. + + {pendingApplicantCount > 0 + ? `This is permanent. ${pendingApplicantCount} applicant${pendingApplicantCount === 1 ? "" : "s"} will be told this listing has closed, and you won't be able to accept or reject anyone afterwards.` + : "This action is permanent and cannot be undone."} + {/* action buttons */}
diff --git a/components/modals/modal-registry.tsx b/components/modals/modal-registry.tsx index 939336583..d86e95da6 100644 --- a/components/modals/modal-registry.tsx +++ b/components/modals/modal-registry.tsx @@ -25,6 +25,7 @@ import { ApplicationAction } from "@/lib/consts/application"; import { EmployerApplication, Resume } from "@/lib/db/db.types"; import ApplicationActionModal from "./ApplicationActionModal"; import DeleteJobListingModal from "./DeleteJobListingModal"; +import CloseListingModal from "./CloseListingModal"; import { Job, PublicUser } from "@/lib/db/db.types"; import DeleteResumeModal from "./DeleteResumeModal"; import { AddResumeModal } from "../features/student/profile/AddResumeModal"; @@ -115,10 +116,12 @@ export const useModalRegistry = () => { job, isProcessing, onConfirm, + pendingApplicantCount, }: { job: Job; isProcessing: boolean; onConfirm: () => void; + pendingApplicantCount?: number; }) => open( "delete-listing", @@ -128,6 +131,7 @@ export const useModalRegistry = () => { isProcessing={isProcessing} onConfirm={onConfirm} onCancel={() => close("delete-listing")} + pendingApplicantCount={pendingApplicantCount} />, { title: `Delete ${job.title}`, @@ -138,6 +142,46 @@ export const useModalRegistry = () => { ), close: () => close("delete-listing"), }, + // Warns before closing a listing leaves pending applicants unanswered + // (Docs/plans/APPLICANT_STATUS_FINALIZATION_PLAN.md §4.3). Not a gate — + // "Close listing" always proceeds. + closeListing: { + open: ({ + jobTitle, + pendingCount, + shortlistedCount, + isProcessing, + onConfirm, + onReviewFirst, + }: { + jobTitle: string; + pendingCount: number; + shortlistedCount: number; + isProcessing: boolean; + onConfirm: () => void; + onReviewFirst: () => void; + }) => + open( + "close-listing", + DefaultModalLayout, + close("close-listing")} + />, + { + title: `Close ${jobTitle}`, + closeOnBackdropClick: true, + closeOnEscapeKey: true, + showHeaderDivider: true, + }, + ), + close: () => close("close-listing"), + }, // modal for sharing a job listing's short link // (Docs/plans/JOB_SHORT_LINKS_IMPLEMENTATION_PLAN.md D12). shareJob: { diff --git a/components/ui/action-button.tsx b/components/ui/action-button.tsx index a96988d67..bfcd7af23 100644 --- a/components/ui/action-button.tsx +++ b/components/ui/action-button.tsx @@ -1,8 +1,14 @@ import { LucideIcon } from "lucide-react"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; export const ActionButton = ({ icon: Icon, - label = "Button", + label, + disabledLabel, onClick, enabled = true, destructive = false, @@ -11,42 +17,60 @@ export const ActionButton = ({ }: { icon: LucideIcon; label?: string; + /** Tooltip shown while disabled; falls back to `label` when omitted. */ + disabledLabel?: string; onClick: (e: any) => void; enabled?: boolean; destructive?: boolean; size?: number; notification?: boolean; }) => { - return ( - <> - - + >
+ )} + {Icon && } + + ); + + const tooltip = enabled ? label : (disabledLabel ?? label); + if (!tooltip) return button; + + return ( + + {/* A disabled ) : ( - )} @@ -183,21 +245,29 @@ export function ApplicantPage({ query: { applicationId: nextApplicant.id }, }} > - ) : ( - )} - )} -
-
+ } + /> + +
+
{/* "header" ish portion */}
@@ -209,160 +279,104 @@ export function ApplicantPage({ />
-
-

- {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)} +

{/* Contact info */} - {application?.user?.phone_number !== null && ( + {application?.user?.phone_number && ( {application?.user?.phone_number} )} - {!isMobile && ( -

|

- )} - {application?.user?.edu_verification_email !== null && ( + {application?.user?.email && ( - - {application?.user?.edu_verification_email} + + {application?.user?.email} )}
{/* links */} -
-
- - - {user?.portfolio_link ? ( + {hasSocialLinks && ( +
+ {user?.portfolio_link && ( + + - ) : ( -

- + + +

+ Applicant Portfolio

- )} -
- -

- Applicant Portfolio -

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

- + + +

+ Applicant Github

- )} -
- -

- Applicant Github -

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

- + + +

+ Applicant Linkedin

- )} -
- -

- Applicant Linkedin -

-
-
+ +
+ )}
-
+ )}
@@ -372,7 +386,7 @@ export function ApplicantPage({
{hasChallengeSubmission && ( @@ -380,10 +394,7 @@ export function ApplicantPage({ )} - +
- + Education - + {to_university_name(user?.university)} - {user?.degree}
- - Expected Graduation Date + + Degree + + + {user?.degree} + +
+
+ + Date Applied - - {formatMonth(user?.expected_graduation_date)} + + {formatDateWithoutTime(application.applied_at)}
+ {user?.expected_graduation_date && ( +
+ + Expected Graduation Date + + + {formatMonth(user.expected_graduation_date)} + +
+ )}
- +
- + Expected Start Date - - {formatOptionalTimestampDate( + + {formatTimestampDateWithoutTime( internshipPreferences?.expected_start_date, )}
-
- - Expected Duration (Hours) - - - {internshipPreferences?.expected_duration_hours || - "No specified duration"} + {internshipPreferences?.expected_duration_hours != + null && ( +
+ + Expected Duration (Hours) + + + {internshipPreferences.expected_duration_hours} + +
+ )} +
+ + Internship Type + {internshipTypeBadge}
@@ -452,7 +485,7 @@ export function ApplicantPage({ <> {hasChallengeSubmission && ( @@ -460,125 +493,152 @@ export function ApplicantPage({ )} -
- {application?.user?.bio ? ( +
+ {application?.user?.bio && (
-

{application?.user?.bio}

- -
- ) : ( -
-

Applicant has not added a bio.

+

+ {application?.user?.bio} +

)}
-

+

Applicant Information

- {application?.job && ( -

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

- )}
-

Education

-

+

Education

+

{to_university_name(user?.university)}

-

{user?.degree}

-

- Expected Graduation Date +

Degree

+

+ {user?.degree}

-

- {formatMonth(user?.expected_graduation_date)} +

+
+

Date Applied

+

+ {formatDateWithoutTime(application.applied_at)}

+ {user?.expected_graduation_date && ( +
+

+ Expected Graduation Date +

+

+ {formatMonth(user.expected_graduation_date)} +

+
+ )}
-

+

Internship Requirements

-
-

+

+

Expected Start Date

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

+ {internshipPreferences?.expected_duration_hours != null && ( +
+

+ Expected Duration (Hours) +

+

+ {internshipPreferences.expected_duration_hours} +

+
+ )}
-

- Expected Duration (Hours) -

-

- {internshipPreferences?.expected_duration_hours} -

+

Internship Type

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

+

Other Applied Roles

) : ( -

+

Applied Roles

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

- {a.job?.title} -

-
- )) +
+ {(userApplications?.length ?? 0) > 0 ? ( + userApplications?.map((a) => { + if (!a.id) return null; + + const status = + DB_STATUS_MAP[Number(a.status ?? 0)]?.key ?? + "pending"; + const statusConfig = UI_STATUS_MAP.get(status); + const StatusIcon = statusConfig?.icon; + const statusClasses: Record = { + pending: "bg-amber-50 text-amber-700", + shortlisted: "bg-blue-50 text-blue-700", + accepted: "bg-emerald-50 text-emerald-700", + rejected: "bg-rose-50 text-rose-700", + archived: "bg-gray-100 text-gray-600", + }; + + return ( + + + {a.job?.title ?? "Untitled role"} + + + + {StatusIcon && ( + + )} + {status} + + + View application + + + + + ); + }) ) : ( - <> - {application?.job ? ( -

- {" "} - No applied roles -

- ) : ( -

- {" "} - No other applied roles -

- )} - +

+ No other applied roles +

)}
@@ -588,12 +648,7 @@ export function ApplicantPage({ {/* resume */} {application?.resume_id ? ( -
+
) : ( diff --git a/components/features/hire/dashboard/JobHeader.tsx b/components/features/hire/dashboard/JobHeader.tsx index 04ed0c933..fcf1fcae9 100644 --- a/components/features/hire/dashboard/JobHeader.tsx +++ b/components/features/hire/dashboard/JobHeader.tsx @@ -1,9 +1,8 @@ -import { useState } from "react"; +import { useState, type ReactNode } from "react"; import { Button } from "@betterinternship/components"; import { Toggle } from "@/components/ui/toggle"; import { useOwnedJobs } from "@/hooks/use-employer-api"; import { Job } from "@/lib/db/db.types"; -import { formatDateWithoutTime } from "@/lib/utils"; import { cn } from "@betterinternship/components"; import { ArrowLeft, Edit, Info, Trash2, Users } from "lucide-react"; import Link from "next/link"; @@ -23,10 +22,12 @@ export default function JobHeader({ job, onJobUpdate, backHref, + applicantActions, }: { job: Job; onJobUpdate?: (updates: Partial) => void; backHref?: string; + applicantActions?: ReactNode; }) { const router = useRouter(); const { ownedJobs, update_job, delete_job, unpause_job } = useOwnedJobs(); @@ -203,7 +204,7 @@ export default function JobHeader({ ); return ( -
+
{isMobile ? (
@@ -275,118 +276,117 @@ export default function JobHeader({
-
+

{job?.title}

- - Created {formatDateWithoutTime(job?.created_at)} -
-
- - - - - - - {job.paused ? ( - - -
- -
-
- - Re-activate the listing first - -
- ) : ( + + - )} - -
+ {job.paused ? ( + + +
+ +
+
+ + Re-activate the listing first + +
+ ) : ( + + + + )} + +
+ )}
) : (
@@ -399,13 +399,10 @@ export default function JobHeader({ > -
+

{job?.title}

- - Created {formatDateWithoutTime(job?.created_at)} -
@@ -466,7 +463,9 @@ export default function JobHeader({
-
{desktopActionButtons}
+
+ {applicantActions ?? desktopActionButtons} +
)}
diff --git a/components/ui/action-button.tsx b/components/ui/action-button.tsx index a96988d67..00231349f 100644 --- a/components/ui/action-button.tsx +++ b/components/ui/action-button.tsx @@ -1,4 +1,5 @@ import { LucideIcon } from "lucide-react"; +import { cn } from "@betterinternship/components"; export const ActionButton = ({ icon: Icon, @@ -8,6 +9,7 @@ export const ActionButton = ({ destructive = false, size = 20, notification = false, + className, }: { icon: LucideIcon; label?: string; @@ -16,24 +18,18 @@ export const ActionButton = ({ destructive?: boolean; size?: number; notification?: boolean; + className?: string; }) => { return ( <> From 8346fce8f7efbdeff8160b6fbd50ff78209a48f6 Mon Sep 17 00:00:00 2001 From: Jana Marie Bantolino Date: Sun, 20 Sep 2026 00:56:23 +0800 Subject: [PATCH 11/93] feat: enhance ApplicationRow component with improved internship type display and dropdown menu descriptions --- .../hire/dashboard/ApplicationRow.tsx | 81 ++++++++++--------- 1 file changed, 44 insertions(+), 37 deletions(-) diff --git a/components/features/hire/dashboard/ApplicationRow.tsx b/components/features/hire/dashboard/ApplicationRow.tsx index 1a7f58512..be98e01a7 100644 --- a/components/features/hire/dashboard/ApplicationRow.tsx +++ b/components/features/hire/dashboard/ApplicationRow.tsx @@ -10,13 +10,14 @@ import { formatDateWithoutTime, formatTimestampDateWithoutTime, } from "@/lib/utils/date-utils"; -import { ApplicationAction, DB_STATUS_MAP } from "@/lib/consts/application"; +import { ApplicationAction } from "@/lib/consts/application"; import { Archive, ArchiveRestore, + Award, Calendar, - ContactRound, GraduationCap, + HandHelping, School, Trash2, } from "lucide-react"; @@ -26,7 +27,6 @@ import { DropdownMenu, type DropdownMenuItem, } from "@/components/ui/dropdown-menu"; -import StatusBadge from "@/components/ui/status-badge"; import { TableCell, TableRow } from "@betterinternship/components"; interface ApplicationRowProps { @@ -66,11 +66,23 @@ export function ApplicationRow({ {}) as InternshipPreferences; const currentStatusId = application.status?.toString() ?? "0"; - const filterKey = DB_STATUS_MAP[application.status || 0]?.key || "pending"; - const defaultStatus: DropdownMenuItem = { id: currentStatusId, }; + const internshipType = + preferences.internship_type === "credited" ? ( + + + Credited + + ) : preferences.internship_type === "voluntary" ? ( + + + Voluntary + + ) : ( + Not specified + ); const challengeSubmission = application.challenge_submission?.trim() ?? ""; const hasChallengeSubmission = challengeSubmission.length > 0; @@ -98,7 +110,11 @@ export function ApplicationRow({
{isSuperListing && ( - + )}
{isSuperListing ? ( @@ -128,10 +144,7 @@ export function ApplicationRow({ {application.user?.degree}
-
- - {preferences.internship_type} -
+
{internshipType}
@@ -144,14 +157,11 @@ export function ApplicationRow({ )} {!isSuperListing && (
- {filterKey !== "accepted" && filterKey !== "rejected" ? ( - - ) : ( - - )} +
)} @@ -182,21 +192,20 @@ export function ApplicationRow({ {application.applied_at?.toLocaleDateString()} - {filterKey !== "accepted" && filterKey !== "rejected" ? ( - - ) : ( - - )} +
{application.visibility === "visible" && ( ) => { e.stopPropagation(); if (application.visibility === "archived") { @@ -272,7 +281,7 @@ export function ApplicationRow({
- {preferences.internship_type} + {internshipType} {formatTimestampDateWithoutTime(preferences.expected_start_date)} @@ -282,15 +291,12 @@ export function ApplicationRow({ {formatDateWithoutTime(application.applied_at)} - {filterKey !== "accepted" && filterKey !== "rejected" ? ( - - ) : ( - - )} +
@@ -310,6 +316,7 @@ export function ApplicationRow({ label={ application.visibility === "archived" ? "Unarchive" : "Archive" } + className="text-gray-500 enabled:data-[destructive=false]:hover:bg-gray-100 enabled:hover:text-gray-800" /> )} {application.visibility === "archived" && ( From f329059aa47a00180bf2df71d0ecbc104aec357d Mon Sep 17 00:00:00 2001 From: Jana Marie Bantolino Date: Sun, 20 Sep 2026 01:05:59 +0800 Subject: [PATCH 12/93] fix: update status ordering logic in ApplicationsContent component --- .../features/hire/dashboard/ApplicationsContent.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/components/features/hire/dashboard/ApplicationsContent.tsx b/components/features/hire/dashboard/ApplicationsContent.tsx index be39a87b9..e1d99786e 100644 --- a/components/features/hire/dashboard/ApplicationsContent.tsx +++ b/components/features/hire/dashboard/ApplicationsContent.tsx @@ -83,7 +83,12 @@ export const ApplicationsContent = forwardRef< if (!app_statuses) return null; - const bulkStatusItems = app_statuses + const statusOrder = [0, 1, 4, 6, 7, 5]; + const orderedStatuses = app_statuses.toSorted( + (a, b) => statusOrder.indexOf(a.id) - statusOrder.indexOf(b.id), + ); + + const bulkStatusItems = orderedStatuses .map((status): DropdownMenuItem => { // look up config for db id const config = DB_STATUS_MAP[status.id]; @@ -109,8 +114,8 @@ export const ApplicationsContent = forwardRef< // get statuses specifically for the rows. these use different action items. const getRowStatuses = (application: EmployerApplication) => { - return app_statuses - .filter((status) => status.id !== 7 && status.id !== 5 && status.id !== 0) + return orderedStatuses + .filter((status) => status.id !== 7 && status.id !== 5) .map((status): DropdownMenuItem => { const config = DB_STATUS_MAP[status.id]; From 4e22c886f3e960910140b34c5ad13c73c4d3bd88 Mon Sep 17 00:00:00 2001 From: jayylmao Date: Sun, 20 Sep 2026 14:22:11 +0800 Subject: [PATCH 13/93] style(student): rm bottom bar nav on mobile --- components/shared/mobile-bottom-nav.tsx | 27 ++----------------------- 1 file changed, 2 insertions(+), 25 deletions(-) diff --git a/components/shared/mobile-bottom-nav.tsx b/components/shared/mobile-bottom-nav.tsx index d52c37441..100272939 100644 --- a/components/shared/mobile-bottom-nav.tsx +++ b/components/shared/mobile-bottom-nav.tsx @@ -86,31 +86,8 @@ export const MobileBottomNav: React.FC = ({ const profile = useProfileData(); const showFormsTab = hasFormsEnabledUniversity(profileData ?? profile.data); - // Not logged in: show minimal nav with Home and Sign In - if (!isAuthenticated()) { - return ( -
- {/* Search Button */} - } - label="Home" - isActive={pathname === "/search"} - onClick={() => router.push("/search")} - /> - - {/* Sign In Button */} - } - label="Log In" - isActive={false} - variant="accent" - onClick={() => - router.push(`${process.env.NEXT_PUBLIC_API_URL}/auth/google`) - } - /> -
- ); - } + // don't display bottom nav when signed out. + if (!isAuthenticated()) return null; // Logged in: show full navigation return ( From ac74d109b94b85c1ce2a87f20f055f668da7b59b Mon Sep 17 00:00:00 2001 From: Mo David Date: Sun, 20 Sep 2026 16:18:00 +0800 Subject: [PATCH 14/93] chore: get rid of old conversations infrastructure --- app/hire/__/page.tsx | 1 - app/hire/authctx.tsx | 3 --- app/student/__/page.tsx | 1 - lib/api/services.ts | 38 -------------------------------- lib/ctx-auth.tsx | 1 - lib/db/db.types.ts | 7 ------ lib/notification-service.ts | 44 ------------------------------------- 7 files changed, 95 deletions(-) delete mode 100644 lib/notification-service.ts diff --git a/app/hire/__/page.tsx b/app/hire/__/page.tsx index 68c368842..f7a374863 100644 --- a/app/hire/__/page.tsx +++ b/app/hire/__/page.tsx @@ -15,7 +15,6 @@ const InternalSetupPage = () => { await queryClient.invalidateQueries({ queryKey: ["my-profile"] }); await queryClient.invalidateQueries({ queryKey: ["my-applications"] }); await queryClient.invalidateQueries({ queryKey: ["my-saved-jobs"] }); - await queryClient.invalidateQueries({ queryKey: ["my-conversations"] }); await queryClient.invalidateQueries({ queryKey: ["my-forms"] }); await queryClient.invalidateQueries({ queryKey: ["my-form-templates"] }); await queryClient.invalidateQueries({ queryKey: ["my-form-template"] }); diff --git a/app/hire/authctx.tsx b/app/hire/authctx.tsx index f7724eaf8..40687a3fe 100644 --- a/app/hire/authctx.tsx +++ b/app/hire/authctx.tsx @@ -111,9 +111,6 @@ export const AuthContextProvider = ({ await Promise.all([ queryClient.invalidateQueries({ queryKey: ["my-employer-profile"] }), - queryClient.invalidateQueries({ - queryKey: ["my-employer-conversations"], - }), queryClient.invalidateQueries({ queryKey: ["me"] }), queryClient.invalidateQueries({ queryKey: ["my-employer-team"] }), ]); diff --git a/app/student/__/page.tsx b/app/student/__/page.tsx index 0dab33fcf..c890c5f58 100644 --- a/app/student/__/page.tsx +++ b/app/student/__/page.tsx @@ -16,7 +16,6 @@ const InternalSetupPage = () => { await queryClient.invalidateQueries({ queryKey: ["my-profile"] }); await queryClient.invalidateQueries({ queryKey: ["my-applications"] }); await queryClient.invalidateQueries({ queryKey: ["my-saved-jobs"] }); - await queryClient.invalidateQueries({ queryKey: ["my-conversations"] }); await queryClient.invalidateQueries({ queryKey: ["my-forms"] }); await queryClient.invalidateQueries({ queryKey: ["my-form-templates"] }); await queryClient.invalidateQueries({ queryKey: ["my-form-template"] }); diff --git a/lib/api/services.ts b/lib/api/services.ts index 6181d530e..2a7754016 100644 --- a/lib/api/services.ts +++ b/lib/api/services.ts @@ -1,6 +1,5 @@ import { FormTemplate } from "../db/forms-db.types"; import { - Conversation, CreateJobChallengeListingPayload, UpdateJobChallengeListingPayload, Employer, @@ -814,43 +813,6 @@ export const JobService = { }, }; -interface ConversationResponse extends FetchResponse { - conversation?: Conversation; -} - -export const EmployerConversationService = { - async sendToUser(conversationId: string, message: string) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return APIClient.post( - APIRouteBuilder("conversations").r("send-to-user").build(), - { - conversation_id: conversationId, - message, - }, - ); - }, - - async createConversation(userId: string) { - return APIClient.post( - APIRouteBuilder("conversations").r("create").build(), - { user_id: userId }, - ); - }, -}; - -export const UserConversationService = { - async sendToEmployer(conversationId: string, message: string) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return APIClient.post( - APIRouteBuilder("conversations").r("send-to-employer").build(), - { - conversation_id: conversationId, - message, - }, - ); - }, -}; - // Application Services interface UserApplicationsResponse extends FetchResponse { applications: UserApplication[]; diff --git a/lib/ctx-auth.tsx b/lib/ctx-auth.tsx index 95ceb5c78..dda1232b4 100644 --- a/lib/ctx-auth.tsx +++ b/lib/ctx-auth.tsx @@ -74,7 +74,6 @@ export const AuthContextProvider = ({ await queryClient.invalidateQueries({ queryKey: ["my-profile"] }); await queryClient.invalidateQueries({ queryKey: ["my-applications"] }); await queryClient.invalidateQueries({ queryKey: ["my-saved-jobs"] }); - await queryClient.invalidateQueries({ queryKey: ["my-conversations"] }); await queryClient.invalidateQueries({ queryKey: ["my-forms"] }); await queryClient.invalidateQueries({ queryKey: ["my-form-templates"] }); await queryClient.invalidateQueries({ queryKey: ["my-form-template"] }); diff --git a/lib/db/db.types.ts b/lib/db/db.types.ts index 1fb4203e2..11240d1da 100644 --- a/lib/db/db.types.ts +++ b/lib/db/db.types.ts @@ -13,7 +13,6 @@ import { CareerRefDepartments, CareerUsers, CareerEmployers, - CareerConversations, CareerEmployerUsers, CareerJobs, CareerApplications, @@ -60,12 +59,6 @@ export type Employer = Partial> & { application_count?: number | string | null; }; export type User = Partial>; -export interface Conversation extends Selectable { - employers?: Partial; - employer?: Partial; - users?: Partial; - user?: Partial; -} export type PrivateEmployerUser = Selectable; export type PublicEmployerUser = Omit; diff --git a/lib/notification-service.ts b/lib/notification-service.ts deleted file mode 100644 index ad5a658ff..000000000 --- a/lib/notification-service.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Provides functions for sending web notifications. - */ - -// check if the browser supports the web notifications api. -export function checkNotificationSupport() { - return "Notification" in window; -} - -// send popup to request notification permission. -export async function requestNotificationPermission() { - return await Notification.requestPermission(); -} - -// check for permission state. -export function getNotificationPermission() { - return Notification.permission; -} - -export type ChatNotificationOptions = { - body: string; - icon?: string; - badge?: string; - tag?: string; - requireInteraction?: boolean; -}; - -// check if the page is visible. can be used to determine whether notifications can be shown. -export function shouldShowNotification() { - return document.hidden; -} - -// send a new notification. -export function sendNotification( - title: string, - options: ChatNotificationOptions, -) { - if (!checkNotificationSupport || getNotificationPermission() !== "granted") { - return; - } - - const n: Notification = new Notification(title, options); - return n; -} From e9e8dc093c8c80c116e9de8caae5515c019ffb8f Mon Sep 17 00:00:00 2001 From: Mo David Date: Sun, 20 Sep 2026 16:19:11 +0800 Subject: [PATCH 15/93] chore: get rid of unnecessary job service test --- lib/api/__tests__/JobService.test.ts | 76 ---------------------------- 1 file changed, 76 deletions(-) delete mode 100644 lib/api/__tests__/JobService.test.ts diff --git a/lib/api/__tests__/JobService.test.ts b/lib/api/__tests__/JobService.test.ts deleted file mode 100644 index b6272256d..000000000 --- a/lib/api/__tests__/JobService.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { JobService } from "@/lib/api/services"; -import { APIClient, APIRouteBuilder } from "@/lib/api/api-client"; - -jest.mock("@/lib/api/api-client", () => ({ - APIClient: { - get: jest.fn(), - post: jest.fn(), - put: jest.fn(), - delete: jest.fn(), - }, - APIRouteBuilder: jest.fn(() => ({ - r: jest.fn().mockReturnThis(), - build: jest.fn().mockReturnValue("/mocked/jobs/saved"), - })), -})); - -describe("JobService", () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it("should retrieve saved jobs successfully", async () => { - const mockSavedJobs = { - jobs: [ - { id: "1", title: "Software Engineer" }, - { id: "2", title: "Designer" }, - ], - }; - - (APIClient.get as jest.Mock).mockResolvedValue(mockSavedJobs); - - const result = await JobService.getSavedJobs(); - - expect(APIRouteBuilder).toHaveBeenCalledWith("jobs"); // check that the route builder was used correctly - expect(APIClient.get).toHaveBeenCalledWith("/mocked/jobs/saved"); // check that APIClient.get was called with the mocked URL - expect(result).toEqual(mockSavedJobs); // verify the returned data - }); - - it("should create a new job successfully", async () => { - const mockJobData = { title: "Backend Engineer", description: "Build APIs" }; - const mockCreateResponse = { success: true }; - - // customize route builder mock for create - (APIRouteBuilder as jest.Mock).mockReturnValueOnce({ - r: jest.fn().mockReturnThis(), - build: jest.fn().mockReturnValue("/mocked/jobs/create"), - }); - - (APIClient.post as jest.Mock).mockResolvedValueOnce(mockCreateResponse); - - const result = await JobService.createJob(mockJobData); - - expect(APIRouteBuilder).toHaveBeenCalledWith("jobs"); - expect(APIClient.post).toHaveBeenCalledWith("/mocked/jobs/create", mockJobData); - expect(result).toEqual(mockCreateResponse); - }); - - it("should update a job successfully", async () => { - const mockJobId = "123"; - const mockUpdateData = { title: "Updated Title" }; - const mockUpdateResponse = { success: true }; - - (APIRouteBuilder as jest.Mock).mockReturnValueOnce({ - r: jest.fn().mockReturnThis(), - build: jest.fn().mockReturnValue(`/mocked/jobs/${mockJobId}/update`), - }); - - (APIClient.put as jest.Mock).mockResolvedValueOnce(mockUpdateResponse ); - - const result = await JobService.updateJob(mockJobId, mockUpdateData); - - expect(APIRouteBuilder).toHaveBeenCalledWith("jobs"); - expect(APIClient.put).toHaveBeenCalledWith(`/mocked/jobs/${mockJobId}/update`, mockUpdateData); - expect(result).toEqual(mockUpdateResponse ); - }); -}); From 3f7c2083235e3d0f3b0f61b16e9ba1195310ee57 Mon Sep 17 00:00:00 2001 From: Mo David Date: Sun, 20 Sep 2026 16:29:55 +0800 Subject: [PATCH 16/93] chore: get rid of unused god mass apply modal and god api routes --- .../components/MassApplyJobsSelector.tsx | 168 ------------------ components/modals/modal-registry.tsx | 30 ---- lib/api/god.api.ts | 43 ----- 3 files changed, 241 deletions(-) delete mode 100644 components/modals/components/MassApplyJobsSelector.tsx diff --git a/components/modals/components/MassApplyJobsSelector.tsx b/components/modals/components/MassApplyJobsSelector.tsx deleted file mode 100644 index 719a210ce..000000000 --- a/components/modals/components/MassApplyJobsSelector.tsx +++ /dev/null @@ -1,168 +0,0 @@ -"use client"; - -import { useEffect, useMemo, useState } from "react"; -import { Button } from "@betterinternship/components"; -import { JobCard, JobDetails } from "@/components/shared/jobs"; -import { useJobListingsPage } from "@/lib/api/student.data.api"; -import { useMassApply } from "@/lib/api/god.api"; -import { Job } from "@/lib/db/db.types"; -import { toast } from "sonner"; - -interface MassApplyJobsSelectorProps { - selectedStudentIds: Set; - onClose: () => void; -} - -export function MassApplyJobsSelector({ - selectedStudentIds, - onClose, -}: MassApplyJobsSelectorProps) { - const [selectedJobId, setSelectedJobId] = useState(null); - const [searchTerm, setSearchTerm] = useState(""); - const [jobsPage, setJobsPage] = useState(1); - const jobsPageSize = 10; - const massApply = useMassApply(); - - // Same server-paginated hook as the student search page — no - // filters/university, since this god-mode tool just needs search + paging. - const jobs = useJobListingsPage({ - search: searchTerm.trim() || undefined, - page: jobsPage, - limit: jobsPageSize, - }); - - // Reset to page 1 when search term changes - useEffect(() => { - setJobsPage(1); - }, [searchTerm]); - - const totalPages = Math.max(1, Math.ceil(jobs.total / jobsPageSize)); - - const selectedJob = useMemo( - () => jobs.jobs.find((j: Job) => j.id === selectedJobId), - [jobs.jobs, selectedJobId], - ); - - const handleApply = async () => { - if (!selectedJobId) { - toast.error("Please select a job"); - return; - } - - try { - await massApply.mutateAsync({ - jobId: selectedJobId, - studentIds: Array.from(selectedStudentIds), - }); - toast.success( - `Successfully applied ${selectedStudentIds.size} student(s) to the job!`, - ); - onClose(); - } catch (error) { - console.error("Mass apply error:", error); - toast.error("Failed to apply students. Check console for details."); - } - }; - - return ( -
-
-

- Apply {selectedStudentIds.size} Student - {selectedStudentIds.size > 1 ? "s" : ""} to Job -

-
- -
- {/* Job List - left side */} -
-
- setSearchTerm(e.target.value)} - className="w-full px-2 py-1.5 border border-gray-300 rounded-md text-xs focus:outline-none focus:ring-2 focus:ring-blue-500" - /> -
- -
- {jobs.isPending ? ( -
-

Loading jobs...

-
- ) : jobs.jobs.length === 0 ? ( -
-

No jobs found

-
- ) : ( - jobs.jobs.map((job: Job) => ( - setSelectedJobId(job.id ?? null)} - /> - )) - )} -
- - {/* Pagination Controls */} - {!jobs.isPending && jobs.total > jobsPageSize && ( -
- - - {jobsPage}/{totalPages} - - -
- )} -
- - {/* Job Details - right side */} -
- {selectedJob ? ( -
- -
- ) : ( -
-

Select a job

-
- )} -
-
- -
- - -
-
- ); -} diff --git a/components/modals/modal-registry.tsx b/components/modals/modal-registry.tsx index caa85f230..616b256a4 100644 --- a/components/modals/modal-registry.tsx +++ b/components/modals/modal-registry.tsx @@ -6,7 +6,6 @@ import { CancelFormModal } from "./components/CancelFormModal"; import { WarningModal } from "./components/WarningModal"; import { SuccessModal } from "./components/SuccessModal"; import { SuperListingClosedModal } from "./components/SuperListingClosedModal"; -import { MassApplyJobsSelector } from "./components/MassApplyJobsSelector"; import { DefaultModalLayout, SlideUpModalLayout, @@ -414,35 +413,6 @@ export const useModalRegistry = () => { close: () => close("success"), }, - // Mass apply job selector (God mode) - massApplyJobSelector: { - open: ({ - selectedStudentIds, - onClose, - panelClassName, - }: { - selectedStudentIds: Set; - onClose: () => void; - panelClassName?: string; - }) => - open( - "mass-apply-job-selector", - DefaultModalLayout, - { - onClose(); - close("mass-apply-job-selector"); - }} - />, - { - title: " ", - panelClassName, - }, - ), - close: () => close("mass-apply-job-selector"), - }, - previewFormPdf: { open: ({ documentUrl }: { documentUrl: string }) => open( diff --git a/lib/api/god.api.ts b/lib/api/god.api.ts index d1a4266e9..ec0364eb0 100644 --- a/lib/api/god.api.ts +++ b/lib/api/god.api.ts @@ -194,49 +194,6 @@ export function useImportCsv() { }); } -export const StudentGodAPI = { - impersonate: async (studentId: string, reason?: string) => - APIClient.post( - APIRouteBuilder("student-god") - .r("students", studentId, "impersonations") - .build(), - reason ? { reason } : {}, - ), - stop: async () => - APIClient.post( - APIRouteBuilder("student-god").r("impersonations", "stop").build(), - {}, - ), - massApply: async (dto: { jobId: string; studentIds: string[] }) => - APIClient.post( - APIRouteBuilder("student-god").r("mass-apply").build(), - dto, - ), -}; - -export function useStudentImpersonation() { - const impersonate = useMutation({ - mutationFn: ({ - studentId, - reason, - }: { - studentId: string; - reason?: string; - }) => StudentGodAPI.impersonate(studentId, reason), - }); - const stop = useMutation({ - mutationFn: () => StudentGodAPI.stop(), - }); - return { impersonate, stop }; -} - -export function useMassApply() { - return useMutation({ - mutationFn: (dto: { jobId: string; studentIds: string[] }) => - StudentGodAPI.massApply(dto), - }); -} - // ── MOA document verification ─────────────────────────────────────────── export interface MoaUpload { From 102f26eefcfbfb822ec71e2276f48fa068eef8c5 Mon Sep 17 00:00:00 2001 From: Mo David Date: Sun, 20 Sep 2026 16:36:09 +0800 Subject: [PATCH 17/93] chore: remove old auth methods for password-based student login --- lib/api/services.ts | 20 -------------------- lib/ctx-auth.tsx | 21 --------------------- 2 files changed, 41 deletions(-) diff --git a/lib/api/services.ts b/lib/api/services.ts index 2a7754016..2b50a65a7 100644 --- a/lib/api/services.ts +++ b/lib/api/services.ts @@ -269,26 +269,6 @@ export const AuthService = { ); }, - async login(email: string, password: string = "") { - return APIClient.post( - APIRouteBuilder("auth").r("login").build(), - { - email, - password, - }, - ); - }, - - async verify(userId: string, key: string) { - return APIClient.post( - APIRouteBuilder("auth").r("verify-email").build(), - { - user_id: userId, - key, - }, - ); - }, - async requestActivation(email: string) { return APIClient.post( APIRouteBuilder("auth").r("activate").build(), diff --git a/lib/ctx-auth.tsx b/lib/ctx-auth.tsx index dda1232b4..17cf99d9d 100644 --- a/lib/ctx-auth.tsx +++ b/lib/ctx-auth.tsx @@ -14,10 +14,6 @@ interface IAuthContext { ) => Promise< ({ user: Partial; message?: string } & FetchResponse) | null >; - verify: ( - userId: string, - key: string, - ) => Promise<(Partial & FetchResponse) | null>; logout: () => Promise; isAuthenticated: () => boolean; refreshAuthentication: () => Promise | null>; @@ -83,22 +79,6 @@ export const AuthContextProvider = ({ return response; }; - const verify = async (userId: string, key: string) => { - const response = await AuthService.verify(userId, key); - if (!response.success) return null; - await queryClient.invalidateQueries({ queryKey: ["jobs"] }); - await queryClient.invalidateQueries({ queryKey: ["my-profile"] }); - await queryClient.invalidateQueries({ queryKey: ["my-applications"] }); - await queryClient.invalidateQueries({ queryKey: ["my-saved-jobs"] }); - await queryClient.invalidateQueries({ queryKey: ["my-conversations"] }); - await queryClient.invalidateQueries({ queryKey: ["my-forms"] }); - await queryClient.invalidateQueries({ queryKey: ["my-form-templates"] }); - await queryClient.invalidateQueries({ queryKey: ["my-form-template"] }); - await queryClient.invalidateQueries({ queryKey: ["my-resumes"] }); - setIsAuthenticated(true); - return response; - }; - const logout = async () => { await AuthService.logout(); queryClient.clear(); @@ -125,7 +105,6 @@ export const AuthContextProvider = ({ isAuthenticated, From a5e53a6dccc1e93b3fc669ed5017102979e50d60 Mon Sep 17 00:00:00 2001 From: Mo David Date: Sun, 20 Sep 2026 16:37:57 +0800 Subject: [PATCH 18/93] chore: remove unused modals --- components/modals/MassApplyModal.tsx | 36 --------------- .../modals/components/MassApplyComposer.tsx | 45 ------------------- components/modals/modal-registry.tsx | 32 ------------- 3 files changed, 113 deletions(-) delete mode 100644 components/modals/MassApplyModal.tsx delete mode 100644 components/modals/components/MassApplyComposer.tsx diff --git a/components/modals/MassApplyModal.tsx b/components/modals/MassApplyModal.tsx deleted file mode 100644 index 485977bfc..000000000 --- a/components/modals/MassApplyModal.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { ModalComponent, ModalHandle } from "@/hooks/use-modal"; -import { Button } from "@betterinternship/components"; -import { RefObject } from "react"; - -export const MassApplyModal = ({ - ref, - disabled, - onCancel, - onSubmit, -}: { - ref?: RefObject; - disabled?: boolean; - onCancel: () => void; - onSubmit: () => void; -}) => { - return ( - -
-

Apply to selected jobs

-

- We’ll skip any postings that require info your profile - doesn’t have. -

- -
- - -
-
-
- ); -}; diff --git a/components/modals/components/MassApplyComposer.tsx b/components/modals/components/MassApplyComposer.tsx deleted file mode 100644 index 7430bea33..000000000 --- a/components/modals/components/MassApplyComposer.tsx +++ /dev/null @@ -1,45 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { Button } from "@betterinternship/components"; -import { cn } from "@betterinternship/components"; - -export type MassApplyComposerProps = { - disabled?: boolean; - onCancel: () => void; - onSubmit: () => void | Promise; - className?: string; -}; - -export function MassApplyComposer({ - disabled, - onCancel, - onSubmit, - className, -}: MassApplyComposerProps) { - const [submitting, setSubmitting] = useState(false); - const canSubmit = !disabled && !submitting; - - const handleSubmit = async () => { - if (!canSubmit) return; - try { - setSubmitting(true); - await onSubmit(); - } finally { - setSubmitting(false); - } - }; - - return ( -
-
- - -
-
- ); -} diff --git a/components/modals/modal-registry.tsx b/components/modals/modal-registry.tsx index 616b256a4..806bc90ad 100644 --- a/components/modals/modal-registry.tsx +++ b/components/modals/modal-registry.tsx @@ -431,38 +431,6 @@ export const useModalRegistry = () => { close: () => close("preview-form-pdf"), }, - formTemplateDetails: { - open: ({ - title, - content, - onClose, - onRequestClose, - showCloseButton, - closeOnBackdropClick, - closeOnEscapeKey, - mobileFullscreen, - }: { - title?: ReactNode; - content: ReactNode; - onClose?: () => void; - onRequestClose?: () => void; - showCloseButton?: boolean; - closeOnBackdropClick?: boolean; - closeOnEscapeKey?: boolean; - mobileFullscreen?: boolean; - }) => - open("form-template-details", SlideUpModalLayout, content, { - title, - onClose, - onRequestClose, - showCloseButton, - closeOnBackdropClick, - closeOnEscapeKey, - mobileFullscreen, - }), - close: () => close("form-template-details"), - }, - centeredDetails: { open: ({ title, From 927518d7c3a15bb66553c11fe1cf059fbdb96f49 Mon Sep 17 00:00:00 2001 From: Mo David Date: Sun, 20 Sep 2026 16:41:39 +0800 Subject: [PATCH 19/93] chore: remove unused application routes; application withdrawal to be added in the future --- lib/api/services.ts | 30 ------------------------------ lib/api/student.actions.api.ts | 5 ----- 2 files changed, 35 deletions(-) diff --git a/lib/api/services.ts b/lib/api/services.ts index 2b50a65a7..0bf46b605 100644 --- a/lib/api/services.ts +++ b/lib/api/services.ts @@ -802,10 +802,6 @@ interface EmployerApplicationsResponse extends FetchResponse { applications: EmployerApplication[]; } -interface UserApplicationResponse extends FetchResponse { - application: UserApplication; -} - interface CreateApplicationResponse extends FetchResponse { application: UserApplication; } @@ -835,38 +831,12 @@ export const ApplicationService = { ); }, - async getApplicationById(id: string): Promise { - return APIClient.get( - APIRouteBuilder("applications").r(id).build(), - ); - }, - async getEmployerApplications(): Promise { return APIClient.get( APIRouteBuilder("employer").r("applications").build(), ); }, - async updateApplication( - id: string, - data: { - githubLink?: string; - portfolioLink?: string; - resumeFilename?: string; - }, - ) { - return APIClient.put( - APIRouteBuilder("applications").r(id).build(), - data, - ); - }, - - async withdrawApplication(id: string) { - return APIClient.delete( - APIRouteBuilder("applications").r(id).build(), - ); - }, - async reviewApplication( id: string, review_options: { review?: string; notes?: string; status?: number }, diff --git a/lib/api/student.actions.api.ts b/lib/api/student.actions.api.ts index e817c447d..6cb8e90f2 100644 --- a/lib/api/student.actions.api.ts +++ b/lib/api/student.actions.api.ts @@ -34,11 +34,6 @@ export const useApplicationActions = () => { onSettled: () => queryClient.invalidateQueries({ queryKey: ["my-applications"] }), }), - withdraw: useMutation({ - mutationFn: (id: string) => ApplicationService.withdrawApplication(id), - onSettled: () => - queryClient.invalidateQueries({ queryKey: ["my-applications"] }), - }), }; return actions; From db44bc9d506045db65702561751b0f2aa7c6f2fe Mon Sep 17 00:00:00 2001 From: Mo David Date: Sun, 20 Sep 2026 16:43:32 +0800 Subject: [PATCH 20/93] chore: rm unused form template route --- app/student/forms/page.tsx | 14 -------------- lib/api/services.ts | 7 ------- 2 files changed, 21 deletions(-) diff --git a/app/student/forms/page.tsx b/app/student/forms/page.tsx index 534b07ba9..91c808efb 100644 --- a/app/student/forms/page.tsx +++ b/app/student/forms/page.tsx @@ -77,19 +77,6 @@ export default function FormsPage() { router, ]); - // Query 1: Check for updates (cheap query - just a timestamp) - // TODO: Enable this later for smart cache invalidation - // const { data: updateInfo } = useQuery({ - // queryKey: ["form-templates-last-updated"], - // queryFn: () => FormService.getFormTemplatesLastUpdated(), - // }); - - // console.log("Form templates last updated info:", updateInfo); - - // Query 2: Fetch full form data only if version changes - // The queryKey includes the version, so React Query treats it as a new query when version changes - // When re-enabling the smart update check, add updateInfo?.version back to queryKey - // and change enabled to: enabled: !!updateInfo const { data: { formTemplates, formGroupDescription } = {}, isLoading } = useQuery({ queryKey: ["my-form-templates"], @@ -98,7 +85,6 @@ export default function FormsPage() { staleTime: FORM_TEMPLATES_STALE_TIME, gcTime: FORM_TEMPLATES_GC_TIME, refetchOnWindowFocus: true, // Refetch when user switches back to tab - // enabled: !!updateInfo, // Only fetch after we have update info }); // ? I think I can abstract this somehow in the future diff --git a/lib/api/services.ts b/lib/api/services.ts index 0bf46b605..09e110477 100644 --- a/lib/api/services.ts +++ b/lib/api/services.ts @@ -398,13 +398,6 @@ export const FormService = { return response; }, - async getFormTemplatesLastUpdated() { - return APIClient.get<{ - lastUpdatedAt: string; - version: number; - }>(APIRouteBuilder("services").r("me/latest-form-check").build()); - }, - async getMyGeneratedForms() { const { forms } = await APIClient.get<{ forms: { From 7a536761a61d1f61452de260d83eba3317cf444d Mon Sep 17 00:00:00 2001 From: Mo David Date: Sun, 20 Sep 2026 17:35:21 +0800 Subject: [PATCH 21/93] chore: rm deprecated tin iom link --- lib/api/services.ts | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/lib/api/services.ts b/lib/api/services.ts index 09e110477..f2ec05c4b 100644 --- a/lib/api/services.ts +++ b/lib/api/services.ts @@ -22,10 +22,6 @@ interface EmployerResponse extends FetchResponse { employer: Partial; } -interface IomLinkRequestResponse extends FetchResponse { - url: string; -} - interface MoaUniversitiesResponse extends FetchResponse { universityIds: string[]; } @@ -106,13 +102,6 @@ export const EmployerService = { ); }, - async requestIomLink(tin: string) { - return APIClient.post( - APIRouteBuilder("employer").r("iom-link", "request").build(), - { tin }, - ); - }, - async startIomRegistration() { return APIClient.post( APIRouteBuilder("employer").r("iom-link", "start-registration").build(), From 425e1f6592879107490edab3551be9c94dc03a97 Mon Sep 17 00:00:00 2001 From: Mo David Date: Sun, 20 Sep 2026 18:00:53 +0800 Subject: [PATCH 22/93] chore: rm unused query keys for deprecated chat feat --- app/hire/%5F%5F/page.tsx | 1 - app/student/%5F%5F/page.tsx | 1 - 2 files changed, 2 deletions(-) diff --git a/app/hire/%5F%5F/page.tsx b/app/hire/%5F%5F/page.tsx index 68c368842..f7a374863 100644 --- a/app/hire/%5F%5F/page.tsx +++ b/app/hire/%5F%5F/page.tsx @@ -15,7 +15,6 @@ const InternalSetupPage = () => { await queryClient.invalidateQueries({ queryKey: ["my-profile"] }); await queryClient.invalidateQueries({ queryKey: ["my-applications"] }); await queryClient.invalidateQueries({ queryKey: ["my-saved-jobs"] }); - await queryClient.invalidateQueries({ queryKey: ["my-conversations"] }); await queryClient.invalidateQueries({ queryKey: ["my-forms"] }); await queryClient.invalidateQueries({ queryKey: ["my-form-templates"] }); await queryClient.invalidateQueries({ queryKey: ["my-form-template"] }); diff --git a/app/student/%5F%5F/page.tsx b/app/student/%5F%5F/page.tsx index 837d0e62a..651998fce 100644 --- a/app/student/%5F%5F/page.tsx +++ b/app/student/%5F%5F/page.tsx @@ -15,7 +15,6 @@ const InternalSetupPage = () => { await queryClient.invalidateQueries({ queryKey: ["my-profile"] }); await queryClient.invalidateQueries({ queryKey: ["my-applications"] }); await queryClient.invalidateQueries({ queryKey: ["my-saved-jobs"] }); - await queryClient.invalidateQueries({ queryKey: ["my-conversations"] }); await queryClient.invalidateQueries({ queryKey: ["my-forms"] }); await queryClient.invalidateQueries({ queryKey: ["my-form-templates"] }); await queryClient.invalidateQueries({ queryKey: ["my-form-template"] }); From 8f3e5935c238453ec33b8b7d0bc24ccb72babbfb Mon Sep 17 00:00:00 2001 From: Mo David Date: Sun, 20 Sep 2026 18:20:00 +0800 Subject: [PATCH 23/93] chore: rm jest step in ci/cd --- .../workflows/fork-sync-trigger-develop.yml | 35 ------------------- 1 file changed, 35 deletions(-) diff --git a/.github/workflows/fork-sync-trigger-develop.yml b/.github/workflows/fork-sync-trigger-develop.yml index 05b622737..f2fdcd857 100644 --- a/.github/workflows/fork-sync-trigger-develop.yml +++ b/.github/workflows/fork-sync-trigger-develop.yml @@ -50,41 +50,6 @@ jobs: - name: Install Dependencies run: npm i --legacy-peer-deps - - name: Run Jest Tests and Log - id: jest - continue-on-error: true - run: | - set +e - # Run Jest and pipe output to file - npm run test - TEST_EXIT_CODE=$? - - # Prepare log for Discord (Tail last 1500 chars, escape quotes/newlines for JSON) - LOG_CONTENT=$(tail -c 1800 jest.log) - - if [ $TEST_EXIT_CODE -eq 0 ]; then - STATUS="✅ **DEV** Jest Passed" - else - STATUS="❌ **DEV** Jest Failed" - fi - - ROLE_ID="1430152257072271376" - jq -n \ - --arg content "**$STATUS** - \`\`\`text - $LOG_CONTENT - \`\`\`" \ - '{content: $content}' >payload.json - - curl -H "Content-Type: application/json" \ - -d @payload.json \ - ${{ secrets.DISCORD_WEBHOOK_URL }} - - set -e - - # Check exit code again to actually fail the step if needed - if [ $TEST_EXIT_CODE -ne 0 ]; then exit 1; fi - - name: Deploy to Vercel id: vercel run: | From 4094c7cc19babd4d9475782143ca4e4b537d59db Mon Sep 17 00:00:00 2001 From: Mo David Date: Sun, 20 Sep 2026 18:24:38 +0800 Subject: [PATCH 24/93] chore: remove all jest files --- eslint.config.mjs | 5 - jest.config.js | 29 ----- package.json | 7 +- tests/utils.test.ts | 283 -------------------------------------------- tsconfig.json | 4 +- 5 files changed, 2 insertions(+), 326 deletions(-) delete mode 100644 jest.config.js delete mode 100644 tests/utils.test.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index da96021e4..701d69756 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -3,7 +3,6 @@ import eslint from '@eslint/js'; import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'; import globals from 'globals'; import tseslint from 'typescript-eslint'; -import jestPlugin from "eslint-plugin-jest"; export default tseslint.config( { @@ -16,7 +15,6 @@ export default tseslint.config( languageOptions: { globals: { ...globals.node, - ...globals.jest, }, sourceType: 'commonjs', parserOptions: { @@ -24,9 +22,6 @@ export default tseslint.config( tsconfigRootDir: import.meta.dirname, }, }, - plugins: { - jest: jestPlugin, - }, }, { rules: { diff --git a/jest.config.js b/jest.config.js deleted file mode 100644 index 91503a639..000000000 --- a/jest.config.js +++ /dev/null @@ -1,29 +0,0 @@ -// jest.config.js for testing purposes with Jest and TypeScript -// In cmd, npm install --save-dev jest ts-jest @types/jest --legacy-peer-deps -// npx jest - // npx jest --clearCache - - -const { createDefaultPreset } = require("ts-jest"); - -const tsJestTransformCfg = createDefaultPreset().transform; - -/** @type {import("jest").Config} **/ -module.exports = { - testEnvironment: "node", - transform: { - ...tsJestTransformCfg, - }, - moduleNameMapper: { - - //moduleNameMapper tells Jest what @/ means. - // refers to your project root (the folder with package.json. - // ^@/(.*)$ matches imports - - "^@/(.*)$": "/$1", - }, - moduleDirectories: ["node_modules", ""], -}; - - - diff --git a/package.json b/package.json index f793db4fd..6710348af 100644 --- a/package.json +++ b/package.json @@ -8,8 +8,7 @@ "stage": "npm run build && git push origin develop", "publish": "npm run build && git push origin develop", "start": "next start", - "lint": "next lint", - "test": "npx jest --no-color --passWithNoTests >jest.log 2>&1" + "lint": "next lint" }, "dependencies": { "@betterinternship/components": "^2.9.3", @@ -61,7 +60,6 @@ "date-fns": "^4.1.0", "embla-carousel-fade": "^8.6.0", "embla-carousel-react": "8.5.1", - "eslint-plugin-jest": "^29.0.1", "framer-motion": "^12.34.0", "gsap": "^3.13.0", "input-otp": "^1.4.2", @@ -93,7 +91,6 @@ "devDependencies": { "@tailwindcss/forms": "^0.5.10", "@types/canvas-confetti": "^1.9.0", - "@types/jest": "^30.0.0", "@types/knuth-shuffle-seeded": "^1.0.2", "@types/node": "^22", "@types/pg": "^8.20.0", @@ -102,11 +99,9 @@ "eslint": "^9.18.0", "eslint-config-prettier": "^10.0.1", "eslint-plugin-prettier": "^5.2.2", - "jest": "^30.2.0", "postcss": "^8.5.15", "prettier": "^3.4.2", "tailwindcss": "^4", - "ts-jest": "^29.4.5", "typescript": "^5", "typescript-eslint": "^8.20.0" }, diff --git a/tests/utils.test.ts b/tests/utils.test.ts deleted file mode 100644 index 660e64494..000000000 --- a/tests/utils.test.ts +++ /dev/null @@ -1,283 +0,0 @@ -/// - -import { - toSafeString, - isValidUUID, - getURL, - isValidPHNumber, - isValidEmail, - normalizePhoneNumber, - isPhoneNumberSame, - createSearchFilterString, - hashStringToInt, -} from "@/lib/utils"; - -describe("Utility Functions", () => { - // Test toSafeString - describe("toSafeString", () => { - it("should return the string if it's valid", () => { - expect(toSafeString("Test String")).toBe("Test String"); - }); - - it("should return the default value for null input", () => { - expect(toSafeString(null, "default")).toBe("default"); - }); - - it("should return the default value for undefined input", () => { - expect(toSafeString(undefined, "default")).toBe("default"); - }); - - it("should return the default value for empty string input", () => { - expect(toSafeString("", "default")).toBe("default"); - }); - - it("should return the default value for whitespace string input", () => { - expect(toSafeString(" ", "default")).toBe("default"); - }); - - it("should use an empty string as default when not provided", () => { - expect(toSafeString(null)).toBe(""); - expect(toSafeString(" ")).toBe(""); - }); - }); - - // Test isValidUUID - describe("isValidUUID", () => { - it("should return true for a valid UUID v4", () => { - expect(isValidUUID("f47ac10b-58cc-4372-a567-0e02b2c3d479")).toBe(true); - }); - - it("should return true for a valid UUID v4 with uppercase letters", () => { - expect(isValidUUID("F47AC10B-58CC-4372-A567-0E02B2C3D479")).toBe(true); - }); - - it("should return false for an invalid format", () => { - expect(isValidUUID("f47ac10b-58cc-4372-a567-0e02b2c3d47")).toBe(false); - }); - - it("should return false for a UUID with wrong version (must be 4)", () => { - expect(isValidUUID("f47ac10b-58cc-1372-a567-0e02b2c3d479")).toBe(false); - }); - - it("should return false for a UUID with wrong variant (must be 8, 9, a, or b)", () => { - expect(isValidUUID("f47ac10b-58cc-4372-c567-0e02b2c3d479")).toBe(false); - }); - }); - - // Test getURL - describe("getURL", () => { - const originalEnv = process.env; - - beforeEach(() => { - jest.resetModules(); - process.env = { ...originalEnv }; - delete process.env.NEXT_PUBLIC_SITE_URL; - delete process.env.NEXT_PUBLIC_PREVIEW_URL; - delete process.env.NEXT_PUBLIC_VERCEL_BRANCH_URL; - delete process.env.NEXT_PUBLIC_VERCEL_URL; - }); - - afterAll(() => { - process.env = originalEnv; - }); - - it("should return NEXT_PUBLIC_SITE_URL if set, and ensure https and trailing slash", () => { - process.env.NEXT_PUBLIC_SITE_URL = "mysite.com"; - expect(getURL()).toBe("https://mysite.com/"); - }); - - it("should fall back to NEXT_PUBLIC_VERCEL_URL, and ensure https and trailing slash", () => { - process.env.NEXT_PUBLIC_VERCEL_URL = "myvercel.app"; - expect(getURL()).toBe("https://myvercel.app/"); - }); - - it("should prioritize existing protocol (http)", () => { - process.env.NEXT_PUBLIC_SITE_URL = "http://mydev.com"; - expect(getURL()).toBe("http://mydev.com/"); - }); - - it("should add a trailing slash if missing", () => { - process.env.NEXT_PUBLIC_SITE_URL = "https://mysite.com"; - expect(getURL()).toBe("https://mysite.com/"); - }); - - it("should use localhost default if no environment variables are set", () => { - expect(getURL()).toBe("http://localhost:3080/"); - }); - }); - - // Test isValidPHNumber - describe("isValidPHNumber", () => { - it("should return false for null or undefined input", () => { - expect(isValidPHNumber(null)).toBe(false); - expect(isValidPHNumber(undefined)).toBe(false); - }); - - it("should return true for valid 10-digit format (9xxxxxxxxx)", () => { - expect(isValidPHNumber("9171234567")).toBe(true); - }); - - it("should return true for valid 11-digit format (09xxxxxxxxx)", () => { - expect(isValidPHNumber("09171234567")).toBe(true); - }); - - it("should return true for valid 12-digit format (639xxxxxxxxx)", () => { - expect(isValidPHNumber("639171234567")).toBe(true); - }); - - it("should return true for valid +63 format (+639xxxxxxxxx)", () => { - expect(isValidPHNumber("+639171234567")).toBe(true); - }); - - it("should return true for valid grouped format ((+63) 9xx xxx xxxx)", () => { - expect(isValidPHNumber("(+63) 917 123 4567")).toBe(true); - }); - - it("should return false for an invalid number of digits", () => { - expect(isValidPHNumber("917123456")).toBe(false); - }); - }); - - // Test isValidEmail - describe("isValidEmail", () => { - it("should return true for a standard valid email", () => { - expect(isValidEmail("test.user@example.com")).toBe(true); - }); - - it("should return true for an email with numbers and underscores", () => { - expect(isValidEmail("user_123@sub.domain-name.co")).toBe(true); - }); - - it("should return false for null or undefined input", () => { - expect(isValidEmail(null)).toBe(false); - expect(isValidEmail(undefined)).toBe(false); - }); - - it("should return false for an email missing @ symbol", () => { - expect(isValidEmail("test.user.example.com")).toBe(false); - }); - - it("should return false for an email with missing domain extension", () => { - expect(isValidEmail("test.user@example")).toBe(false); - }); - }); - - // Test normalizePhoneNumber - describe("normalizePhoneNumber", () => { - it("should return null for null, undefined, or empty string input", () => { - expect(normalizePhoneNumber(null)).toBe(null); - expect(normalizePhoneNumber(undefined)).toBe(null); - expect(normalizePhoneNumber("")).toBe(null); - }); - - it("should normalize 10-digit number (9xxxxxxxxx) to +639xxxxxxxxx", () => { - expect(normalizePhoneNumber("9171234567")).toBe("+639171234567"); - }); - - it("should normalize 11-digit number (09xxxxxxxxx) to +639xxxxxxxxx", () => { - expect(normalizePhoneNumber("09171234567")).toBe("+639171234567"); - }); - - it("should normalize 12-digit number (639xxxxxxxxx) to +639xxxxxxxxx", () => { - expect(normalizePhoneNumber("639171234567")).toBe("+639171234567"); - }); - - it("should return a valid +63 number (+639xxxxxxxxx) as is", () => { - expect(normalizePhoneNumber("+639171234567")).toBe("+639171234567"); - }); - - it("should handle numbers with spaces and hyphens by removing them", () => { - expect(normalizePhoneNumber("0917-123-4567")).toBe("+639171234567"); - expect(normalizePhoneNumber("917 123 4567")).toBe("+639171234567"); - }); - - it("should return null for non-PH-mobile-formatted numbers", () => { - expect(normalizePhoneNumber("1234567890")).toBe(null); - }); - }); - - // Test isPhoneNumberSame - describe("isPhoneNumberSame", () => { - it("should return false if either number is null or undefined", () => { - expect(isPhoneNumberSame("09171234567", null)).toBe(false); - expect(isPhoneNumberSame(undefined, "09171234567")).toBe(false); - expect(isPhoneNumberSame(null, null)).toBe(false); - }); - - it("should return true for different formats of the same number", () => { - expect(isPhoneNumberSame("09171234567", "+639171234567")).toBe(true); - expect(isPhoneNumberSame("9171234567", "639171234567")).toBe(true); - }); - - it("should return false for different valid phone numbers", () => { - expect(isPhoneNumberSame("09171234567", "09181234567")).toBe(false); - }); - - it("should return false if normalization fails for either number", () => { - expect(isPhoneNumberSame("09171234567", "12345")).toBe(false); - expect(isPhoneNumberSame("12345", "09171234567")).toBe(false); - }); - }); - - // Test createSearchFilterString - describe("createSearchFilterString", () => { - const columns = ["first_name", "last_name", "email"]; - - it("should return an empty string if searchTerm is null or empty", () => { - expect(createSearchFilterString(columns, "")).toBe(""); - expect(createSearchFilterString(columns, " ")).toBe(""); - }); - - it("should correctly create the filter string for a single term", () => { - const expected = - "first_name.ilike.%john%,last_name.ilike.%john%,email.ilike.%john%"; - expect(createSearchFilterString(columns, "john")).toBe(expected); - }); - - it("should treat multiple words in searchTerm as a single search term", () => { - const searchTerm = "John Doe"; - const expected = - "first_name.ilike.%John Doe%,last_name.ilike.%John Doe%,email.ilike.%John Doe%"; - expect(createSearchFilterString(columns, searchTerm)).toBe(expected); - }); - - it("should handle columns with special characters (if they were valid SQL columns)", () => { - const cols = ["id", "data_value"]; - const expected = "id.ilike.%test%,data_value.ilike.%test%"; - expect(createSearchFilterString(cols, "test")).toBe(expected); - }); - }); - - // Test hashStringToInt - describe("hashStringToInt", () => { - it("should return 0 for an empty string", () => { - expect(hashStringToInt("")).toBe(0); - }); - - it("should return a consistent hash for the same string", () => { - const hash = hashStringToInt("hello world"); - expect(hash).toBe(hashStringToInt("hello world")); - expect(typeof hash).toBe("number"); - }); - - it("should return different hashes for different strings", () => { - const hash1 = hashStringToInt("apple"); - const hash2 = hashStringToInt("orange"); - expect(hash1).not.toBe(hash2); - }); - - it("should return an unsigned 32-bit integer", () => { - const hash = hashStringToInt( - "A very long string that should result in a large hash value", - ); - expect(hash).toBeGreaterThanOrEqual(0); - expect(hash).toBeLessThanOrEqual(4294967295); - }); - - it("should return different hashes for strings that only differ by case", () => { - const hash1 = hashStringToInt("Test"); - const hash2 = hashStringToInt("test"); - expect(hash1).not.toBe(hash2); - }); - }); -}); diff --git a/tsconfig.json b/tsconfig.json index 5d075aaf9..c6f9a3841 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,9 +1,7 @@ { "compilerOptions": { "lib": ["dom", "dom.iterable", "esnext"], - "types": [ - "jest" - ], + "types": [], "allowJs": true, "target": "ES6", "skipLibCheck": true, From 9640fc24736deb90b988199ca13218224514b2cb Mon Sep 17 00:00:00 2001 From: Mo David Date: Sun, 20 Sep 2026 18:25:19 +0800 Subject: [PATCH 25/93] chore: redeploy --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index d5aa256cb..a22efee5a 100644 --- a/README.md +++ b/README.md @@ -16,3 +16,4 @@ npm run dev ```bash npm run test ``` + From 8b6693ec98774aaa2c1e31d31893221aa9bb7d7a Mon Sep 17 00:00:00 2001 From: jayylmao Date: Mon, 21 Sep 2026 13:09:46 +0800 Subject: [PATCH 26/93] style(student): rm top bar on student listing page --- components/features/student/app-header.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/components/features/student/app-header.tsx b/components/features/student/app-header.tsx index 626fb3a43..eef7923b2 100644 --- a/components/features/student/app-header.tsx +++ b/components/features/student/app-header.tsx @@ -171,9 +171,12 @@ export function StudentAppHeader({ pathname.startsWith("/super-listing/") || pathname.startsWith("/student/super-listing/"); + const isListingRoute = pathname.startsWith("/search/"); + if ( navigationHidden || isSuperListingRoute || + isListingRoute || !routeExcluded(HIDE_ON_ROUTES) ) { return null; From 54743d89e06c13e3913c2e118a177b9f25549673 Mon Sep 17 00:00:00 2001 From: Jana Marie Bantolino Date: Mon, 21 Sep 2026 16:45:08 +0800 Subject: [PATCH 27/93] feat(employers): add notification icon for users not receiving applicant digests --- app/hire/god/employers/page.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/hire/god/employers/page.tsx b/app/hire/god/employers/page.tsx index b157a25b4..dc0989505 100644 --- a/app/hire/god/employers/page.tsx +++ b/app/hire/god/employers/page.tsx @@ -3,6 +3,7 @@ import { useRouter, useSearchParams } from "next/navigation"; import { Suspense, useState, useCallback, useRef } from "react"; import { toast } from "sonner"; +import { BellOff } from "lucide-react"; import { Button, Badge, Input } from "@betterinternship/components"; import { ListShell, @@ -166,6 +167,12 @@ function TeamEmailsList({ > {m.email} + {!m.receives_applicant_digest && ( + + )} ))} From c4a2f4e67107dadf52273b11d315a290d23903de Mon Sep 17 00:00:00 2001 From: Jana Marie Bantolino Date: Tue, 22 Sep 2026 16:16:03 +0800 Subject: [PATCH 28/93] feat(verification): implement OTP cooldown management and session storage updates --- app/hire/register/verify/page.tsx | 20 ++++++++++++++++++++ app/student/register/page.tsx | 11 +++++++---- hooks/use-otp-verification.ts | 3 +++ 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/app/hire/register/verify/page.tsx b/app/hire/register/verify/page.tsx index fae0ca192..2ca2414af 100644 --- a/app/hire/register/verify/page.tsx +++ b/app/hire/register/verify/page.tsx @@ -14,6 +14,8 @@ import { useAuthContext } from "../../authctx"; import { HireAuthShell } from "@/components/features/hire/hire-auth-shell"; type RegistrationProfile = Record; +const OTP_SENT_AT_KEY = "hire-registration-otp-sent-at"; +const OTP_COOLDOWN_SECONDS = 60; export default function VerifyHireRegistrationPage() { const router = useRouter(); @@ -48,6 +50,7 @@ export default function VerifyHireRegistrationPage() { const completeActivation = async () => { sessionStorage.removeItem("hire-registration-email"); + sessionStorage.removeItem(OTP_SENT_AT_KEY); await refreshAuthentication(); router.replace("/dashboard"); }; @@ -61,6 +64,7 @@ export default function VerifyHireRegistrationPage() { otpInputProps, requestOtp, sending, + startCooldown, } = useOtpVerification({ email: email.trim().toLowerCase(), requestOtpAction: (address) => @@ -75,6 +79,19 @@ export default function VerifyHireRegistrationPage() { }, }); + useEffect(() => { + if (!hasSentCode) return; + + const sentAt = Number(sessionStorage.getItem(OTP_SENT_AT_KEY)); + if (!Number.isFinite(sentAt)) return; + + const remaining = Math.ceil( + (sentAt + OTP_COOLDOWN_SECONDS * 1000 - Date.now()) / 1000, + ); + if (remaining > 0) startCooldown(remaining); + else sessionStorage.removeItem(OTP_SENT_AT_KEY); + }, [hasSentCode, startCooldown]); + const submitCode = async () => { const result = await activateOtp(otpInputProps.value, { failureMessage: "Verification code not valid.", @@ -117,6 +134,7 @@ export default function VerifyHireRegistrationPage() { sessionStorage.removeItem("hire-registration-profile"); sessionStorage.setItem("hire-registration-email", normalizedEmail); + sessionStorage.setItem(OTP_SENT_AT_KEY, String(Date.now())); setAccountCreated(true); setHasSentCode(true); } catch { @@ -131,6 +149,7 @@ export default function VerifyHireRegistrationPage() { failureMessage: "Couldn't send verification code. Try again.", }); if (result?.success) { + sessionStorage.setItem(OTP_SENT_AT_KEY, String(Date.now())); sessionStorage.setItem("hire-registration-email", normalizedEmail); setHasSentCode(true); } @@ -173,6 +192,7 @@ export default function VerifyHireRegistrationPage() { setter={(value) => { setEmail(value); setHasSentCode(false); + sessionStorage.removeItem(OTP_SENT_AT_KEY); setRegistrationError(""); }} maxLength={80} diff --git a/app/student/register/page.tsx b/app/student/register/page.tsx index ca34d6dac..9be3bf540 100644 --- a/app/student/register/page.tsx +++ b/app/student/register/page.tsx @@ -67,13 +67,15 @@ function RegisterPageContent() { // skip main register page if the user is already registered. useEffect(() => { if (step === 1 && auth.isAuthenticated()) { + if (submitting) return; + if (skipOtpStep) { finishRegistration(); } else { setStep(2); } } - }, [step, auth, router, skipOtpStep]); + }, [step, auth, router, skipOtpStep, submitting]); // Direct visits to /register (no OAuth pass, so no reg cookie) can never // register — treat them as if they clicked the sign-in button instead. @@ -121,7 +123,6 @@ function RegisterPageContent() { return () => { cancelled = true; }; - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const regForm = useForm({ @@ -141,7 +142,7 @@ function RegisterPageContent() { */ const handleSubmit = (values: FormInputs) => { setSubmitting(true); - const shouldSkipOtp = skipOtpStep || isNoUniversity(values.university); + const shouldSkipOtpForUniversity = isNoUniversity(values.university); // Check for missing fields if (!values.first_name?.trim()) { @@ -188,7 +189,9 @@ function RegisterPageContent() { setSubmitting(false); - if (shouldSkipOtp) { + const autoVerified = Boolean(response?.user?.edu_verification_email); + + if (shouldSkipOtpForUniversity || autoVerified) { finishRegistration(); return; } diff --git a/hooks/use-otp-verification.ts b/hooks/use-otp-verification.ts index 848185b9f..579592329 100644 --- a/hooks/use-otp-verification.ts +++ b/hooks/use-otp-verification.ts @@ -74,6 +74,8 @@ export function useOtpVerification({ useEffect(() => { if (email !== prevEmailRef.current) { setError(""); + setIsCoolingDown(false); + setCountdown(0); prevEmailRef.current = email; } }, [email]); @@ -224,6 +226,7 @@ export function useOtpVerification({ value: otp, }, resetOtp, + startCooldown, requestOtp, sending, }; From 05b187762e7184257f8d4b2688b84a68f90406b5 Mon Sep 17 00:00:00 2001 From: Jana Marie Bantolino Date: Tue, 22 Sep 2026 18:54:14 +0800 Subject: [PATCH 29/93] refactor: remove unused Archive import and label from ApplicantPage and ApplicationRow components --- components/features/hire/dashboard/ApplicantPage.tsx | 1 - components/features/hire/dashboard/ApplicationRow.tsx | 1 - 2 files changed, 2 deletions(-) diff --git a/components/features/hire/dashboard/ApplicantPage.tsx b/components/features/hire/dashboard/ApplicantPage.tsx index e15397cbc..0e340f0e3 100644 --- a/components/features/hire/dashboard/ApplicantPage.tsx +++ b/components/features/hire/dashboard/ApplicantPage.tsx @@ -41,7 +41,6 @@ import { ChevronLeft, ChevronRight, HelpCircle, - Archive, } from "lucide-react"; import { useCallback, useEffect, useState, useMemo } from "react"; import { Divider } from "@/components/ui/divider"; diff --git a/components/features/hire/dashboard/ApplicationRow.tsx b/components/features/hire/dashboard/ApplicationRow.tsx index d3b169b34..cd16dcdfa 100644 --- a/components/features/hire/dashboard/ApplicationRow.tsx +++ b/components/features/hire/dashboard/ApplicationRow.tsx @@ -224,7 +224,6 @@ export function ApplicationRow({ } }} enabled={canArchive} - label="Archive" disabledLabel={ARCHIVE_DISABLED_LABEL} /> )} From fc7f05f7ed05ccf8954c93e638649774290a58d3 Mon Sep 17 00:00:00 2001 From: jayylmao <73204320+jayylmao@users.noreply.github.com> Date: Tue, 22 Sep 2026 21:24:22 +0800 Subject: [PATCH 30/93] refactor(create/edit job): discard modal visual consistency --- app/hire/dashboard/manage/page.tsx | 2 +- components/modals/DiscardEditModal.tsx | 43 ++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 components/modals/DiscardEditModal.tsx diff --git a/app/hire/dashboard/manage/page.tsx b/app/hire/dashboard/manage/page.tsx index 79e5715ad..17d791f41 100644 --- a/app/hire/dashboard/manage/page.tsx +++ b/app/hire/dashboard/manage/page.tsx @@ -39,7 +39,7 @@ function ManageContent() { return ( <> - + {profile.data?.is_verified === false ? (
diff --git a/components/modals/DiscardEditModal.tsx b/components/modals/DiscardEditModal.tsx new file mode 100644 index 000000000..6b1a01fe9 --- /dev/null +++ b/components/modals/DiscardEditModal.tsx @@ -0,0 +1,43 @@ +import { TriangleAlert } from "lucide-react"; +import { HeaderIcon } from "../ui/text"; +import { Button } from "@betterinternship/components"; + +interface DiscardEditModalProps { + onConfirm: () => void; + onCancel: () => void; + title?: string; + message?: string; + confirmLabel?: string; +} + +export default function DiscardEditModal({ + onConfirm, + onCancel, + title = "Discard your changes?", + message = "All unsaved changes will be lost.", + confirmLabel = "Discard edits", +}: DiscardEditModalProps) { + return ( +
+
+ +

{title}

+
+ {message} + + {/* action buttons */} +
+ + +
+
+ ); +} From e3bd07cb85b4b66d9a515c08746f1dc0367262e3 Mon Sep 17 00:00:00 2001 From: jayylmao <73204320+jayylmao@users.noreply.github.com> Date: Tue, 22 Sep 2026 21:24:31 +0800 Subject: [PATCH 31/93] chore(package): bump package version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 6710348af..00322cac0 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "lint": "next lint" }, "dependencies": { - "@betterinternship/components": "^2.9.3", + "@betterinternship/components": "^2.9.4", "@betterinternship/core": "^2.25.0", "@betterinternship/schema": "^1.11.0", "@marsidev/react-turnstile": "^1.4.2", From b59a8689afed54552dd3093227dfbbd331411cf2 Mon Sep 17 00:00:00 2001 From: jayylmao <73204320+jayylmao@users.noreply.github.com> Date: Tue, 22 Sep 2026 21:24:58 +0800 Subject: [PATCH 32/93] feat(dropdown): add mobile-optimized layout --- components/ui/dropdown-menu.tsx | 211 +++++++++++++++++++++----------- 1 file changed, 142 insertions(+), 69 deletions(-) diff --git a/components/ui/dropdown-menu.tsx b/components/ui/dropdown-menu.tsx index b1ffbebbf..c78ec2dee 100644 --- a/components/ui/dropdown-menu.tsx +++ b/components/ui/dropdown-menu.tsx @@ -1,6 +1,6 @@ import { useEffect, useState, useRef, type ReactNode } from "react"; import { cn } from "@betterinternship/components"; -import { Check, ChevronDown, ChevronUp } from "lucide-react"; +import { Check, ChevronDown, ChevronUp, X } from "lucide-react"; import StatusBadge, { getStatusFilterKey, STATUS_COLOR_CLASSES, @@ -9,6 +9,7 @@ import StatusBadge, { import { UI_STATUS_MAP } from "@/lib/consts/application"; import { createPortal } from "react-dom"; import { AnimatePresence, motion } from "framer-motion"; +import { useAppContext } from "@/lib/ctx-app"; export type DropdownMenuItem = { id: string; @@ -32,6 +33,7 @@ export const DropdownMenu = ({ placeholder?: ReactNode; withDescriptions?: boolean; }) => { + const { isMobile } = useAppContext(); const [isOpen, setIsOpen] = useState(false); const [activeItem, setActiveItem] = useState(defaultItem); const [hasSelection, setHasSelection] = useState(!placeholder); @@ -42,6 +44,8 @@ export const DropdownMenu = ({ : STATUS_COLOR_CLASSES[getStatusFilterKey(parseInt(activeItem.id))] : "border-gray-300 bg-background text-gray-700"; const menuRef = useRef(null); + const panelRef = useRef(null); + const isOpenRef = useRef(isOpen); const [pos, setPos] = useState<{ top?: number; bottom?: number; @@ -86,16 +90,35 @@ export const DropdownMenu = ({ }, [defaultItem, placeholder]); useEffect(() => { - const handleClickOut = (e: MouseEvent) => { - if (menuRef.current && !menuRef.current.contains(e.target as Node)) { - setIsOpen(false); + isOpenRef.current = isOpen; + }, [isOpen]); + + // Capture-phase click (not mousedown): mousedown and click are separate + // events, so closing on mousedown never stopped the click that follows it + // from also landing on whatever was underneath (e.g. opening the + // applicant row a status dropdown sits in). Intercepting the click itself, + // before it reaches its target, is what actually swallows it. + useEffect(() => { + const handleOutsideClick = (e: MouseEvent) => { + if (!isOpenRef.current) return; + + const target = e.target as Node; + if ( + menuRef.current?.contains(target) || + panelRef.current?.contains(target) + ) { + return; } + + setIsOpen(false); + e.preventDefault(); + e.stopPropagation(); }; - document.addEventListener("mousedown", handleClickOut); + document.addEventListener("click", handleOutsideClick, true); return () => { - document.removeEventListener("mousedown", handleClickOut); + document.removeEventListener("click", handleOutsideClick, true); }; }, []); @@ -151,73 +174,123 @@ export const DropdownMenu = ({ {createPortal( {isOpen && ( - + {isMobile && ( + setIsOpen(false)} + /> )} - onClick={(e) => e.stopPropagation()} - > - {items.map((item, idx) => { - const itemFilterKey = getStatusFilterKey(parseInt(item.id)); - const itemStatusClass = withDescriptions - ? STATUS_DESCRIPTION_STYLES[itemFilterKey].item - : STATUS_COLOR_CLASSES[itemFilterKey]; - const itemHoverClass = withDescriptions - ? "" - : STATUS_HOVER_CLASSES[itemFilterKey]; + e.stopPropagation()} + > + {isMobile && ( +
+ + {placeholder ?? "Select status"} + + +
+ )} + {items.map((item, idx) => { + const itemFilterKey = getStatusFilterKey(parseInt(item.id)); + const itemStatusClass = withDescriptions + ? STATUS_DESCRIPTION_STYLES[itemFilterKey].item + : STATUS_COLOR_CLASSES[itemFilterKey]; + const itemHoverClass = withDescriptions + ? "" + : STATUS_HOVER_CLASSES[itemFilterKey]; - return ( -
{ - e.stopPropagation(); - setActiveItem(item); - setHasSelection(true); - setIsOpen(false); - item.onClick?.(); - }} - > - {withDescriptions ? ( - <> - { + e.stopPropagation(); + setActiveItem(item); + setHasSelection(true); + setIsOpen(false); + item.onClick?.(); + }} + > + {withDescriptions ? ( + <> + + {item.id === activeItem.id && ( + + )} + + ) : ( + - {item.id === activeItem.id && ( - - )} - - ) : ( - - )} -
- ); - })} -
+ )} +
+ ); + })} + + )} , document.body, From 1cf0ce524175cf2d67433ac1ee104dd260116ef1 Mon Sep 17 00:00:00 2001 From: jayylmao <73204320+jayylmao@users.noreply.github.com> Date: Tue, 22 Sep 2026 21:25:21 +0800 Subject: [PATCH 33/93] refactor(create/edit job): discard modal visual consistency --- components/modals/modal-registry.tsx | 36 ++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/components/modals/modal-registry.tsx b/components/modals/modal-registry.tsx index f39a539a4..6e4b70b66 100644 --- a/components/modals/modal-registry.tsx +++ b/components/modals/modal-registry.tsx @@ -26,6 +26,7 @@ import type { ResumeDTO } from "@/lib/api/services"; import ApplicationActionModal from "./ApplicationActionModal"; import DeleteJobListingModal from "./DeleteJobListingModal"; import CloseListingModal from "./CloseListingModal"; +import DiscardEditModal from "./DiscardEditModal"; import { Job, PublicUser } from "@/lib/db/db.types"; import DeleteResumeModal from "./DeleteResumeModal"; import { AddResumeModal } from "../features/student/profile/AddResumeModal"; @@ -182,6 +183,41 @@ export const useModalRegistry = () => { ), close: () => close("close-listing"), }, + // Confirms leaving the create/edit listing form with unsaved changes. + discardEdit: { + open: ({ + onConfirm, + title, + message, + confirmLabel, + }: { + onConfirm: () => void; + title?: string; + message?: string; + confirmLabel?: string; + }) => + open( + "discard-edit", + DefaultModalLayout, + { + onConfirm(); + close("discard-edit"); + }} + onCancel={() => close("discard-edit")} + title={title} + message={message} + confirmLabel={confirmLabel} + />, + { + title: "Discard changes?", + closeOnBackdropClick: true, + closeOnEscapeKey: true, + showHeaderDivider: true, + }, + ), + close: () => close("discard-edit"), + }, // modal for sharing a job listing's short link // (Docs/plans/JOB_SHORT_LINKS_IMPLEMENTATION_PLAN.md D12). shareJob: { From ea94e1089f3a9757768ae63ed924cb8b6e6b3717 Mon Sep 17 00:00:00 2001 From: jayylmao <73204320+jayylmao@users.noreply.github.com> Date: Tue, 22 Sep 2026 21:25:42 +0800 Subject: [PATCH 34/93] refactor(create/edit job): discard modal visual consistency --- .../features/hire/listings/createJob.tsx | 37 ++++--------------- 1 file changed, 8 insertions(+), 29 deletions(-) diff --git a/components/features/hire/listings/createJob.tsx b/components/features/hire/listings/createJob.tsx index b57701c0f..9116087a8 100644 --- a/components/features/hire/listings/createJob.tsx +++ b/components/features/hire/listings/createJob.tsx @@ -12,7 +12,6 @@ import { useFormData } from "@/lib/form-data"; import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; import { useMobile } from "@/hooks/use-mobile"; -import { TriangleAlert } from "lucide-react"; import { cn } from "@betterinternship/components"; import { BasicStep } from "./create-job-steps/BasicStep"; import { SetupStep } from "./create-job-steps/SetupStep"; @@ -249,20 +248,10 @@ const CreateJobPage = ({ + +
+ ); + if (loading) { return ( @@ -68,14 +105,20 @@ function EditJobPageRouteContent() { return ( <> - + ); From 8151077adca14509b8d23cddf3aa042f85015f58 Mon Sep 17 00:00:00 2001 From: jayylmao <73204320+jayylmao@users.noreply.github.com> Date: Tue, 22 Sep 2026 21:27:13 +0800 Subject: [PATCH 36/93] style(job details): rm extra top padding to increase info density --- app/hire/listings/details/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/hire/listings/details/page.tsx b/app/hire/listings/details/page.tsx index c3563a7fc..55465326c 100644 --- a/app/hire/listings/details/page.tsx +++ b/app/hire/listings/details/page.tsx @@ -50,7 +50,7 @@ function JobDetailsPageRouteContent() { onJobUpdate={updateJob} backHref={`/dashboard/manage?jobId=${jobId}`} /> -
+
From 6675f2f3bf43445e71cb8a8bcabfde8b97632748 Mon Sep 17 00:00:00 2001 From: jayylmao <73204320+jayylmao@users.noreply.github.com> Date: Tue, 22 Sep 2026 21:27:58 +0800 Subject: [PATCH 37/93] style(job not found): use unified component for not found error --- app/student/search/[job_id]/page.tsx | 49 +++++++++++++++------------- 1 file changed, 27 insertions(+), 22 deletions(-) diff --git a/app/student/search/[job_id]/page.tsx b/app/student/search/[job_id]/page.tsx index d0a52e77a..a9f309896 100644 --- a/app/student/search/[job_id]/page.tsx +++ b/app/student/search/[job_id]/page.tsx @@ -2,8 +2,14 @@ import React, { useCallback, useState } from "react"; import { useParams, useRouter } from "next/navigation"; -import { ArrowLeft, X } from "lucide-react"; -import { Button, PageContainer, Card } from "@betterinternship/components"; +import { ArrowLeft, TriangleAlert, X } from "lucide-react"; +import { + Button, + PageContainer, + Card, + PageHeader, + StatusNotice, +} from "@betterinternship/components"; import { useProfileData, useJobData } from "@/lib/api/student.data.api"; import { useModalRef } from "@/hooks/use-modal"; import { useMobile } from "@/hooks/use-mobile"; @@ -60,26 +66,25 @@ export default function JobPage() { if (!job || (!job.data && !job.isPending)) { return ( -
-
- - -
-

Job does not exist.

-

- If you are coming from a valid link, it is possible that the job - was deleted by the employer. -

-
-
+
+
+ router.back()} + className="flex items-center gap-2 px-3 py-2" + > + + Back + + } + variant="destructive" + />
); From 7c4d3399bc2ca97922a1b940eff56087fdd073e1 Mon Sep 17 00:00:00 2001 From: jayylmao <73204320+jayylmao@users.noreply.github.com> Date: Tue, 22 Sep 2026 21:28:14 +0800 Subject: [PATCH 38/93] chore(job page): rm unnecessary import --- app/student/search/[job_id]/page.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/app/student/search/[job_id]/page.tsx b/app/student/search/[job_id]/page.tsx index a9f309896..b0da42d1f 100644 --- a/app/student/search/[job_id]/page.tsx +++ b/app/student/search/[job_id]/page.tsx @@ -7,7 +7,6 @@ import { Button, PageContainer, Card, - PageHeader, StatusNotice, } from "@betterinternship/components"; import { useProfileData, useJobData } from "@/lib/api/student.data.api"; From 3b401e50a253d909197f347ba7e4a9fcf923e20f Mon Sep 17 00:00:00 2001 From: jayylmao <73204320+jayylmao@users.noreply.github.com> Date: Tue, 22 Sep 2026 21:28:37 +0800 Subject: [PATCH 39/93] style(applicant page): rm unnecessary vertical padding to improve info density --- components/features/hire/dashboard/ApplicantPage.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/components/features/hire/dashboard/ApplicantPage.tsx b/components/features/hire/dashboard/ApplicantPage.tsx index 0e340f0e3..843bf8c10 100644 --- a/components/features/hire/dashboard/ApplicantPage.tsx +++ b/components/features/hire/dashboard/ApplicantPage.tsx @@ -263,10 +263,10 @@ export function ApplicantPage({
} /> - +
{/* "header" ish portion */} From 3e8ac1f00b39d3d70c189555ce69dd49c88680ba Mon Sep 17 00:00:00 2001 From: jayylmao <73204320+jayylmao@users.noreply.github.com> Date: Tue, 22 Sep 2026 21:29:25 +0800 Subject: [PATCH 40/93] style(applicants list): improve display on no applicants --- .../hire/dashboard/ApplicationsContent.tsx | 217 +++++++++--------- 1 file changed, 114 insertions(+), 103 deletions(-) diff --git a/components/features/hire/dashboard/ApplicationsContent.tsx b/components/features/hire/dashboard/ApplicationsContent.tsx index f990537b5..dba5908a1 100644 --- a/components/features/hire/dashboard/ApplicationsContent.tsx +++ b/components/features/hire/dashboard/ApplicationsContent.tsx @@ -7,6 +7,7 @@ import { useSearchParams } from "next/navigation"; import { useApplicationSelection } from "@/hooks/use-application-selection"; import { Badge, + StatusNotice, Table, TableBody, TableCell, @@ -14,7 +15,7 @@ import { TableHeader, TableRow, } from "@betterinternship/components"; -import { EmployerApplication } from "@/lib/db/db.types"; +import { EmployerApplication, Job } from "@/lib/db/db.types"; import { ApplicationRow } from "./ApplicationRow"; import { useAppContext } from "@/lib/ctx-app"; import { useDbRefs } from "@/lib/db/use-refs"; @@ -25,6 +26,7 @@ import { ArchiveRestore, Calendar, ContactRound, + Ghost, GraduationCap, ListCheck, Trash2, @@ -42,6 +44,7 @@ import { ApplicationsCommandBar } from "./ApplicationsCommandBar"; import { FormCheckbox } from "@/components/EditForm"; import { type DropdownMenuItem } from "@/components/ui/dropdown-menu"; import { ActionButton } from "@/components/ui/action-button"; +import { ShareJobButton } from "../../student/job/share-job-button"; interface ApplicationsContentProps { applications: EmployerApplication[]; @@ -54,6 +57,7 @@ interface ApplicationsContentProps { apps: EmployerApplication[], status?: number, ) => void; + job: Job; } export const ApplicationsContent = forwardRef< @@ -67,6 +71,7 @@ export const ApplicationsContent = forwardRef< onApplicationClick, setSelectedApplication, onAction, + job, }, ref, ) { @@ -363,7 +368,12 @@ export const ApplicationsContent = forwardRef< )) ) : (
- No applications under this category. + } + />
)}
@@ -397,108 +407,109 @@ export const ApplicationsContent = forwardRef<
) : ( - - - - - - - -
- - Applicant -
-
- {isSuperListing ? ( - <> - -
- - Date applied -
-
- -
- - Status -
-
- - ) : ( - <> - -
- - Education -
-
- -
- - Crediting -
-
- -
- - Expected start date -
-
- -
- - Date applied -
-
- -
- - Status -
-
- - )} - -
-
- - {visibleApplications.length ? ( - visibleApplications.map((application, index) => ( - { - if (selectedApplications.size === 0) { - onApplicationClick(application); - } else { - toggleSelect(application.id!, v); - } - }} - setSelectedApplication={setSelectedApplication} - checkboxSelected={selectedApplications.has(application.id!)} - onToggleSelect={(v) => toggleSelect(application.id!, v)} - onAction={onAction} - statuses={getRowStatuses(application)} - /> - )) - ) : ( - - - - No applications under this category. - - + <> +
+ + + + + + +
+ + Applicant +
+
+ {isSuperListing ? ( + <> + +
+ + Date applied +
+
+ +
+ + Status +
+
+ + ) : ( + <> + +
+ + Education +
+
+ +
+ + Crediting +
+
+ +
+ + Expected start date +
+
+ +
+ + Date applied +
+
+ +
+ + Status +
+
+ + )} +
- )} - -
+ + + {visibleApplications.length > 0 && + visibleApplications.map((application, index) => ( + { + if (selectedApplications.size === 0) { + onApplicationClick(application); + } else { + toggleSelect(application.id!, v); + } + }} + setSelectedApplication={setSelectedApplication} + checkboxSelected={selectedApplications.has(application.id!)} + onToggleSelect={(v) => toggleSelect(application.id!, v)} + onAction={onAction} + statuses={getRowStatuses(application)} + /> + ))} + + + {visibleApplications.length === 0 && ( + } + /> + )} + )}
); From f780536ffe890459f48cd0401ad08b4baa461419 Mon Sep 17 00:00:00 2001 From: jayylmao <73204320+jayylmao@users.noreply.github.com> Date: Tue, 22 Sep 2026 21:30:26 +0800 Subject: [PATCH 41/93] feat(recruiter job page): move navigation into menu to improve info density --- .../features/hire/dashboard/JobHeader.tsx | 426 +++++++++--------- 1 file changed, 215 insertions(+), 211 deletions(-) diff --git a/components/features/hire/dashboard/JobHeader.tsx b/components/features/hire/dashboard/JobHeader.tsx index 62900dd68..fff8059d0 100644 --- a/components/features/hire/dashboard/JobHeader.tsx +++ b/components/features/hire/dashboard/JobHeader.tsx @@ -1,5 +1,10 @@ import { useState, type ReactNode } from "react"; -import { Button } from "@betterinternship/components"; +import { + Button, + Popover, + PopoverContent, + PopoverTrigger, +} from "@betterinternship/components"; import { Toggle } from "@/components/ui/toggle"; import { useEmployerApplications, @@ -7,7 +12,14 @@ import { } from "@/hooks/use-employer-api"; import { Job } from "@/lib/db/db.types"; import { cn } from "@betterinternship/components"; -import { ArrowLeft, Edit, Info, Trash2, Users } from "lucide-react"; +import { + ArrowLeft, + Edit, + Info, + MoreHorizontal, + Trash2, + Users, +} from "lucide-react"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import { useListingsBusinessLogic } from "@/hooks/hire/listings/use-listings-business-logic"; @@ -26,17 +38,20 @@ export default function JobHeader({ onJobUpdate, backHref, applicantActions, + showJobTitle = true, }: { job: Job; onJobUpdate?: (updates: Partial) => void; backHref?: string; applicantActions?: ReactNode; + showJobTitle?: boolean; }) { const router = useRouter(); const { ownedJobs, update_job, delete_job, unpause_job } = useOwnedJobs(); const { saving } = useListingsBusinessLogic(ownedJobs); const [reEnabling, setReEnabling] = useState(false); const [togglingActive, setTogglingActive] = useState(false); + const [actionsOpen, setActionsOpen] = useState(false); const openNotificationsRequiredModal = useNotificationsRequiredModal(); // Counts feed the close-listing warning (plan §4.3) — already loaded here, @@ -151,6 +166,8 @@ export default function JobHeader({ }); }; + const closeMenu = () => setActionsOpen(false); + const desktopActionButtons = ( <> ); + const statusBadge = ( +
+ + {job.is_active ? "Active" : job.paused ? "Inactive" : "Paused"} + + {job.paused && !!job.waiting_count && ( + + {job.waiting_count} student{job.waiting_count === 1 ? "" : "s"}{" "} + waiting + + )} +
+ ); + + // Mobile-only: the desktop keeps the buttons and toggle as separate, + // always-visible controls (see desktopActionButtons + desktopToggle below). + const mobileActionsMenu = ( + + + + + + + + + + + + {job.paused ? ( + <> +
+ + Edit +
+

+ Re-activate the listing first +

+ + ) : ( + + + + )} + +
+ +
+ Visible to students +
+ void handleToggleActive()} + loading={togglingActive} + /> +
+
+ {job.paused && ( +
+ +
+ )} + +
+ + + + + ); + return (
-
+
{isMobile ? (
- - - -
-
- void handleToggleActive()} - loading={togglingActive} - /> -
- - {job.is_active - ? "Active" - : job.paused - ? "Inactive" - : "Paused"} - - {job.paused && ( - <> - {!!job.waiting_count && ( - - {job.waiting_count} student - {job.waiting_count === 1 ? "" : "s"} waiting - - )} - - - )} -
-
- - {job.paused - ? "Re-activate the listing first" - : "Toggle the visibility of the listing to students."} - -
-
-
-

- {job?.title} -

-
- {applicantActions ?? ( -
- - - - - - - {job.paused ? ( - - -
- -
-
- - Re-activate the listing first - -
- ) : ( - - - - )} +
+ {showJobTitle && ( +
+

+ {job?.title} +

+
+ )}
- )} + {showJobTitle && ( +
+ {statusBadge} + {mobileActionsMenu} +
+ )} +
+ {applicantActions}
) : (
@@ -462,41 +487,20 @@ export default function JobHeader({ loading={togglingActive} />
- - {job.is_active - ? "Active" - : job.paused - ? "Inactive" - : "Paused"} - + {statusBadge} {job.paused && ( - <> - {!!job.waiting_count && ( - - {job.waiting_count} student - {job.waiting_count === 1 ? "" : "s"} waiting - - )} - - + )}
From f3ad2443ff3f3928c605dce0ab01bf2744694ae3 Mon Sep 17 00:00:00 2001 From: jayylmao <73204320+jayylmao@users.noreply.github.com> Date: Tue, 22 Sep 2026 21:31:16 +0800 Subject: [PATCH 42/93] chore: pass job into layout for job share cta --- components/features/hire/dashboard/JobTabs.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/components/features/hire/dashboard/JobTabs.tsx b/components/features/hire/dashboard/JobTabs.tsx index 9f78f1db0..72ac5b793 100644 --- a/components/features/hire/dashboard/JobTabs.tsx +++ b/components/features/hire/dashboard/JobTabs.tsx @@ -86,7 +86,7 @@ export default function JobTabs({ selectedJob }: JobTabsProps) { router.push(`/dashboard/applicant?applicationId=${application.id}`); }; - if (isLoading || !isAuthenticated()) return null; + if (isLoading || !isAuthenticated() || !selectedJob) return null; return ( <> @@ -106,6 +106,7 @@ export default function JobTabs({ selectedJob }: JobTabsProps) { onApplicationClick={handleApplicationClick} setSelectedApplication={setSelectedApplication} onAction={triggerAction} + job={selectedJob} />
From 4c6e22eee457565573ff299f741a36d913a1d362 Mon Sep 17 00:00:00 2001 From: jayylmao <73204320+jayylmao@users.noreply.github.com> Date: Tue, 22 Sep 2026 21:32:13 +0800 Subject: [PATCH 43/93] refactor(create/edit job): discard modal visual consistency --- components/features/hire/listings/editJob.tsx | 490 +++++++++--------- 1 file changed, 234 insertions(+), 256 deletions(-) diff --git a/components/features/hire/listings/editJob.tsx b/components/features/hire/listings/editJob.tsx index 211be0a6b..004289d3d 100644 --- a/components/features/hire/listings/editJob.tsx +++ b/components/features/hire/listings/editJob.tsx @@ -1,11 +1,6 @@ "use client"; -import { - Button, - PageContainer, - PageHeader, -} from "@betterinternship/components"; -import { TriangleAlert } from "lucide-react"; +import { Button, PageContainer } from "@betterinternship/components"; import { cn } from "@betterinternship/components"; import { BasicStep } from "./create-job-steps/BasicStep"; import { SetupStep } from "./create-job-steps/SetupStep"; @@ -16,7 +11,7 @@ import { Job, UpdateJobChallengeListingPayload } from "@/lib/db/db.types"; import { useDbRefs } from "@/lib/db/use-refs"; import { useFormData } from "@/lib/form-data"; import { useRouter, useSearchParams } from "next/navigation"; -import { useEffect, useState } from "react"; +import { forwardRef, useEffect, useImperativeHandle, useState } from "react"; import { useMobile } from "@/hooks/use-mobile"; interface EditJobPageProps { @@ -29,161 +24,134 @@ interface EditJobPageProps { job: UpdateJobChallengeListingPayload, ) => Promise<{ success: boolean }>; actions?: React.ReactNode[]; + // Lets the parent's header know whether the Save button should be + // disabled, since required-field validation lives here with formData. + onMissingChange?: (missing: boolean) => void; } -const EditJobPage = ({ - job, - is_editing = false, - set_is_editing = () => {}, - saving = false, - update_job, - actions = [], -}: EditJobPageProps) => { - const { job_pay_freq, isNotNull } = useDbRefs(); - const { isMobile } = useMobile(); - const [isMissing, setMissing] = useState(false); - const [challengeTitle, setChallengeTitle] = useState(""); - const [challengeDescription, setChallengeDescription] = useState(""); - const { formData, setField, setFields, fieldSetter } = useFormData(job); - const router = useRouter(); - const profile = useProfile(); - const searchParams = useSearchParams(); - const isSuperListing = Boolean(job.challenge); - - const refreshFlag = searchParams.get("refresh"); - - const { job_categories } = useDbRefs(); +export interface EditJobPageHandle { + submit: () => void; +} - // keep duplicate tempDisable per instruction - const tempDisable = [ - "f5bd5b55-14e3-44c7-be02-477e3ae446d2", - "381239bf-7c82-4f87-a1b8-39d952f8876b", - "8b323584-9340-41e8-928e-f9345f1ad59e", - "e5a73819-ee90-43fb-b71b-7ba12f0a4dbf", - "642e5b8e-41ac-478f-bc28-ed03ef653c78", - "91b180be-3d23-4f0a-bd64-c82cef9d3ae5", - "0a28afa9-f9aa-4782-b29a-adaf18e1f388", - "63624cde-383a-406e-af54-c58bd2af425f", - "94a29ca7-a014-474f-8958-68fc5c10e734", - "06a890ac-5f7f-4763-b733-9e45cb03defd", - "657da8d0-69a7-4312-8da1-7bd97145310b", - ]; +const EditJobPage = forwardRef( + function EditJobPage( + { + job, + is_editing = false, + set_is_editing = () => {}, + saving = false, + update_job, + actions = [], + onMissingChange, + }, + ref, + ) { + const { job_pay_freq, isNotNull } = useDbRefs(); + const { isMobile } = useMobile(); + const [isMissing, setMissing] = useState(false); + const [challengeTitle, setChallengeTitle] = useState(""); + const [challengeDescription, setChallengeDescription] = useState(""); + const { formData, setField, setFields, fieldSetter } = + useFormData(job); + const router = useRouter(); + const profile = useProfile(); + const searchParams = useSearchParams(); + const isSuperListing = Boolean(job.challenge); - // create category groups - copied from createJob - const category_items = - job_categories - .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) - .map((category) => { - if ( - tempDisable.includes(category.id) || - category.parent_id == null || - category.name == "Engineering" - ) - return null; + const refreshFlag = searchParams.get("refresh"); - return { - id: category.id, - name: category.name, - }; - }) - .filter(Boolean) ?? []; + const { job_categories } = useDbRefs(); - const registry = useModalRegistry(); + // keep duplicate tempDisable per instruction + const tempDisable = [ + "f5bd5b55-14e3-44c7-be02-477e3ae446d2", + "381239bf-7c82-4f87-a1b8-39d952f8876b", + "8b323584-9340-41e8-928e-f9345f1ad59e", + "e5a73819-ee90-43fb-b71b-7ba12f0a4dbf", + "642e5b8e-41ac-478f-bc28-ed03ef653c78", + "91b180be-3d23-4f0a-bd64-c82cef9d3ae5", + "0a28afa9-f9aa-4782-b29a-adaf18e1f388", + "63624cde-383a-406e-af54-c58bd2af425f", + "94a29ca7-a014-474f-8958-68fc5c10e734", + "06a890ac-5f7f-4763-b733-9e45cb03defd", + "657da8d0-69a7-4312-8da1-7bd97145310b", + ]; - const isSalaryFilled = typeof formData.salary === "number" && formData.salary; - const payFreqMissing = isSalaryFilled && !isNotNull(formData.salary_freq); + // create category groups - copied from createJob + const category_items = + job_categories + .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) + .map((category) => { + if ( + tempDisable.includes(category.id) || + category.parent_id == null || + category.name == "Engineering" + ) + return null; - const listingInternshipPreferences = () => ({ - internship_types: formData.internship_preferences?.internship_types, - job_setup_ids: formData.internship_preferences?.job_setup_ids, - job_category_ids: formData.internship_preferences?.job_category_ids, - job_commitment_ids: formData.internship_preferences?.job_commitment_ids, - expected_start_date: formData.internship_preferences?.expected_start_date, - require_github: formData.internship_preferences?.require_github, - require_portfolio: formData.internship_preferences?.require_portfolio, - }); + return { + id: category.id, + name: category.name, + }; + }) + .filter(Boolean) ?? []; - useEffect(() => { - if (refreshFlag === "true") { - const newUrl = new URL(window.location.href); - newUrl.searchParams.delete("refresh"); - window.history.replaceState({}, "", newUrl.toString()); - window.location.reload(); - } - }, [refreshFlag]); + const registry = useModalRegistry(); - const handleSaveEdit = async () => { - if (!formData.title?.trim()) { - alert("Job title is required"); - return; - } + const isSalaryFilled = + typeof formData.salary === "number" && formData.salary; + const payFreqMissing = isSalaryFilled && !isNotNull(formData.salary_freq); - if (!formData.location?.trim()) { - alert("Job location is required"); - return; - } + const listingInternshipPreferences = () => ({ + internship_types: formData.internship_preferences?.internship_types, + job_setup_ids: formData.internship_preferences?.job_setup_ids, + job_category_ids: formData.internship_preferences?.job_category_ids, + job_commitment_ids: formData.internship_preferences?.job_commitment_ids, + expected_start_date: formData.internship_preferences?.expected_start_date, + require_github: formData.internship_preferences?.require_github, + require_portfolio: formData.internship_preferences?.require_portfolio, + }); - if (!formData.description?.trim()) { - alert("Job description is required"); - return; - } + useEffect(() => { + if (refreshFlag === "true") { + const newUrl = new URL(window.location.href); + newUrl.searchParams.delete("refresh"); + window.history.replaceState({}, "", newUrl.toString()); + window.location.reload(); + } + }, [refreshFlag]); - if (isSuperListing && !challengeTitle.trim()) { - alert("Challenge title is required"); - return; - } + const handleSaveEdit = async () => { + if (!formData.title?.trim()) { + alert("Job title is required"); + return; + } - const edited_job: UpdateJobChallengeListingPayload = { - title: formData.title, - description: formData.description ?? "", - requirements: null, - location: formData.location ?? profile.data?.location ?? "", - allowance: formData.allowance, - salary: formData.allowance === 0 ? formData.salary : undefined, - salary_freq: formData.allowance === 0 ? formData.salary_freq : undefined, - is_unlisted: formData.is_unlisted ?? false, - internship_preferences: listingInternshipPreferences(), - ...(isSuperListing - ? { - challenge: { - title: challengeTitle.trim(), - description: challengeDescription.trim() || null, - }, - } - : {}), - }; + if (!formData.location?.trim()) { + alert("Job location is required"); + return; + } - if (job.id) { - const result = await update_job(job.id, edited_job); - if (result.success) { - router.push(`/dashboard/manage?jobId=${job.id}`); + if (!formData.description?.trim()) { + alert("Job description is required"); + return; } - } - }; - useEffect(() => { - if (job) { - const merged = job.requirements?.trim() - ? `${job.description ?? ""}\n\n### Requirements\n${job.requirements}`.trim() - : (job.description ?? ""); - setFields({ ...job, description: merged, requirements: null } as Job); - setChallengeTitle(job.challenge?.title ?? ""); - setChallengeDescription(job.challenge?.description ?? ""); - } - }, [job]); + if (isSuperListing && !challengeTitle.trim()) { + alert("Challenge title is required"); + return; + } - useEffect(() => { - if (job && saving) { const edited_job: UpdateJobChallengeListingPayload = { - id: formData.id, - title: formData.title ?? "", + title: formData.title, description: formData.description ?? "", requirements: null, - location: formData.location ?? "", - allowance: formData.allowance ?? undefined, - salary: formData.salary ?? null, - salary_freq: formData.salary_freq ?? undefined, - is_unlisted: formData.is_unlisted, + location: formData.location ?? profile.data?.location ?? "", + allowance: formData.allowance, + salary: formData.allowance === 0 ? formData.salary : undefined, + salary_freq: + formData.allowance === 0 ? formData.salary_freq : undefined, + is_unlisted: formData.is_unlisted ?? false, internship_preferences: listingInternshipPreferences(), ...(isSuperListing ? { @@ -195,76 +163,141 @@ const EditJobPage = ({ : {}), }; - update_job(edited_job.id ?? "", edited_job).then( - // @ts-ignore - ({ job: updated_job }) => { - set_is_editing(false); - }, - ); - } - }, [saving]); + if (job.id) { + const result = await update_job(job.id, edited_job); + if (result.success) { + router.push(`/dashboard/manage?jobId=${job.id}`); + } + } + }; + + useEffect(() => { + if (job) { + const merged = job.requirements?.trim() + ? `${job.description ?? ""}\n\n### Requirements\n${job.requirements}`.trim() + : (job.description ?? ""); + setFields({ ...job, description: merged, requirements: null } as Job); + setChallengeTitle(job.challenge?.title ?? ""); + setChallengeDescription(job.challenge?.description ?? ""); + } + }, [job]); - useEffect(() => { - const missing = - !formData.title?.trim() || - !formData.location?.trim() || - !formData.description?.trim() || - formData.allowance === undefined || - !formData.internship_preferences?.internship_types?.length || - !formData.internship_preferences?.job_commitment_ids?.length || - !formData.internship_preferences?.job_setup_ids?.length || - !formData.internship_preferences?.job_category_ids?.length || - (isSuperListing && !challengeTitle.trim()) || - payFreqMissing; + useEffect(() => { + if (job && saving) { + const edited_job: UpdateJobChallengeListingPayload = { + id: formData.id, + title: formData.title ?? "", + description: formData.description ?? "", + requirements: null, + location: formData.location ?? "", + allowance: formData.allowance ?? undefined, + salary: formData.salary ?? null, + salary_freq: formData.salary_freq ?? undefined, + is_unlisted: formData.is_unlisted, + internship_preferences: listingInternshipPreferences(), + ...(isSuperListing + ? { + challenge: { + title: challengeTitle.trim(), + description: challengeDescription.trim() || null, + }, + } + : {}), + }; - setMissing(missing); - }, [ - formData.title, - formData.location, - formData.description, - formData.allowance, - formData.internship_preferences?.internship_types, - formData.internship_preferences?.job_commitment_ids, - formData.internship_preferences?.job_setup_ids, - formData.internship_preferences?.job_category_ids, - formData.salary, - formData.salary_freq, - challengeTitle, - isSuperListing, - ]); + update_job(edited_job.id ?? "", edited_job).then( + // @ts-ignore + ({ job: updated_job }) => { + set_is_editing(false); + }, + ); + } + }, [saving]); - const openDiscardModal = () => - registry.warning.open({ - icon: TriangleAlert, - iconColor: "text-primary", - title: "Are you sure you want to cancel?", - message: "All unsaved changes will be lost.", - primaryAction: { label: "Continue Editing", onClick: () => {} }, - secondaryAction: { - label: "Discard Edits", - onClick: () => router.push(`/dashboard/manage?jobId=${job.id}`), - }, - panelClassName: "sm:max-w-md", - }); + useEffect(() => { + const missing = + !formData.title?.trim() || + !formData.location?.trim() || + !formData.description?.trim() || + formData.allowance === undefined || + !formData.internship_preferences?.internship_types?.length || + !formData.internship_preferences?.job_commitment_ids?.length || + !formData.internship_preferences?.job_setup_ids?.length || + !formData.internship_preferences?.job_category_ids?.length || + (isSuperListing && !challengeTitle.trim()) || + payFreqMissing; - return ( - <> - {!isMobile && ( -
-
- -
+ setMissing(missing); + onMissingChange?.(Boolean(missing)); + }, [ + formData.title, + formData.location, + formData.description, + formData.allowance, + formData.internship_preferences?.internship_types, + formData.internship_preferences?.job_commitment_ids, + formData.internship_preferences?.job_setup_ids, + formData.internship_preferences?.job_category_ids, + formData.salary, + formData.salary_freq, + challengeTitle, + isSuperListing, + ]); + + const openDiscardModal = () => + registry.discardEdit.open({ + onConfirm: () => router.push(`/dashboard/manage?jobId=${job.id}`), + }); + + // Desktop's Cancel/Save controls now live in the parent page's JobHeader + // (see EditJobPageRouteContent) so there's a single header instead of two + // stacked ones. The parent triggers a save through this handle. + useImperativeHandle(ref, () => ({ submit: () => void handleSaveEdit() })); + + return ( + <> + +
+ + + +
+
+ {isMobile && ( +
+
-
- )} - -
- - - -
-
- {isMobile && ( -
-
- - -
-
- )} - - ); -}; + + ); + }, +); export default EditJobPage; From 591d82697280bc1f9405790a77abb05b5cb2e538 Mon Sep 17 00:00:00 2001 From: jayylmao <73204320+jayylmao@users.noreply.github.com> Date: Tue, 22 Sep 2026 21:32:39 +0800 Subject: [PATCH 44/93] style(job details): improve info density by removing vertical padding --- components/features/hire/listings/jobDetails.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/features/hire/listings/jobDetails.tsx b/components/features/hire/listings/jobDetails.tsx index 3b8c5aa51..a436d9131 100644 --- a/components/features/hire/listings/jobDetails.tsx +++ b/components/features/hire/listings/jobDetails.tsx @@ -21,7 +21,7 @@ const JobDetailsPage = ({ job }: JobDetailsPageProps) => { ).length; return ( - +

From 868bc76845cfbce78aab88aba19ca3665019303c Mon Sep 17 00:00:00 2001 From: jayylmao <73204320+jayylmao@users.noreply.github.com> Date: Tue, 22 Sep 2026 22:01:31 +0800 Subject: [PATCH 45/93] fix(student): breakpoint detection for mobile bottom nav with top nav --- components/shared/mobile-bottom-nav.tsx | 2 +- components/shared/mobile-nav-wrapper.tsx | 8 +------- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/components/shared/mobile-bottom-nav.tsx b/components/shared/mobile-bottom-nav.tsx index 100272939..c6e5f45b5 100644 --- a/components/shared/mobile-bottom-nav.tsx +++ b/components/shared/mobile-bottom-nav.tsx @@ -91,7 +91,7 @@ export const MobileBottomNav: React.FC = ({ // Logged in: show full navigation return ( -
+
{/* Search Button */} } diff --git a/components/shared/mobile-nav-wrapper.tsx b/components/shared/mobile-nav-wrapper.tsx index f4583a046..9f5b2b7c8 100644 --- a/components/shared/mobile-nav-wrapper.tsx +++ b/components/shared/mobile-nav-wrapper.tsx @@ -1,16 +1,10 @@ "use client"; import { usePathname } from "next/navigation"; -import { useMobile } from "@/hooks/use-mobile"; import { MobileBottomNav } from "@/components/shared/mobile-bottom-nav"; import { useHeaderContext } from "@/lib/ctx-header"; -/** - * Wrapper component that only renders MobileBottomNav on mobile devices - * Hides on homepage - */ export default function MobileNavWrapper() { - const { isMobile } = useMobile(); const pathname = usePathname(); const { navigationHidden } = useHeaderContext(); const hide = @@ -26,7 +20,7 @@ export default function MobileNavWrapper() { pathname === "/super-listing" || pathname.startsWith("/super-listing/"); - if (!isMobile || hide) { + if (hide) { return null; } From ab4f50a582077b99e4222240c6ebe505beef431a Mon Sep 17 00:00:00 2001 From: jayylmao <73204320+jayylmao@users.noreply.github.com> Date: Tue, 22 Sep 2026 23:09:47 +0800 Subject: [PATCH 46/93] refactor(hire): rm nested scroll contexts --- app/hire/allowLanding.tsx | 6 +++--- app/hire/layout.tsx | 10 ++++------ 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/app/hire/allowLanding.tsx b/app/hire/allowLanding.tsx index 79c15e32f..dbb5d3345 100644 --- a/app/hire/allowLanding.tsx +++ b/app/hire/allowLanding.tsx @@ -16,10 +16,10 @@ export default function AllowLanding({ const hideHeader = isStudentLanding || isAuthRoute; return ( -
+ <> {!hideHeader && } -
{children}
+
{children}
{!isStudentLanding &&
} -
+ ); } diff --git a/app/hire/layout.tsx b/app/hire/layout.tsx index cfed74315..168f1b335 100644 --- a/app/hire/layout.tsx +++ b/app/hire/layout.tsx @@ -94,13 +94,11 @@ const HTMLContent = ({ - -
-
- {children} -
+
+
+ {children}
- +
From 3933e3b7a5978acf14ca6a1be1dca3a40fd34f67 Mon Sep 17 00:00:00 2001 From: jayylmao <73204320+jayylmao@users.noreply.github.com> Date: Tue, 22 Sep 2026 23:10:26 +0800 Subject: [PATCH 47/93] fix(my jobs): spacing with simplified scroll context --- app/student/applications/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/student/applications/page.tsx b/app/student/applications/page.tsx index 26aa90931..c007c3299 100644 --- a/app/student/applications/page.tsx +++ b/app/student/applications/page.tsx @@ -169,7 +169,7 @@ function MyJobsPageInner() {
- + Applications From 75211432b8ada983074f954c801414d7ed34e6e9 Mon Sep 17 00:00:00 2001 From: jayylmao <73204320+jayylmao@users.noreply.github.com> Date: Tue, 22 Sep 2026 23:10:44 +0800 Subject: [PATCH 48/93] refactor(student): rm nested scroll contexts --- app/student/allowLanding.tsx | 10 ++++------ app/student/layout.tsx | 12 +++++------- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/app/student/allowLanding.tsx b/app/student/allowLanding.tsx index 7adf85060..c03381915 100644 --- a/app/student/allowLanding.tsx +++ b/app/student/allowLanding.tsx @@ -18,17 +18,15 @@ export default function AllowLanding({ isStudentLanding || pathname.startsWith("/companies/") || isChallengePage; if (hideSharedHeader) { - return ( -
{children}
- ); + return
{children}
; } return ( -
+ <> -
{children}
-
+
{children}
+ ); } diff --git a/app/student/layout.tsx b/app/student/layout.tsx index 1740e7991..a8ce83858 100644 --- a/app/student/layout.tsx +++ b/app/student/layout.tsx @@ -119,14 +119,12 @@ const HTMLContent = ({ - -
-
- {children} -
- +
+
+ {children}
- + +
From 2a4f9b42bada322aae747737b8ceb7aa229f477f Mon Sep 17 00:00:00 2001 From: jayylmao <73204320+jayylmao@users.noreply.github.com> Date: Tue, 22 Sep 2026 23:11:15 +0800 Subject: [PATCH 49/93] fix(job header): fix positioning on simplified scroll context --- 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 fff8059d0..07ecfaf51 100644 --- a/components/features/hire/dashboard/JobHeader.tsx +++ b/components/features/hire/dashboard/JobHeader.tsx @@ -426,7 +426,7 @@ export default function JobHeader({ ); return ( -
+
{isMobile ? (
From 1df74141428cb2a74eed6f87558130f6bf4663b7 Mon Sep 17 00:00:00 2001 From: jayylmao <73204320+jayylmao@users.noreply.github.com> Date: Tue, 22 Sep 2026 23:11:59 +0800 Subject: [PATCH 50/93] fix(recruit admin): inconsistent mobile detection for admin button --- components/features/hire/app-header.tsx | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/components/features/hire/app-header.tsx b/components/features/hire/app-header.tsx index 05c6c5e2e..f39ddb731 100644 --- a/components/features/hire/app-header.tsx +++ b/components/features/hire/app-header.tsx @@ -2,7 +2,6 @@ import { useAuthContext } from "@/app/hire/authctx"; import { useProfile } from "@/hooks/use-employer-api"; -import { useMobile } from "@/hooks/use-mobile"; import { usePfpUrl } from "@/hooks/use-pfp"; import { usePathname, useRouter } from "next/navigation"; import { @@ -25,7 +24,6 @@ import { * @component */ export function HireAppHeader() { - const { isMobile } = useMobile(); const pathname = usePathname() ?? ""; const router = useRouter(); const { god, proxy, exitProxy, user, logout } = useAuthContext(); @@ -57,14 +55,14 @@ export function HireAppHeader() { const isGodActive = pathname.startsWith("/god"); const adminButton = god ? ( - isMobile ? ( + <>
- ) : ( - ) + ) : undefined; return ( From 8e7b62e1314f4722e6adb29a8c5f7aa2e10edb33 Mon Sep 17 00:00:00 2001 From: jayylmao <73204320+jayylmao@users.noreply.github.com> Date: Tue, 22 Sep 2026 23:13:42 +0800 Subject: [PATCH 51/93] fix(search): scrollbar not being flush on the right side of the window --- components/features/student/search/SearchResultsDesktop.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/components/features/student/search/SearchResultsDesktop.tsx b/components/features/student/search/SearchResultsDesktop.tsx index bf3d11f97..f24d156e6 100644 --- a/components/features/student/search/SearchResultsDesktop.tsx +++ b/components/features/student/search/SearchResultsDesktop.tsx @@ -122,9 +122,10 @@ export function SearchResultsDesktop({
{/* Right: Details */} -
+
{selectedJob?.id ? ( Date: Tue, 22 Sep 2026 23:15:04 +0800 Subject: [PATCH 52/93] fix(student): student header not pinning on simplified scroll context --- components/features/student/app-header.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/features/student/app-header.tsx b/components/features/student/app-header.tsx index eef7923b2..c877dcf20 100644 --- a/components/features/student/app-header.tsx +++ b/components/features/student/app-header.tsx @@ -320,7 +320,7 @@ export function StudentAppHeader({ <>
Date: Tue, 22 Sep 2026 23:16:46 +0800 Subject: [PATCH 53/93] fix(search): scrollbar not flush against the right --- components/shared/jobs.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/components/shared/jobs.tsx b/components/shared/jobs.tsx index 1997fa22b..21d181934 100644 --- a/components/shared/jobs.tsx +++ b/components/shared/jobs.tsx @@ -724,6 +724,7 @@ export function JobDetails({ user, actions = [], applyDisabledText = "Complete required items to apply.", + className, }: { job: Job; user?: { @@ -732,6 +733,7 @@ export function JobDetails({ }; actions?: React.ReactNode[]; applyDisabledText?: string; + className?: string; }) { const hasGithub = !!user?.github_link?.trim(); const hasPortfolio = !!user?.portfolio_link?.trim(); @@ -745,7 +747,7 @@ export function JobDetails({ return ( <> -
+
{job.hibernating && } Date: Tue, 22 Sep 2026 23:17:03 +0800 Subject: [PATCH 54/93] chore(package): bump components package ver --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 00322cac0..7b42079cf 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "lint": "next lint" }, "dependencies": { - "@betterinternship/components": "^2.9.4", + "@betterinternship/components": "^2.9.5", "@betterinternship/core": "^2.25.0", "@betterinternship/schema": "^1.11.0", "@marsidev/react-turnstile": "^1.4.2", From 07c6b8d5281855ccd416ff1880c2cdd54d1c8dca Mon Sep 17 00:00:00 2001 From: jayylmao <73204320+jayylmao@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:00:44 +0800 Subject: [PATCH 55/93] feat(seo): make dedicated listing page visible to crawlers from search page --- .../student/search/SearchResultsDesktop.tsx | 18 +++++++++++------ .../student/search/SearchResultsMobile.tsx | 20 +++++++++++++------ 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/components/features/student/search/SearchResultsDesktop.tsx b/components/features/student/search/SearchResultsDesktop.tsx index f24d156e6..32591d2e9 100644 --- a/components/features/student/search/SearchResultsDesktop.tsx +++ b/components/features/student/search/SearchResultsDesktop.tsx @@ -1,5 +1,6 @@ "use client"; +import Link from "next/link"; import { cn } from "@betterinternship/components"; import { Paginator } from "@/components/ui/paginator"; import { Loader } from "@/components/ui/loader"; @@ -79,21 +80,26 @@ export function SearchResultsDesktop({ />
)} - -
onJobCardClick(job)} + onClick={(e) => { + e.preventDefault(); + onJobCardClick(job); + }} > onJobCardClick(job)} /> -
+
))}
diff --git a/components/features/student/search/SearchResultsMobile.tsx b/components/features/student/search/SearchResultsMobile.tsx index c06b9a3a6..8c595f95a 100644 --- a/components/features/student/search/SearchResultsMobile.tsx +++ b/components/features/student/search/SearchResultsMobile.tsx @@ -1,5 +1,6 @@ "use client"; +import Link from "next/link"; import { cn } from "@betterinternship/components"; import { Paginator } from "@/components/ui/paginator"; import { Loader } from "@/components/ui/loader"; @@ -43,11 +44,7 @@ export function SearchResultsMobile({ {jobsPage.length ? (
{jobsPage.map((job) => ( -
onJobCardClick(job)} - > +
{!job.challenge && !job.hibernating && (
)} - onJobCardClick(job)} /> + {/* seo optimization */} + { + e.preventDefault(); + onJobCardClick(job); + }} + > + onJobCardClick(job)} /> +
))}
From 2719c2fe4c1b67d93596439a10024b574c84c852 Mon Sep 17 00:00:00 2001 From: jayylmao <73204320+jayylmao@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:07:29 +0800 Subject: [PATCH 56/93] fix: shared cache leak between clients --- app/tanstack-provider.tsx | 32 ++++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/app/tanstack-provider.tsx b/app/tanstack-provider.tsx index 4fbc05d91..4ee7dab9a 100644 --- a/app/tanstack-provider.tsx +++ b/app/tanstack-provider.tsx @@ -1,18 +1,32 @@ "use client"; +import { useState } from "react"; import AsyncStorage from "@react-native-async-storage/async-storage"; -import { QueryClient, defaultShouldDehydrateQuery } from "@tanstack/react-query"; +import { + QueryClient, + defaultShouldDehydrateQuery, +} from "@tanstack/react-query"; import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client"; import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister"; -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - gcTime: 24 * 60 * 60 * 1000, - staleTime: 24 * 60 * 60 * 1000, +function makeQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { + gcTime: 24 * 60 * 60 * 1000, + staleTime: 24 * 60 * 60 * 1000, + }, }, - }, -}); + }); +} + +let browserQueryClient: QueryClient | undefined; + +function getQueryClient() { + if (typeof window === "undefined") return makeQueryClient(); + if (!browserQueryClient) browserQueryClient = makeQueryClient(); + return browserQueryClient; +} const asyncStoragePersister = createAsyncStoragePersister({ storage: typeof window === "undefined" ? undefined : AsyncStorage, @@ -23,6 +37,8 @@ export default function TanstackProvider({ }: { children: React.ReactNode; }) { + const [queryClient] = useState(getQueryClient); + return ( Date: Thu, 24 Sep 2026 15:15:04 +0800 Subject: [PATCH 57/93] feat: cache useRefs data to reduce db calls --- lib/db/use-refs-backend.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/lib/db/use-refs-backend.ts b/lib/db/use-refs-backend.ts index 6852c86bf..50222be64 100644 --- a/lib/db/use-refs-backend.ts +++ b/lib/db/use-refs-backend.ts @@ -8,6 +8,8 @@ */ import "server-only"; +import { cache } from "react"; +import { unstable_cache } from "next/cache"; import { College, University, @@ -41,7 +43,7 @@ const db = new Kysely({ /** * Fetches all refs tables on the server and returns serializable data for clients. */ -export const getRefsData = async (): Promise => { +const getRefsDataUncached = async (): Promise => { const [ colleges, universities, @@ -104,3 +106,11 @@ export const getRefsData = async (): Promise => { domains, }; }; + +// cache refs data for one hour and catch repeat calls to reduce db calls. +export const getRefsData = cache( + unstable_cache(getRefsDataUncached, ["refs-data"], { + revalidate: 3600, + tags: ["refs-data"], + }), +); From d67ba0cebd6f38c43078b5db9f5a27bcb59932e3 Mon Sep 17 00:00:00 2001 From: jayylmao <73204320+jayylmao@users.noreply.github.com> Date: Thu, 24 Sep 2026 16:01:33 +0800 Subject: [PATCH 58/93] refactor: move baseurl function for seo to own file --- app/student/layout.tsx | 23 +---------------------- lib/site-url.ts | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+), 22 deletions(-) create mode 100644 lib/site-url.ts diff --git a/app/student/layout.tsx b/app/student/layout.tsx index a8ce83858..96bdd22b3 100644 --- a/app/student/layout.tsx +++ b/app/student/layout.tsx @@ -14,29 +14,8 @@ import { SonnerToaster } from "@/components/ui/sonner-toast"; import { TooltipProvider } from "@/components/ui/tooltip"; import { AppMQJobsProvider } from "@/components/providers/mq-jobs-provider"; import { FilloutJobsProvider } from "@/hooks/forms/filloutFormProcess"; +import { baseUrl } from "@/lib/site-url"; -const baseUrl = (() => { - const isProduction = process.env.NODE_ENV === "production"; - const fallbackBaseUrl = isProduction - ? "https://www.betterinternship.com" - : "https://dev.betterinternship.com"; - const rawConfiguredUrl = process.env.NEXT_PUBLIC_CLIENT_URL?.trim(); - const configuredUrl = rawConfiguredUrl - ? /^https?:\/\//i.test(rawConfiguredUrl) - ? rawConfiguredUrl - : `https://${rawConfiguredUrl}` - : undefined; - - // Prevent localhost OG/Twitter URLs so metadata always points to shareable domains. - if ( - configuredUrl && - /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?(\/|$)/i.test(configuredUrl) - ) { - return fallbackBaseUrl; - } - - return configuredUrl || fallbackBaseUrl; -})().replace(/\/$/, ""); const ogImage = `${baseUrl}/og.png`; export const metadata: Metadata = { diff --git a/lib/site-url.ts b/lib/site-url.ts new file mode 100644 index 000000000..1fc997ee2 --- /dev/null +++ b/lib/site-url.ts @@ -0,0 +1,24 @@ +const baseUrl = (() => { + const isProduction = process.env.NODE_ENV === "production"; + const fallbackBaseUrl = isProduction + ? "https://www.betterinternship.com" + : "https://dev.betterinternship.com"; + const rawConfiguredUrl = process.env.NEXT_PUBLIC_CLIENT_URL?.trim(); + const configuredUrl = rawConfiguredUrl + ? /^https?:\/\//i.test(rawConfiguredUrl) + ? rawConfiguredUrl + : `https://${rawConfiguredUrl}` + : undefined; + + // Prevent localhost OG/Twitter URLs so metadata always points to shareable domains. + if ( + configuredUrl && + /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?(\/|$)/i.test(configuredUrl) + ) { + return fallbackBaseUrl; + } + + return configuredUrl || fallbackBaseUrl; +})().replace(/\/$/, ""); + +export { baseUrl }; From 9a3751eda8c15f9e23ea04419f024ab30923d00c Mon Sep 17 00:00:00 2001 From: jayylmao <73204320+jayylmao@users.noreply.github.com> Date: Thu, 24 Sep 2026 16:02:00 +0800 Subject: [PATCH 59/93] feat(seo): add crawler rules generator --- app/student/robots.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 app/student/robots.ts diff --git a/app/student/robots.ts b/app/student/robots.ts new file mode 100644 index 000000000..5652d78e3 --- /dev/null +++ b/app/student/robots.ts @@ -0,0 +1,20 @@ +import { baseUrl } from "@/lib/site-url"; +import type { MetadataRoute } from "next"; + +export default function robots(): MetadataRoute.Robots { + return { + rules: { + userAgent: "*", + allow: "/", + disallow: [ + "/profile", + "/applications", + "/saved", + "/forms", + "/search/og", + "/l", + ], + }, + sitemap: `${baseUrl}/sitemap.xml`, + }; +} From d815f61e2c5d6f48b69681163de839f232c46a48 Mon Sep 17 00:00:00 2001 From: jayylmao <73204320+jayylmao@users.noreply.github.com> Date: Thu, 24 Sep 2026 16:02:10 +0800 Subject: [PATCH 60/93] feat(seo): add sitemap generator --- app/student/sitemap.ts | 49 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 app/student/sitemap.ts diff --git a/app/student/sitemap.ts b/app/student/sitemap.ts new file mode 100644 index 000000000..eaaadc108 --- /dev/null +++ b/app/student/sitemap.ts @@ -0,0 +1,49 @@ +import type { MetadataRoute } from "next"; +import { baseUrl } from "@/lib/site-url"; + +export const revalidate = 3600; + +interface SitemapJob { + id: string; + last_activated_at?: string | null; + updated_at?: string | null; + created_at?: string | null; +} + +async function fetchSitemapJobs(): Promise { + try { + const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/jobs`, { + next: { revalidate: 3600 }, + }); + + if (!res.ok) return []; + + const data = (await res.json()) as { jobs?: SitemapJob[] }; + + return data.jobs ?? []; + } catch { + return []; + } +} + +export default async function sitemap(): Promise { + const jobs = await fetchSitemapJobs(); + + const jobEntries: MetadataRoute.Sitemap = jobs.map((job) => ({ + url: `${baseUrl}/search/${job.id}`, + lastModified: + job.last_activated_at ?? job.updated_at ?? job.created_at ?? undefined, + changeFrequency: "weekly", + priority: 0.8, + })); + + const staticEntries: MetadataRoute.Sitemap = [ + { url: baseUrl, changeFrequency: "daily", priority: 1.0 }, + { url: `${baseUrl}/search`, changeFrequency: "daily", priority: 0.9 }, + { url: `${baseUrl}/companies`, changeFrequency: "monthly", priority: 0.6 }, + { url: `${baseUrl}/privacy`, changeFrequency: "yearly", priority: 0.3 }, + { url: `${baseUrl}/terms`, changeFrequency: "yearly", priority: 0.3 }, + ]; + + return [...staticEntries, ...jobEntries]; +} From 360926004ec65e3cbf3c5723f777cec268a84c88 Mon Sep 17 00:00:00 2001 From: jayylmao <73204320+jayylmao@users.noreply.github.com> Date: Thu, 24 Sep 2026 16:10:01 +0800 Subject: [PATCH 61/93] feat(seo): add metadata to listing page --- app/student/search/[job_id]/layout.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/student/search/[job_id]/layout.tsx b/app/student/search/[job_id]/layout.tsx index 515e3119c..9feb50aa6 100644 --- a/app/student/search/[job_id]/layout.tsx +++ b/app/student/search/[job_id]/layout.tsx @@ -50,6 +50,8 @@ export async function generateMetadata({ return { title, description, + alternates: { canonical: `/search/${job_id}` }, + ...(job.is_unlisted ? { robots: { index: false, follow: true } } : {}), openGraph: { title, description, images: [image], type: "website" }, twitter: { card: "summary_large_image", From 303ef5367973cfed0c416a2ded0dc070ba493bc2 Mon Sep 17 00:00:00 2001 From: jayylmao <73204320+jayylmao@users.noreply.github.com> Date: Thu, 24 Sep 2026 16:10:23 +0800 Subject: [PATCH 62/93] feat(seo): add unlisted metadata to job listing --- lib/api/job-preview.server.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/api/job-preview.server.ts b/lib/api/job-preview.server.ts index c51f0dee9..03ba1c358 100644 --- a/lib/api/job-preview.server.ts +++ b/lib/api/job-preview.server.ts @@ -11,6 +11,7 @@ export interface JobPreviewData { job_setup_ids?: number[]; job_commitment_ids?: number[]; } | null; + is_unlisted?: boolean | null; } /** From e664608784c62d19a817b0d681d1947cd293671c Mon Sep 17 00:00:00 2001 From: jayylmao <73204320+jayylmao@users.noreply.github.com> Date: Thu, 24 Sep 2026 16:10:37 +0800 Subject: [PATCH 63/93] feat(seo): add search layout page for metadata --- app/student/search/layout.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 app/student/search/layout.tsx diff --git a/app/student/search/layout.tsx b/app/student/search/layout.tsx new file mode 100644 index 000000000..e42e4d395 --- /dev/null +++ b/app/student/search/layout.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from "next"; + +export const metadata: Metadata = { + alternates: { canonical: "/search" }, +}; + +export default function SearchLayout({ + children, +}: { + children: React.ReactNode; +}) { + return children; +} From be362c14e3f8c0f3d50d8f6d8bc339fde7c8d561 Mon Sep 17 00:00:00 2001 From: jayylmao <73204320+jayylmao@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:36:38 +0800 Subject: [PATCH 64/93] feat(seo): server-side search page rendering --- app/student/search/[job_id]/JobPageView.tsx | 195 +++++++++++++++++++ app/student/search/[job_id]/page.tsx | 196 ++------------------ lib/api/job-preview.server.ts | 15 ++ lib/api/student.data.api.ts | 13 +- 4 files changed, 237 insertions(+), 182 deletions(-) create mode 100644 app/student/search/[job_id]/JobPageView.tsx diff --git a/app/student/search/[job_id]/JobPageView.tsx b/app/student/search/[job_id]/JobPageView.tsx new file mode 100644 index 000000000..291026ca4 --- /dev/null +++ b/app/student/search/[job_id]/JobPageView.tsx @@ -0,0 +1,195 @@ +"use client"; + +import React, { useCallback, useState } from "react"; +import { useRouter } from "next/navigation"; +import { ArrowLeft, TriangleAlert, X } from "lucide-react"; +import { + Button, + PageContainer, + Card, + StatusNotice, +} from "@betterinternship/components"; +import { useProfileData, useJobData } from "@/lib/api/student.data.api"; +import { useModalRef } from "@/hooks/use-modal"; +import { useMobile } from "@/hooks/use-mobile"; +import { Loader } from "@/components/ui/loader"; +import { JobDetails } from "@/components/shared/jobs"; +import { ApplySuccessModal } from "@/components/modals/ApplySuccessModal"; +import { PageError } from "@/components/ui/error"; +import { SaveJobButton } from "@/components/features/student/job/save-job-button"; +import { ApplyToJobButton } from "@/components/features/student/job/apply-to-job-button"; +import { useApplicationActions } from "@/lib/api/student.actions.api"; +import { ShareJobButton } from "@/components/features/student/job/share-job-button"; +import type { ApplyPayload } from "@/components/modals/components/ApplyModal"; +import type { Job } from "@/lib/db/db.types"; +import { toast } from "sonner"; + +/** + * Job page interactive body. Rendered by the server + * page.tsx with the job already fetched (initialJob) so the details, share/ + * save/apply actions, and modals hydrate from real data instead of a + * client-only fetch. + */ +export function JobPageView({ + jobId, + initialJob, +}: { + jobId: string; + initialJob: Job; +}) { + const router = useRouter(); + const job = useJobData(jobId, { initialData: initialJob }); + const [isActionsSheetOpen, setIsActionsSheetOpen] = useState(false); + const { isMobile } = useMobile(); + const applySuccessModalRef = useModalRef(); + const applicationActions = useApplicationActions(); + + const profile = useProfileData(); + + const handleApply = useCallback( + async ({ resumeId }: ApplyPayload) => { + if (!job.data?.id || !resumeId) return; + + const response = await applicationActions.create.mutateAsync({ + job_id: job.data.id, + resume_id: resumeId, + }); + + if (response.message) { + toast.error(response.message); + return; + } + + applySuccessModalRef.current?.open(); + }, + [applicationActions.create, job.data], + ); + + if (job.error && !job.data) + return ( + + ); + + if (!job || (!job.data && !job.isPending)) { + return ( +
+
+ router.back()} + className="flex items-center gap-2 px-3 py-2" + > + + Back + + } + variant="destructive" + /> +
+
+ ); + } + + return ( + <> + {/* Desktop and Mobile Layout */} +
+ {job.isPending ? ( + Loading job details... + ) : ( +
+
+
+
+ +
+ {job.data && } + {job.data && !job.data.hibernating && ( + <> + + + + )} +
+
+
+
+ + {job.data?.id && ( + + {/* Job Header Card */} + + {/* Job Details Grid */} + + + + )} + + {isMobile && isActionsSheetOpen && ( +
+ +
+ {job.data?.id && ( + setIsActionsSheetOpen(false)} + /> + )} +
+
+ )} +
+ )} +
+ + + + ); +} diff --git a/app/student/search/[job_id]/page.tsx b/app/student/search/[job_id]/page.tsx index b0da42d1f..e18c2689a 100644 --- a/app/student/search/[job_id]/page.tsx +++ b/app/student/search/[job_id]/page.tsx @@ -1,188 +1,22 @@ -"use client"; - -import React, { useCallback, useState } from "react"; -import { useParams, useRouter } from "next/navigation"; -import { ArrowLeft, TriangleAlert, X } from "lucide-react"; -import { - Button, - PageContainer, - Card, - StatusNotice, -} from "@betterinternship/components"; -import { useProfileData, useJobData } from "@/lib/api/student.data.api"; -import { useModalRef } from "@/hooks/use-modal"; -import { useMobile } from "@/hooks/use-mobile"; -import { Loader } from "@/components/ui/loader"; -import { JobDetails } from "@/components/shared/jobs"; -import { ApplySuccessModal } from "@/components/modals/ApplySuccessModal"; -import { PageError } from "@/components/ui/error"; -import { SaveJobButton } from "@/components/features/student/job/save-job-button"; -import { ApplyToJobButton } from "@/components/features/student/job/apply-to-job-button"; -import { useApplicationActions } from "@/lib/api/student.actions.api"; -import { ShareJobButton } from "@/components/features/student/job/share-job-button"; -import type { ApplyPayload } from "@/components/modals/components/ApplyModal"; -import { toast } from "sonner"; +import { notFound } from "next/navigation"; +import { fetchJobFull } from "@/lib/api/job-preview.server"; +import { JobPageView } from "./JobPageView"; /** - * The individual job page. - * Allows viewing unlisted jobs. + * The individual job page. Server-rendered so the job's real content lands + * in the initial HTML for crawlers. */ -export default function JobPage() { - const router = useRouter(); - const params = useParams(); - const { job_id } = params; - const job = useJobData(job_id as string); - const [isActionsSheetOpen, setIsActionsSheetOpen] = useState(false); - const { isMobile } = useMobile(); - const applySuccessModalRef = useModalRef(); - const applicationActions = useApplicationActions(); - - const profile = useProfileData(); - - const handleApply = useCallback( - async ({ resumeId }: ApplyPayload) => { - if (!job.data?.id || !resumeId) return; - - const response = await applicationActions.create.mutateAsync({ - job_id: job.data.id, - resume_id: resumeId, - }); - - if (response.message) { - toast.error(response.message); - return; - } - - applySuccessModalRef.current?.open(); - }, - [applicationActions.create, job.data], - ); - - if (job.error) - return ( - - ); - - if (!job || (!job.data && !job.isPending)) { - return ( -
-
- router.back()} - className="flex items-center gap-2 px-3 py-2" - > - - Back - - } - variant="destructive" - /> -
-
- ); - } - - return ( - <> - {/* Desktop and Mobile Layout */} -
- {job.isPending ? ( - Loading job details... - ) : ( -
-
-
-
- -
- {job.data && } - {job.data && !job.data.hibernating && ( - <> - - - - )} -
-
-
-
+export const revalidate = 300; - {job.data?.id && ( - - {/* Job Header Card */} - - {/* Job Details Grid */} - - - - )} +export default async function JobPage({ + params, +}: { + params: Promise<{ job_id: string }>; +}) { + const { job_id } = await params; + const job = await fetchJobFull(job_id); - {isMobile && isActionsSheetOpen && ( -
- -
- {job.data?.id && ( - setIsActionsSheetOpen(false)} - /> - )} -
-
- )} -
- )} -
+ if (job === null) notFound(); - - - ); + return ; } diff --git a/lib/api/job-preview.server.ts b/lib/api/job-preview.server.ts index 03ba1c358..386c0265f 100644 --- a/lib/api/job-preview.server.ts +++ b/lib/api/job-preview.server.ts @@ -1,4 +1,5 @@ import "server-only"; +import { Job } from "../db/db.types"; export interface JobPreviewData { title?: string | null; @@ -42,3 +43,17 @@ export async function fetchJobPreview( return null; } } + +// fetch full job data for a given job ID +export async function fetchJobFull(jobId: string): Promise { + const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/jobs/${jobId}`, { + next: { revalidate: 300 }, + }); + + if (!res.ok) { + throw new Error(`Failed to fetch job with ID ${jobId}: ${res.status}`); + } + + const data = (await res.json()) as { job?: Job | null }; + return data?.job ?? null; +} diff --git a/lib/api/student.data.api.ts b/lib/api/student.data.api.ts index afe27917f..9eb3f303f 100644 --- a/lib/api/student.data.api.ts +++ b/lib/api/student.data.api.ts @@ -10,6 +10,7 @@ import { PublicUser, JobWaitlist, ListingInternshipPreferences, + Job, } from "@/lib/db/db.types"; /** @@ -118,8 +119,12 @@ export function useJobStatus() { * @param options.enabled Set false to defer the fetch (e.g. a ?jobId= deep * link that resolves via the current listing page first, and only falls * back to this fetch-by-id when the job isn't on that page). + * @param options.initialData Job object to pre-populate cache with */ -export function useJobData(jobId: string, options: { enabled?: boolean } = {}) { +export function useJobData( + jobId: string, + options: { enabled?: boolean; initialData?: Job } = {}, +) { const applications = useApplicationsData(); const applied = !!useMemo( () => applications.data.find((application) => application.job_id === jobId), @@ -129,6 +134,12 @@ export function useJobData(jobId: string, options: { enabled?: boolean } = {}) { queryKey: ["jobs", jobId], queryFn: async () => await JobService.getJobById(jobId), enabled: options.enabled, + ...(options.initialData + ? { + initialData: { success: true, job: options.initialData }, + initialDataUpdatedAt: 0, + } + : {}), }); return { isPending, data: data?.job ?? null, applied, error }; From e6a142f8b5d9c68cfa2fdecc176dee1c89ec03dc Mon Sep 17 00:00:00 2001 From: jayylmao <73204320+jayylmao@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:27:31 +0800 Subject: [PATCH 65/93] chore(package): update db schema types --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7b42079cf..b135af4a6 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "dependencies": { "@betterinternship/components": "^2.9.5", "@betterinternship/core": "^2.25.0", - "@betterinternship/schema": "^1.11.0", + "@betterinternship/schema": "^1.13.4", "@marsidev/react-turnstile": "^1.4.2", "@mdxeditor/editor": "^3.35.1", "@posthog/react": "^1.8.1", From 770c3489893f30c0dfc4cdce41f47d8872fd9b4f Mon Sep 17 00:00:00 2001 From: jayylmao <73204320+jayylmao@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:55:55 +0800 Subject: [PATCH 66/93] fix: robots txt 404 --- app/{student => }/robots.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) rename app/{student => }/robots.ts (56%) diff --git a/app/student/robots.ts b/app/robots.ts similarity index 56% rename from app/student/robots.ts rename to app/robots.ts index 5652d78e3..4db4c9ffd 100644 --- a/app/student/robots.ts +++ b/app/robots.ts @@ -1,7 +1,14 @@ import { baseUrl } from "@/lib/site-url"; import type { MetadataRoute } from "next"; +import { headers } from "next/headers"; + +export default async function robots(): Promise { + const host = (await headers()).get("host") ?? ""; + + if (host.startsWith("hire.")) { + return { rules: { userAgent: "*", disallow: ["/dashboard"] } }; + } -export default function robots(): MetadataRoute.Robots { return { rules: { userAgent: "*", From 96a572f45ac20044f6c201b5276cdc5ab9ff4955 Mon Sep 17 00:00:00 2001 From: jayylmao <73204320+jayylmao@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:56:10 +0800 Subject: [PATCH 67/93] chore: ignore postman files --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 5780363ba..25a650dc0 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,7 @@ CLAUDE.md AGENTS.md .claude/ .claudeignore + +# Postman files +.postman/ +postman/ From 729c687324ac9a47bb681776aed4be03a07b7ca4 Mon Sep 17 00:00:00 2001 From: jayylmao <73204320+jayylmao@users.noreply.github.com> Date: Thu, 24 Sep 2026 19:03:36 +0800 Subject: [PATCH 68/93] feat(seo): add schema to each job listing for crawlers --- app/student/search/[job_id]/layout.tsx | 16 +-- app/student/search/[job_id]/page.tsx | 19 ++- lib/seo/job-posting.ts | 162 +++++++++++++++++++++++++ lib/utils/markdown-utils.ts | 16 +++ 4 files changed, 197 insertions(+), 16 deletions(-) create mode 100644 lib/seo/job-posting.ts create mode 100644 lib/utils/markdown-utils.ts diff --git a/app/student/search/[job_id]/layout.tsx b/app/student/search/[job_id]/layout.tsx index 9feb50aa6..c5e81007f 100644 --- a/app/student/search/[job_id]/layout.tsx +++ b/app/student/search/[job_id]/layout.tsx @@ -1,20 +1,6 @@ import type { Metadata } from "next"; import { fetchJobPreview } from "@/lib/api/job-preview.server"; - -/** - * job.description is Markdown (rendered via react-markdown — see - * MarkdownBlock in components/shared/jobs.tsx), not HTML, so a chat preview - * needs the syntax stripped or it shows raw `#`/`*`/`[]` markup. - */ -const stripMarkdown = (text: string): string => - text - .replace(/```[\s\S]*?```/g, " ") - .replace(/`([^`]+)`/g, "$1") - .replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1") - .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") - .replace(/[#>*_~-]+/g, " ") - .replace(/\s+/g, " ") - .trim(); +import { stripMarkdown } from "@/lib/utils/markdown-utils"; /** * Server-side metadata for a job page — the crawler-visible reason a `/l/` diff --git a/app/student/search/[job_id]/page.tsx b/app/student/search/[job_id]/page.tsx index e18c2689a..758854d5f 100644 --- a/app/student/search/[job_id]/page.tsx +++ b/app/student/search/[job_id]/page.tsx @@ -1,6 +1,8 @@ import { notFound } from "next/navigation"; import { fetchJobFull } from "@/lib/api/job-preview.server"; import { JobPageView } from "./JobPageView"; +import { getRefsData } from "@/lib/db/use-refs-backend"; +import { buildJobListingSchema } from "@/lib/seo/job-posting"; /** * The individual job page. Server-rendered so the job's real content lands @@ -18,5 +20,20 @@ export default async function JobPage({ if (job === null) notFound(); - return ; + const refs = await getRefsData(); + const schema = buildJobListingSchema(job, refs); + + return ( + <> + {schema && ( +