;
-}) => {
- const profile = useProfileData();
- const { employerName } = useEmployerName(conversation.senderId);
- let lastSelf = false;
-
- return (
-
-
- {conversation.messages
- ?.map((message: any, idx: number) => {
- if (!idx) lastSelf = false;
- const oldLastSelf = lastSelf;
- lastSelf = message.sender_id === profile.data?.id;
- return {
- key: idx,
- message: message.message,
- self: message.sender_id === profile.data?.id,
- prevSelf: oldLastSelf,
- them: employerName,
- };
- })
- ?.toReversed()
- ?.map((d: any) => (
-
- ))}
-
- );
-};
-
-const ConversationCard = ({
- conversation,
- latestIsYou,
- latestMessage,
- setConversationId,
- isUnread,
-}: {
- conversation: any; // consider replacing with a proper type
- latestIsYou: boolean;
- latestMessage: string;
- setConversationId: (id: string) => void;
- isUnread?: boolean;
-}) => {
- const profile = useProfileData();
- const [employerId, setEmployerId] = useState("");
-
- useEffect(() => {
- setEmployerId(
- conversation?.subscribers?.find(
- (subscriberId: string) => subscriberId !== profile.data?.id,
- ) ?? "",
- );
- }, [conversation, profile.data?.id]);
-
- const { employerName } = useEmployerName(employerId);
-
- return (
- setConversationId(conversation.id)}
- onTouchStart={() => setConversationId(conversation.id)}
- >
-
-
- {employerId &&
}
-
-
- {employerName ?? "Conversation"}
-
- Unread
-
-
-
- {(latestIsYou ? "You: " : "") + (latestMessage ?? "")}
-
-
-
-
-
-
- );
-};
diff --git a/app/student/forms/components/FormDashboard.tsx b/app/student/forms/components/FormDashboard.tsx
index 8c0a7db7..127930be 100644
--- a/app/student/forms/components/FormDashboard.tsx
+++ b/app/student/forms/components/FormDashboard.tsx
@@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { FileSearch, FileText } from "lucide-react";
import { AnimatePresence, motion } from "framer-motion";
import { cn } from "@/lib/utils";
-import { FormTemplate } from "@/lib/db/use-moa-backend";
+import { FormTemplate } from "@/lib/db/forms-db.types";
import { Loader } from "@/components/ui/loader";
import {
FormRendererContextBridge,
@@ -62,10 +62,12 @@ const getTimestampMs = (timestamp?: string) => {
export default function FormDashboard({
generatedForms,
formTemplates,
+ formGroupDescription,
isLoading,
}: {
generatedForms: GeneratedFormItem[];
formTemplates: FormTemplate[];
+ formGroupDescription: string;
isLoading: boolean;
}) {
const { isMobile } = useMobile();
@@ -340,7 +342,9 @@ export default function FormDashboard({
- Form Templates
+
+ {formGroupDescription || "Form Templates"}
+
diff --git a/app/student/forms/components/FormSigningLayout.tsx b/app/student/forms/components/FormSigningLayout.tsx
index 082165b9..a927fbfa 100644
--- a/app/student/forms/components/FormSigningLayout.tsx
+++ b/app/student/forms/components/FormSigningLayout.tsx
@@ -68,6 +68,38 @@ const areFormValuesEqual = (
return leftEntries.every(([key, value]) => right[key] === value);
};
+const normalizeFieldName = (fieldName: string) =>
+ String(fieldName ?? "")
+ .trim()
+ .replace(/:default$/i, "");
+
+const getSignatureDerivedFieldNames = (
+ formMetadata: ReturnType["formMetadata"],
+) => {
+ const fields = formMetadata.getFieldsForEditorService();
+ const signatureFieldNames = new Set(
+ fields
+ .filter((field) => field.type === "signature")
+ .map((field) => normalizeFieldName(field.field)),
+ );
+
+ if (!signatureFieldNames.size) return [];
+
+ return fields
+ .filter((field) => {
+ if (field.source !== "derived" || typeof field.prefiller !== "string")
+ return false;
+
+ const prefiller = field.prefiller;
+ return Array.from(signatureFieldNames).some(
+ (signatureFieldName) =>
+ prefiller.includes(signatureFieldName) ||
+ prefiller.includes(`${signatureFieldName}:default`),
+ );
+ })
+ .map((field) => field.field);
+};
+
type SigningStep = "timeline" | "fields" | "preview-review" | "confirm";
const DESKTOP_BACK_STEP_RESET_DELAY_MS = 320;
@@ -122,6 +154,14 @@ export function FormSigningLayout({
>(null);
const [selectionTick, setSelectionTick] = useState(0);
+ useEffect(() => {
+ form.updateWetSignatureMode(!!noEsign);
+
+ return () => {
+ form.updateWetSignatureMode(false);
+ };
+ }, [noEsign]);
+
useEffect(() => {
if (typeof window === "undefined") return;
@@ -248,27 +288,37 @@ export function FormSigningLayout({
})),
[form.keyedFields, fieldOwnerByName],
);
- const initiatorManualFieldKeys = useMemo(
- () =>
- form.fields
- .filter(
- (field) =>
- field.signing_party_id === "initiator" && field.source === "manual",
- )
- .map((field) => field.field),
- [form.fields],
- );
+ const requiredManualFieldKeys = useMemo(() => {
+ const fields = noEsign
+ ? form.formMetadata.getFieldsForClientService(undefined)
+ : form.fields;
+
+ return fields
+ .filter((field) => {
+ if (field.source !== "manual" || field.type === "signature")
+ return false;
+
+ if (noEsign) return true;
+ return field.signing_party_id === "initiator";
+ })
+ .map((field) => field.field);
+ }, [form.fields, form.formMetadata, noEsign]);
const previewValuesWithDerived = useMemo(
() => withDerivedFormValues(form.formMetadata, previewValues),
[form.formMetadata, previewValues],
);
+ const wetSignatureHiddenFieldNames = useMemo(
+ () =>
+ noEsign ? getSignatureDerivedFieldNames(form.formMetadata) : [],
+ [form.formMetadata, noEsign],
+ );
const computeRequiredFieldsComplete = useCallback(
(nextValues: FormValues) =>
- initiatorManualFieldKeys.every(
+ requiredManualFieldKeys.every(
(fieldKey) => !!getFieldValue(nextValues, fieldKey),
),
- [initiatorManualFieldKeys],
+ [requiredManualFieldKeys],
);
const handleValuesChange = useCallback(
@@ -378,7 +428,9 @@ export function FormSigningLayout({
}) && Object.keys(recipientEmailErrors).length === 0
);
case "fields":
- return areRequiredFieldsComplete && signContext.hasAgreed;
+ return (
+ areRequiredFieldsComplete && (noEsign || signContext.hasAgreed)
+ );
case "preview-review":
return true;
case "confirm":
@@ -392,6 +444,7 @@ export function FormSigningLayout({
recipientEmails,
signContext,
form,
+ noEsign,
]);
const handleNext = useCallback(async () => {
@@ -572,9 +625,9 @@ export function FormSigningLayout({
setMobileFieldsTab("form");
setMobilePreviewNeedsAttention(false);
setHasConfirmedDetails(false);
- setAreRequiredFieldsComplete(initiatorManualFieldKeys.length === 0);
+ setAreRequiredFieldsComplete(requiredManualFieldKeys.length === 0);
setCurrentStep(initialStep);
- }, [formLabel, initialStep, initiatorManualFieldKeys.length, noEsign]);
+ }, [formLabel, initialStep, requiredManualFieldKeys.length, noEsign]);
useEffect(() => {
if (currentStep === "confirm") {
@@ -772,6 +825,8 @@ export function FormSigningLayout({
!isMobileLayout && selectedFieldSource === "form"
}
signingParties={recipients}
+ wetSignatureMode={!!noEsign}
+ hiddenFieldNames={wetSignatureHiddenFieldNames}
onFieldClick={handlePdfFieldSelect}
selectedFieldId={form.selectedPreviewId ?? undefined}
/>
diff --git a/app/student/forms/components/FormTemplatesList.tsx b/app/student/forms/components/FormTemplatesList.tsx
index f459c0e6..0ec8e13d 100644
--- a/app/student/forms/components/FormTemplatesList.tsx
+++ b/app/student/forms/components/FormTemplatesList.tsx
@@ -1,5 +1,5 @@
import { useFormRendererContext } from "@/components/features/student/forms/form-renderer.ctx";
-import { FormTemplate } from "@/lib/db/use-moa-backend";
+import { FormTemplate } from "@/lib/db/forms-db.types";
import { ChevronRight } from "lucide-react";
import { Card } from "@/components/ui/card";
import { cn } from "@/lib/utils";
diff --git a/app/student/forms/components/FormsAccessGate.tsx b/app/student/forms/components/FormsAccessGate.tsx
new file mode 100644
index 00000000..d99768b7
--- /dev/null
+++ b/app/student/forms/components/FormsAccessGate.tsx
@@ -0,0 +1,151 @@
+"use client";
+
+import { Fragment, type FormEvent, useState } from "react";
+import { motion } from "framer-motion";
+import { ArrowRight, Loader2 } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { Card } from "@/components/ui/card";
+import {
+ InputOTP,
+ InputOTPGroup,
+ InputOTPSlot,
+} from "@/components/ui/input-otp";
+
+const ACCESS_CODE_SLOT_CLASS =
+ "h-11 w-11 rounded-[0.33em] border border-gray-300 bg-white text-base font-semibold text-gray-900 shadow-inner-soft first:rounded-[0.33em] first:border-l last:rounded-[0.33em]";
+
+const accessCodeGroups = [
+ [0, 1, 2],
+ [3, 4, 5],
+];
+
+const normalizeAccessCode = (value: string) =>
+ value
+ .toUpperCase()
+ .replace(/[^A-Z0-9]/g, "")
+ .slice(0, 6);
+
+export function FormsAccessGate({
+ onAccessGranted,
+}: {
+ onAccessGranted: (accessCode: string) => Promise | void;
+}) {
+ const [accessCode, setAccessCode] = useState("");
+ const [error, setError] = useState("");
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const isAccessCodeComplete = accessCode.length === 6;
+
+ const handleSubmit = async (event: FormEvent) => {
+ event.preventDefault();
+ if (!isAccessCodeComplete || isSubmitting) return;
+
+ setError("");
+ setIsSubmitting(true);
+ try {
+ await onAccessGranted(accessCode);
+ } catch (error) {
+ setError(
+ error instanceof Error
+ ? error.message
+ : "Could not access forms. Please check your code and try again.",
+ );
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+ );
+}
diff --git a/app/student/forms/page.tsx b/app/student/forms/page.tsx
index 26f4f3b6..41b1e673 100644
--- a/app/student/forms/page.tsx
+++ b/app/student/forms/page.tsx
@@ -2,22 +2,26 @@
import { useProfileData } from "@/lib/api/student.data.api";
import { useRouter } from "next/navigation";
-import { FormService } from "@/lib/api/services";
+import { FormService, UserService } from "@/lib/api/services";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useMyForms } from "./myforms.ctx";
import {
FORM_TEMPLATES_STALE_TIME,
FORM_TEMPLATES_GC_TIME,
} from "@/lib/consts/cache";
-import { useEffect } from "react";
+import { useEffect, useState } from "react";
import { useAuthContext } from "@/lib/ctx-auth";
import FormDashboard from "./components/FormDashboard";
+import { FormsAccessGate } from "./components/FormsAccessGate";
import {
useFormFilloutProcessHandled,
useFormFilloutProcessPending,
useFormFilloutProcessReader,
} from "@/hooks/forms/filloutFormProcess";
import { isProfileVerified } from "@/lib/profile";
+import { hasFormsEnabledUniversity } from "@/lib/student-forms-access";
+import { AnimatePresence, motion } from "framer-motion";
+import { Loader } from "@/components/ui/loader";
/**
* The forms page component - shows either history or generate based on form count
@@ -30,28 +34,45 @@ export default function FormsPage() {
const myForms = useMyForms();
const queryClient = useQueryClient();
const { redirectIfNotLoggedIn, isAuthenticated } = useAuthContext();
+ const isStudentAuthenticated = isAuthenticated();
+ const canUseUniversityForms = hasFormsEnabledUniversity(profile.data);
+ const [hasFormsAccess, setHasFormsAccess] = useState(() =>
+ profile.isPending ? null : !!profile.data?.form_group_id,
+ );
+
+ useEffect(() => {
+ if (profile.isPending) return;
+
+ setHasFormsAccess((currentAccess) => {
+ if (currentAccess === true) return true;
+ return !!profile.data?.form_group_id;
+ });
+ }, [profile.data?.form_group_id, profile.isPending]);
// Auth redirect at body level (runs first)
redirectIfNotLoggedIn();
// Profile check only runs if authenticated
useEffect(() => {
- if (!isAuthenticated()) {
+ if (!isStudentAuthenticated) {
return; // Exit if not authenticated
}
if (profile.isPending) {
return;
}
+ if (!hasFormsEnabledUniversity(profile.data)) {
+ router.replace("/search");
+ return;
+ }
if (!isProfileVerified(profile.data)) {
- router.push("/register/verify");
+ router.replace("/register/verify?redirect=forms");
return;
}
-
- if (!profile.data?.department) router.push("/profile/complete-profile");
}, [
- isAuthenticated,
+ isStudentAuthenticated,
profile.data,
profile.data?.department,
+ profile.data?.university,
profile.isPending,
router,
]);
@@ -69,14 +90,16 @@ export default function FormsPage() {
// 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, isLoading } = useQuery({
- queryKey: ["my-form-templates"],
- queryFn: () => FormService.getMyFormTemplates(),
- 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
- });
+ const { data: { formTemplates, formGroupDescription } = {}, isLoading } =
+ useQuery({
+ queryKey: ["my-form-templates"],
+ queryFn: () => FormService.getMyFormTemplates(),
+ enabled: canUseUniversityForms && hasFormsAccess === true,
+ 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
// ? How it works right now:
@@ -96,11 +119,60 @@ export default function FormsPage() {
void queryClient.invalidateQueries({ queryKey: ["my-forms"] });
}, [formFilloutProcess.getAllPending()]);
+ const handleFormsAccessGranted = async (accessCode: string) => {
+ const response = await UserService.joinFormGroup(accessCode);
+
+ if (!response?.success) {
+ alert("Could not access forms. Please check your code and try again.");
+ console.error("Error: ", response.message);
+ setHasFormsAccess(false);
+ return;
+ }
+
+ await queryClient.invalidateQueries({ queryKey: ["my-profile"] });
+ await queryClient.invalidateQueries({ queryKey: ["my-form-templates"] });
+ await queryClient.invalidateQueries({ queryKey: ["my-forms"] });
+ setHasFormsAccess(true);
+ };
+
+ const isResolvingDestination =
+ !isStudentAuthenticated ||
+ profile.isPending ||
+ !profile.data ||
+ !isProfileVerified(profile.data) ||
+ !canUseUniversityForms;
+
+ if (isResolvingDestination || hasFormsAccess === null) {
+ return Loading forms... ;
+ }
+
return (
- !!ft) ?? []}
- isLoading={isLoading}
- />
+
+ {!hasFormsAccess ? (
+
+ ) : (
+
+ !!ft) ?? []}
+ formGroupDescription={formGroupDescription || ""}
+ isLoading={isLoading || profile.isPending}
+ />
+
+ )}
+
);
}
diff --git a/app/student/layout.tsx b/app/student/layout.tsx
index 3c7b3dfc..ca74245a 100644
--- a/app/student/layout.tsx
+++ b/app/student/layout.tsx
@@ -5,11 +5,11 @@ import { HeaderContextProvider } from "@/lib/ctx-header";
import { RefsContextProvider } from "@/lib/db/use-refs";
import { AppContextProvider } from "@/lib/ctx-app";
import { BIMoaContextProvider } from "@/lib/db/use-bi-moa";
+import { getRefsData } from "@/lib/db/use-refs-backend";
+import { getBiMoaData } from "@/lib/db/use-bi-moa-backend";
import { PostHogProvider } from "../posthog-provider";
import TanstackProvider from "../tanstack-provider";
import AllowLanding from "./allowLanding";
-import { ConversationsContextProvider } from "@/hooks/use-conversation";
-import { PocketbaseProvider } from "@/lib/pocketbase";
import { ModalProvider } from "@/components/providers/modal-provider/ModalProvider";
import MobileNavWrapper from "@/components/shared/mobile-nav-wrapper";
import { SonnerToaster } from "@/components/ui/sonner-toast";
@@ -81,14 +81,19 @@ export const viewport: Viewport = {
*
* @component
*/
-export const RootLayout = ({
+export const RootLayout = async ({
children,
}: Readonly<{
children: React.ReactNode;
}>) => {
+ const [refsData, biMoaData] = await Promise.all([
+ getRefsData(),
+ getBiMoaData(),
+ ]);
+
return (
-
-
+
+
{children}
@@ -110,33 +115,29 @@ const HTMLContent = ({
}>) => {
return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
);
};
diff --git a/app/student/page.tsx b/app/student/page.tsx
index bc95725e..a5009370 100644
--- a/app/student/page.tsx
+++ b/app/student/page.tsx
@@ -1,13 +1,33 @@
-"use client";
-
import { HeroSection, Feature } from "@/components/landingStudent/sections";
import Testimonials from "@/components/landingStudent/sections/3rdSection/testimonials";
import { Footer } from "@/components/shared/footer";
-import { useAuthContext } from "@/lib/ctx-auth";
+import { UserService } from "@/lib/api/services";
+import { cookies } from "next/headers";
+import { redirect } from "next/navigation";
+
+async function isLoggedIn() {
+ const cookieHeader = (await cookies()).toString();
+ if (!cookieHeader) return false;
+
+ try {
+ const response = await UserService.getMyProfile({
+ cache: "no-store",
+ headers: {
+ cookie: cookieHeader,
+ },
+ });
+
+ return !!response.user;
+ } catch {
+ return false;
+ }
+}
+
+export default async function HomePage() {
+ if (await isLoggedIn()) {
+ redirect("/search");
+ }
-export default function HomePage() {
- const { redirectIfLoggedIn } = useAuthContext();
- redirectIfLoggedIn();
return (
{/* Hero Section */}
diff --git a/app/student/profile/complete-profile/page.tsx b/app/student/profile/complete-profile/page.tsx
deleted file mode 100644
index 01eab4a7..00000000
--- a/app/student/profile/complete-profile/page.tsx
+++ /dev/null
@@ -1,799 +0,0 @@
-"use client";
-
-import React, { RefObject, useEffect, useMemo, useRef, useState } from "react";
-import {
- Upload,
- UserCheck,
- FileText,
- AlertTriangle,
- Repeat,
- User,
- ArrowLeft,
- TriangleAlert,
-} from "lucide-react";
-import { Card } from "@/components/ui/card";
-import { Badge } from "@/components/ui/badge";
-import { useAnalyzeResume } from "@/hooks/use-register";
-import ResumeUpload from "@/components/features/student/resume-parser/ResumeUpload";
-import { FormDropdown, FormInput } from "@/components/EditForm";
-import { UserService } from "@/lib/api/services";
-import { useProfileData } from "@/lib/api/student.data.api";
-import { Stepper } from "@/components/stepper/stepper";
-import {
- isProfileResume,
- isProfileBaseComplete,
-} from "../../../../lib/profile";
-import { ModalHandle } from "@/hooks/use-modal";
-import { isValidPHNumber } from "@/lib/utils";
-import { useDbRefs } from "@/lib/db/use-refs";
-import { Job } from "@/lib/db/db.types";
-import { isValidRequiredUserName } from "@/lib/utils/name-utils";
-import { DropdownGroup } from "@/components/ui/dropdown";
-import { useQueryClient } from "@tanstack/react-query";
-import { useRouter, useSearchParams } from "next/navigation";
-import { Button } from "@/components/ui/button";
-import { useModal } from "@/hooks/use-modal";
-
-/* ============================== Modal shell ============================== */
-
-export default function IncompleteProfileContent({
- onFinish,
-}: {
- onFinish: () => void;
- applySuccessModalRef?: RefObject
;
- job?: Job | null;
-}) {
- const router = useRouter();
-
- return (
-
-
-
-
-
-
-
- Let's finish setting up your profile
-
-
-
-
-
-
- );
-}
-
-/* ============================== Types ============================== */
-
-type ProfileDraft = {
- firstName?: string;
- middleName?: string;
- lastName?: string;
- phone?: string;
- university?: string;
- college?: string;
- department?: string;
- degree?: string;
-};
-
-type ResumeParsedUserSnake = {
- first_name?: string;
- middle_name?: string;
- last_name?: string;
- phone_number?: string;
- university?: string;
- degree?: string;
-};
-
-function snakeToDraft(u: ResumeParsedUserSnake): Partial {
- return {
- firstName: u.first_name,
- middleName: u.middle_name,
- lastName: u.last_name,
- phone: u.phone_number,
- // university: u.university,
- // degree: u.degree,
- };
-}
-
-/* ============================== Main Stepper ============================== */
-
-function CompleteProfileStepper({ onFinish }: { onFinish: () => void }) {
- const existingProfile = useProfileData();
- const [step, setStep] = useState(0);
- const [showComplete, setShowComplete] = useState(false);
- const queryClient = useQueryClient();
- const router = useRouter();
-
- // track where to redirect the user after completion
- const params = useSearchParams();
- const destination = params.get("dest");
-
- // profile being edited
- const [profile, setProfile] = useState(() => ({
- firstName: existingProfile.data?.first_name ?? "",
- middleName: existingProfile.data?.middle_name ?? "",
- lastName: existingProfile.data?.last_name ?? "",
- phone: existingProfile.data?.phone_number ?? "",
- university: existingProfile.data?.university ?? "",
- college: existingProfile.data?.college ?? "",
- department: existingProfile.data?.department ?? "",
- degree: existingProfile.data?.degree ?? "",
- }));
-
- const [autoApply, setAutoApply] = useState(true);
-
- // parsing/upload states
- const [file, setFile] = useState(null);
- const [isParsing, setIsParsing] = useState(false);
- const [isUpdating, setIsUpdating] = useState(false);
- const [parsedReady, setParsedReady] = useState(false);
- const [parseError, setParseError] = useState(null);
- const [uploadPromise, setUploadPromise] = useState | null>(null);
-
- const {
- open: openFileFailModal,
- close: closeFileFailModal,
- Modal: FileFailModal,
- } = useModal("file-fail-modal", { showCloseButton: false });
-
- // analyze
- const { upload, fileInputRef, response } = useAnalyzeResume();
- const handledResponseRef = useRef(null);
-
- // upload on file
- useEffect(() => {
- if (!file) return;
- setParseError(null);
- setParsedReady(false);
- setIsParsing(true);
-
- const promise = upload(file);
- setUploadPromise(promise);
- }, [file]);
-
- // hydrate once per promise
- useEffect(() => {
- if (!response || handledResponseRef.current === response) return;
- handledResponseRef.current = response;
-
- const { extractedUser, message, success } = response as {
- success?: boolean;
- message?: string;
- extractedUser?: ResumeParsedUserSnake;
- };
-
- if (extractedUser) {
- const patch = snakeToDraft(extractedUser);
- setProfile((p) => ({ ...p, ...patch }));
- }
- setParsedReady(true);
- setIsParsing(false);
-
- if (!success && message) {
- setStep(0);
- openFileFailModal();
- setParseError(message || "Failed to analyze resume.");
- setIsParsing(false);
- setParsedReady(false);
- }
- }, [response]);
-
- const steps = useMemo(() => {
- const s: Array<{
- id: "resume" | "base" | "auto-apply";
- title: string;
- subtitle?: string;
- icon: any;
- canNext: () => boolean;
- component: React.ReactNode;
- }> = [];
-
- if (!isProfileResume(existingProfile.data)) {
- s.push({
- id: "resume",
- title: "Upload your resume",
- subtitle: "Upload a PDF and we'll auto-fill what we can.",
- icon: Upload,
- canNext: () => {
- const result =
- !!file && !isParsing && parsedReady && (response?.success ?? false);
- return result;
- },
- component: (
- setFile(f)}
- fileInputRef={
- fileInputRef as unknown as RefObject
- }
- response={uploadPromise}
- />
- ),
- });
- }
-
- if (!isProfileBaseComplete(existingProfile.data)) {
- s.push({
- id: "base",
- title: "Basic details",
- icon: UserCheck,
- canNext: () => {
- const phoneValid = isValidPHNumber(profile.phone);
- return (
- !!profile.firstName &&
- isValidRequiredUserName(profile.firstName) &&
- !!profile.lastName &&
- isValidRequiredUserName(profile.lastName) &&
- !isUpdating &&
- phoneValid &&
- !!profile.degree &&
- !!profile.college &&
- !!profile.department
- );
- },
- component: ,
- });
- }
-
- if (!existingProfile.data?.acknowledged_auto_apply) {
- s.push({
- id: "auto-apply",
- title: "Auto-apply settings",
- icon: Repeat,
- canNext: () => autoApply !== null && !isUpdating,
- component: ,
- });
- }
-
- return s;
- }, [
- isParsing,
- parsedReady,
- parseError,
- response,
- file,
- uploadPromise,
- profile,
- isUpdating,
- autoApply,
- ]);
-
- useEffect(() => {
- if (steps.length === 0) {
- setShowComplete(true);
- }
- }, [steps.length]);
-
- // Next behavior
- const onNext = async () => {
- const current = steps[step];
- if (!current) return;
-
- if (current.id === "resume") {
- if (!response?.success) {
- return;
- }
- setStep(step + 1);
- return;
- }
-
- if (current.id === "base") {
- setIsUpdating(true);
- const fullName = [
- profile?.firstName ?? "",
- profile?.middleName ?? "",
- profile?.lastName ?? "",
- ]
- .filter(Boolean)
- .join(" ");
- await UserService.updateMyProfile({
- first_name: profile.firstName ?? "",
- middle_name: profile.middleName ?? "",
- last_name: profile.lastName ?? "",
- phone_number: profile.phone ?? "",
- university: profile.university ?? "",
- degree: profile.degree ?? "",
- college: profile.college ?? "",
- department: profile.department ?? "",
- internship_moa_fields: {
- student: {
- "student-degree": profile.degree ?? "",
- "student-college": profile.college ?? "",
- "student-full-name": fullName,
- "student-first-name": profile.firstName ?? "",
- "student-middle-name": profile.middleName ?? "",
- "student-last-name": profile.lastName ?? "",
- "student-department": profile.department ?? "",
- "student-university": profile.university ?? "",
- "student-phone-number": profile.phone ?? "",
- },
- },
- })
- .then(() => {
- setIsUpdating(false);
- const isLast = step + 1 >= steps.length;
- if (isLast) setShowComplete(true);
- else setStep(step + 1);
- })
- .catch((e) => console.log(e));
- return;
- }
-
- if (current.id === "auto-apply") {
- if (autoApply === null) return;
-
- setIsUpdating(true);
- await UserService.updateMyProfile({
- acknowledged_auto_apply: true,
- apply_for_me: autoApply,
- }).then(() => {
- setIsUpdating(false);
- const isLast = step + 1 >= steps.length;
- if (isLast) {
- setShowComplete(true);
- void queryClient.invalidateQueries({
- queryKey: ["my-profile"],
- });
- setTimeout(() => {
- router.push("/profile");
- }, 1500);
- } else setStep(step + 1);
- });
- return;
- }
- };
-
- if (showComplete) {
- return ;
- }
-
- return (
- <>
-
- void onNext()}
- onBack={() => setStep(Math.max(0, step - 1))}
- />
-
-
-
-
-
-
-
-
Could not upload file
-
- Please try again with a different file
-
-
-
-
- Try Again
-
-
-
- >
- );
-}
-
-/* ---------------------- Step: Resume Upload ---------------------- */
-
-function StepResume({
- file,
- isParsing,
- parsedReady,
- parseError,
- onPick,
- fileInputRef,
- response,
-}: {
- file: File | null;
- isParsing: boolean;
- parsedReady: boolean;
- parseError: string | null;
- onPick: (file: File) => void;
- fileInputRef: React.RefObject;
- response: Promise | null;
-}) {
- return (
-
-
onPick(f)}
- onComplete={() => {}}
- />
-
- {file && (
-
-
-
-
-
{file.name}
-
- {(file.size / 1024 / 1024).toFixed(2)} MB
-
-
-
- {parsedReady ? (
-
Parsed
- ) : isParsing ? (
-
Parsing...
- ) : (
-
Waiting...
- )}
-
- )}
-
- {parseError && (
-
- )}
-
- );
-}
-
-/* ---------------------- Step: Basic Identity ---------------------- */
-
-function StepBasicIdentity({
- value,
- onChange,
-}: {
- value: ProfileDraft;
- onChange: (v: ProfileDraft) => void;
-}) {
- const {
- colleges,
- departments,
- get_departments_by_college,
- to_department_name,
- get_colleges_by_university,
- to_college_name,
- } = useDbRefs();
-
- const phoneInvalid = useMemo(
- () => !!value.phone && !isValidPHNumber(value.phone),
- [value.phone],
- );
-
- const firstNameInvalid = useMemo(
- () => !!value.firstName && !isValidRequiredUserName(value.firstName),
- [value.firstName],
- );
-
- const lastNameInvalid = useMemo(
- () => !!value.lastName && !isValidRequiredUserName(value.lastName),
- [value.lastName],
- );
-
- const [departmentOptions, setDepartmentOptions] =
- useState<{ id: string; name: string }[]>(departments);
-
- const [collegesOptions, setCollegesOptions] =
- useState<
- { id: string; name: string; short_name: string; university_id: string }[]
- >(colleges);
-
- // for realtime updating the department based on the college
- useEffect(() => {
- const collegeId = value.college;
-
- if (collegeId) {
- const list = get_departments_by_college?.(collegeId);
- const mapped = list.map((d) => ({
- id: d,
- name: to_department_name(d) ?? "",
- }));
- setDepartmentOptions(mapped);
-
- if (value.department && !mapped.some((d) => d.id === value.department)) {
- onChange({ ...value, department: undefined });
- }
- } else {
- // no college selected -> empty department options
- setDepartmentOptions(
- departments.map((d) => ({ id: d.id, name: d.name })),
- );
- if (value.department) {
- onChange({ ...value, department: undefined });
- }
- }
- }, [
- value.college,
- value.department,
- departments,
- get_departments_by_college,
- to_department_name,
- onChange,
- ]);
-
- // for realtime updating the college based on the university
- useEffect(() => {
- const universityId = value.university;
-
- if (universityId) {
- const list = get_colleges_by_university?.(universityId) ?? [];
- const mapped = list.map((d) => ({
- id: d,
- name: to_college_name(d) ?? "",
- short_name: "",
- university_id: universityId,
- }));
- setCollegesOptions(mapped);
-
- // If the currently selected college is not in the new mapped list, clear it (and department)
- if (value.college && !mapped.some((c) => c.id === value.college)) {
- onChange({ ...value, college: undefined, department: undefined });
- }
- } else {
- // no university selected -> show all colleges without forcing a reset
- setCollegesOptions(
- colleges.map((d) => ({
- id: d.id,
- name: d.name,
- short_name: d.short_name,
- university_id: d.university_id,
- })),
- );
- }
- }, [
- value.university,
- value.college,
- value.department,
- colleges,
- get_colleges_by_university,
- to_college_name,
- onChange,
- ]);
-
- return (
-
- {/* Personal */}
-
-
- Personal information
-
-
-
- onChange({ ...value, firstName: v })}
- />
-
- onChange({ ...value, middleName: v })}
- />
- onChange({ ...value, lastName: v })}
- />
-
-
- {firstNameInvalid && (
-
- Please enter a first name. Special characters are not allowed.
-
- )}
- {lastNameInvalid && (
-
- Please enter a last name. Special characters are not allowed.
-
- )}
-
-
onChange({ ...value, phone: v })}
- />
- {phoneInvalid && (
-
- Please enter a valid mobile number (e.g., 639XXXXXXXXX).
-
- )}
-
-
-
- {/* Education */}
-
-
- Educational background
-
-
-
- onChange({ ...value, college: val.toString() })}
- options={collegesOptions}
- placeholder="Select college…"
- />
-
-
-
-
- onChange({ ...value, department: val.toString() })
- }
- options={departmentOptions}
- placeholder="Select department…"
- disabled={!value.college}
- />
-
-
- onChange({ ...value, degree: val.toString() })}
- placeholder="Select degree / program…"
- disabled={!value.department}
- />
-
-
-
-
- );
-}
-
-/* ---------------------- Step: Auto-Apply Acknowledge ---------------------- */
-
-function StepAutoApply({
- value,
- onChange,
-}: {
- value: boolean | null;
- onChange: (v: boolean) => void;
-}) {
- return (
-
-
-
-
-
Turn on Auto-Apply?
-
- Recommended
-
-
-
-
- We’ll auto-submit matching roles using your saved details.{" "}
-
-
- {/* Native select to avoid extra deps; replace with shadcn Select if you prefer */}
-
-
Auto-Apply preference
-
onChange(e.target.value === "yes")}
- >
- Yes, enable
- No, not now
-
-
- You can change this anytime in your profile.
-
-
-
-
-
- );
-}
-
-/* ---------------------- Completion (rendered OUTSIDE stepper) ---------------------- */
-
-function StepComplete({
- onDone,
- destination,
-}: {
- onDone: () => void;
- destination: string | null;
-}) {
- const router = useRouter();
- const queryClient = useQueryClient();
-
- useEffect(() => {
- const t = setTimeout(() => {
- void (async () => {
- if (destination) {
- // invalidate profile cache before redirecting.
- await queryClient.invalidateQueries({ queryKey: ["my-profile"] });
- router.push(`/${destination}`);
- } else {
- onDone();
- }
- })();
- }, 1400);
- return () => clearTimeout(t);
- }, [onDone, destination, router, queryClient]);
-
- return (
-
- {/* Check animation */}
-
-
-
Profile complete
-
- You’re all set. Nice work!
-
-
- Please wait while we redirect you...
-
-
-
-
- );
-}
-
-/* ============================== Exports ============================== */
-export { CompleteProfileStepper };
diff --git a/app/student/profile/page.tsx b/app/student/profile/page.tsx
index 5b6811f1..36797268 100644
--- a/app/student/profile/page.tsx
+++ b/app/student/profile/page.tsx
@@ -3,6 +3,7 @@ import {
useState,
useEffect,
useRef,
+ useMemo,
forwardRef,
useImperativeHandle,
} from "react";
@@ -15,45 +16,42 @@ import {
CheckCircle2,
Globe2,
Loader2,
- FileQuestion,
+ Trash2,
+ Pencil,
+ Check,
+ X,
} from "lucide-react";
import { useProfileData } from "@/lib/api/student.data.api";
import { useAuthContext } from "../../../lib/ctx-auth";
import { useModal } from "@/hooks/use-modal";
import { useDbRefs } from "@/lib/db/use-refs";
-import { InternshipPreferences, PublicUser } from "@/lib/db/db.types";
+import { InternshipPreferences, PublicUser, Resume } from "@/lib/db/db.types";
import { ErrorLabel, LabeledProperty } from "@/components/ui/labels";
import { UserService } from "@/lib/api/services";
-import { ApplicantModalContent } from "@/components/shared/applicant-modal";
import { Button } from "@/components/ui/button";
import { FileUploadInput, useFile, useFileUpload } from "@/hooks/use-file";
import { Card } from "@/components/ui/card";
-import {
- getFullName,
- isProfileBaseComplete,
- isProfileResume,
-} from "@/lib/profile";
-import { toURL, openURL } from "@/lib/utils/url-utils";
+import { getFullName } from "@/lib/profile";
+import { toURL } from "@/lib/utils/url-utils";
import {
isValidOptionalGitHubURL,
isValidOptionalLinkedinURL,
isValidOptionalURL,
} from "@/lib/utils/url-utils";
import { Loader } from "@/components/ui/loader";
-import { BoolBadge } from "@/components/ui/badge";
import { cn, formatMonth, isValidPHNumber, toSafeString } from "@/lib/utils";
+import { formatOptionalStartMonth } from "@/lib/utils/date-utils";
import { MyUserPfp, PFP_UPDATED_EVENT } from "@/components/shared/pfp";
import { useAppContext } from "@/lib/ctx-app";
import {
createEditForm,
FormMonthPicker,
FormInput,
- FormDropdown,
} from "@/components/EditForm";
import { Divider } from "@/components/ui/divider";
import { isValidRequiredUserName } from "@/lib/utils/name-utils";
-import { useQueryClient } from "@tanstack/react-query";
-import { useSearchParams, useRouter } from "next/navigation";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { useSearchParams } from "next/navigation";
import { Autocomplete, AutocompleteMulti } from "@/components/ui/autocomplete";
import { AutocompleteTreeMulti } from "@/components/ui/autocomplete";
import { POSITION_TREE } from "@/lib/consts/positions";
@@ -63,14 +61,31 @@ import {
type Option as ChipOpt,
} from "@/components/ui/chip-select";
import { Badge } from "@/components/ui/badge";
-import { AutoApplyCard } from "@/components/features/student/profile/AutoApplyCard";
import { useProfileActions } from "@/lib/api/student.actions.api";
-import useModalRegistry from "@/components/modals/modal-registry";
import { toast } from "sonner";
import { toastPresets } from "@/components/ui/sonner-toast";
import { useBlockPageRefreshEffect } from "@/hooks/use-refresh-block";
+import { PDFPreview } from "@/components/shared/pdf-preview";
+import { AddResumeModal } from "@/components/features/student/profile/AddResumeModal";
+import useModalRegistry from "@/components/modals/modal-registry";
+import { sortUniversityOptions } from "../../../lib/student-forms-access";
+import { DEGREES } from "../register/steps/tempDegrees";
const [ProfileEditForm, useProfileEditForm] = createEditForm();
+type ProfileTabKey = "Student Profile" | "Internship Details" | "Resumes";
+type EditableProfileTabKey = Exclude;
+
+const SECTION_TO_PROFILE_TAB: Record = {
+ student: "Student Profile",
+ internship: "Internship Details",
+ resumes: "Resumes",
+};
+
+const PROFILE_TAB_TO_SECTION: Record = {
+ "Student Profile": "student",
+ "Internship Details": "internship",
+ Resumes: "resumes",
+};
export default function ProfilePage() {
const { redirectIfNotLoggedIn } = useAuthContext();
@@ -78,49 +93,123 @@ export default function ProfilePage() {
const profileActions = useProfileActions();
const [isEditing, setIsEditing] = useState(false);
const [saving, setSaving] = useState(false);
+ const [isDeletingResume, setIsDeletingResume] = useState(false);
+ const [isRenamingResume, setIsRenamingResume] = useState(false);
const [saveError, setSaveError] = useState(null);
- const [autoApplySaving, setAutoApplySaving] = useState(false);
- const [autoApplyError, setAutoApplyError] = useState(null);
const { url: resumeURL, sync: syncResumeURL } = useFile({
- fetcher: UserService.getMyResumeURL,
- route: "/users/me/resume",
+ fetcher: (resumeId: string) => UserService.getMyResumeURL(resumeId),
+ route: (resumeId: string) => `/users/me/resume/${resumeId}`,
});
+ const resumes = useQuery({
+ queryKey: ["my-resumes"],
+ queryFn: () => UserService.getMyResumes(),
+ });
+
+ const maxResumesEnv = Number(process.env.NEXT_PUBLIC_MAX_RESUMES_ALLOWED);
+ const maxResumesAllowed = Number.isFinite(maxResumesEnv) ? maxResumesEnv : 5;
+ const resumeCount = resumes.data?.resumes?.length ?? 0;
+ const atResumeLimit = resumeCount >= maxResumesAllowed;
// Modals
+ const { open: openResumeModal, Modal: ResumeModal } =
+ useModal("resume-modal");
const {
- open: openEmployerModal,
- close: closeEmployerModal,
- Modal: EmployerModal,
- } = useModal("employer-modal");
+ open: openAddResumeModal,
+ close: closeAddResumeModal,
+ Modal: AddResumeModalBox,
+ } = useModal("add-resume-modal");
- const { open: openResumeModal } = useModal("resume-modal");
const profileEditorRef = useRef<{ save: () => Promise }>(null);
const queryClient = useQueryClient();
const searchParams = useSearchParams();
-
- const openEmployerWithResume = async () => {
- await syncResumeURL();
- openEmployerModal();
+ const modalRegistry = useModalRegistry();
+ const sectionParam = searchParams.get("section") ?? "";
+ const initialTab = SECTION_TO_PROFILE_TAB[sectionParam] ?? "Student Profile";
+ const [profileTab, setProfileTab] = useState(initialTab);
+
+ const openResumePreview = async (resumeId: string) => {
+ await syncResumeURL(resumeId);
+ openResumeModal();
};
- const handleAutoApplySave = async (newEnabled: boolean) => {
- setAutoApplySaving(true);
- setAutoApplyError(null);
+ const onRenameResume = async (resumeId: string, label: string) => {
+ if (!resumeId) {
+ toast.error("Resume not found.");
+ return false;
+ }
+
+ const nextLabel = label.trim();
+ if (!nextLabel) {
+ toast.error("Resume label is required.");
+ return false;
+ }
+ setIsRenamingResume(true);
try {
- await UserService.updateMyProfile({
- apply_for_me: newEnabled,
- auto_apply_enabled_at: newEnabled ? new Date().toISOString() : null,
+ const result = await UserService.updateMyResume(resumeId, nextLabel);
+ if (!result?.success) {
+ const errorMessage =
+ (result as { error?: string })?.error ||
+ result?.message ||
+ "Failed to rename resume.";
+ toast.error(errorMessage, toastPresets.destructive);
+ return false;
+ }
+
+ toast.success("Resume renamed successfully.", toastPresets.success);
+ void resumes.refetch();
+ void queryClient.invalidateQueries({
+ queryKey: ["my-profile"],
});
- void queryClient.invalidateQueries({ queryKey: ["my-profile"] });
- } catch (e: any) {
- setAutoApplyError((e as string) ?? "Failed to update auto-apply");
+ return true;
+ } catch (error) {
+ toast.error("Resume could not be renamed. Try again.");
+ return false;
} finally {
- setAutoApplySaving(false);
+ setIsRenamingResume(false);
}
};
+ const onDeleteResume = (resume: Resume) => {
+ if (!resume.id) {
+ toast.error("Resume not found.");
+ return;
+ }
+
+ modalRegistry.deleteResume.open({
+ resume,
+ isProcessing: isDeletingResume,
+ onConfirm: () => {
+ void (async () => {
+ setIsDeletingResume(true);
+ try {
+ const result = await UserService.deleteMyResume(resume.id);
+ if (!result?.success) {
+ const errorMessage =
+ (result as { error?: string })?.error ||
+ result?.message ||
+ "Failed to delete resume.";
+ toast.error(errorMessage, toastPresets.destructive);
+ return;
+ }
+
+ modalRegistry.deleteResume.close();
+ toast.success("Resume deleted successfully.", toastPresets.success);
+ void resumes.refetch();
+ void queryClient.invalidateQueries({
+ queryKey: ["my-profile"],
+ });
+ } catch (error) {
+ toast.error("Resume could not be deleted. Try again.");
+ } finally {
+ setIsDeletingResume(false);
+ }
+ })();
+ },
+ });
+ };
+
redirectIfNotLoggedIn();
const {
@@ -128,21 +217,40 @@ export default function ProfilePage() {
upload: pfpUpload,
isUploading: pfpIsUploading,
} = useFileUpload({
- uploader: UserService.updateMyPfp,
+ uploader: (file: FormData) => UserService.updateMyPfp(file),
filename: "pfp",
silent: true,
});
const data = profile.data as PublicUser | undefined;
- const { score, parts, tips } = computeProfileScore(data);
useEffect(() => {
+ setProfileTab(SECTION_TO_PROFILE_TAB[sectionParam] ?? "Student Profile");
if (searchParams.get("edit") === "true") setIsEditing(true);
- }, [searchParams]);
+ }, [searchParams, sectionParam]);
- useEffect(() => {
- if (data?.resume) void syncResumeURL();
- }, [data?.resume, syncResumeURL]);
+ const handleProfileTabChange = (nextTab: ProfileTabKey) => {
+ setProfileTab(nextTab);
+
+ const section = PROFILE_TAB_TO_SECTION[nextTab];
+ const url = new URL(window.location.href);
+ if (section === "student") {
+ url.searchParams.delete("section");
+ } else {
+ url.searchParams.set("section", section);
+ }
+ window.history.replaceState({}, "", url.toString());
+ };
+
+ const handleCancelEditing = () => {
+ setSaveError(null);
+ setSaving(false);
+ setIsEditing(false);
+ };
+
+ const handleEditorTabChange = (nextTab: EditableProfileTabKey) => {
+ handleProfileTabChange(nextTab);
+ };
useBlockPageRefreshEffect(isEditing);
@@ -165,25 +273,13 @@ export default function ProfilePage() {
);
}
- if (!isProfileResume(profile.data) || !isProfileBaseComplete(profile.data)) {
- return (
-
-
-
- Page Paused, Please Reload
-
-
- );
- }
-
return (
data && (
-
+
{/* Top header */}
-
+
- {/* PFP */}
@@ -200,18 +296,19 @@ export default function ProfilePage() {
ref={pfpFileInputRef}
allowedTypes={["image/jpeg", "image/png", "image/webp"]}
maxSize={1}
- onSelect={async (file) => {
- const success = await pfpUpload(file);
- if (success) {
- void queryClient.invalidateQueries({
- queryKey: ["my-profile"],
- });
- window.dispatchEvent(new Event(PFP_UPDATED_EVENT));
- toast.success(
- "Profile photo uploaded successfully.",
- toastPresets.success,
- );
- }
+ onSelect={(file) => {
+ void pfpUpload(file).then((success) => {
+ if (success) {
+ void queryClient.invalidateQueries({
+ queryKey: ["my-profile"],
+ });
+ window.dispatchEvent(new Event(PFP_UPDATED_EVENT));
+ toast.success(
+ "Profile photo uploaded successfully.",
+ toastPresets.success,
+ );
+ }
+ });
}}
/>
@@ -242,25 +339,29 @@ export default function ProfilePage() {
{/* Main content */}
-
+
{/* Left column */}
- {/* Resume */}
-
- Resume/CV
-
-
-
-
{/* Profile */}
{!isEditing && (
<>
{
+ void resumes.refetch();
+ void queryClient.invalidateQueries({
+ queryKey: ["my-profile"],
+ });
+ }}
onEdit={() => setIsEditing(true)}
+ onAddResume={openAddResumeModal}
+ onRenameResume={onRenameResume}
+ onDeleteResume={onDeleteResume}
/>
>
)}
@@ -269,120 +370,78 @@ export default function ProfilePage() {
- void (async () => {
- setSaving(true);
- setSaveError(null);
- const success =
- await profileEditorRef.current?.save();
- setSaving(false);
- if (success) {
- setIsEditing(false);
- toast.success(
- "Profile saved successfully.",
- toastPresets.success,
- );
- } else
- setSaveError(
- "Please fix the errors in the form before saving.", // TODO: Make this a toast
- );
- })()
- }
- disabled={saving}
- >
-
- {saving ? (
- <>
-
- >
- ) : (
- <>Save>
- )}
-
+
+
+
+ Cancel
+
+
+ void (async () => {
+ setSaving(true);
+ setSaveError(null);
+ const success =
+ await profileEditorRef.current?.save();
+ setSaving(false);
+ if (success) {
+ setIsEditing(false);
+ toast.success(
+ "Profile saved successfully.",
+ toastPresets.success,
+ );
+ } else
+ setSaveError(
+ "Please fix the errors in the form before saving.", // TODO: Make this a toast
+ );
+ })()
+ }
+ disabled={saving}
+ >
+
+ {saving ? (
+ <>
+
+ >
+ ) : (
+ <>Save>
+ )}
+
+
}
/>
)}
-
- {/* Right column */}
-
-
-
- {/* Completion meter */}
-
-
- Profile completeness
- {score}%
-
-
-
-
-
- {Object.entries(parts).map(([k, ok]) => (
-
- {" "}
- {k}
-
- ))}
-
- {/* NEW: quick tips, only show top 2 so it stays compact */}
- {tips.length > 0 && (
-
- {tips.slice(0, 2).map((t) => (
- {t}
- ))}
-
- )}
-
-
-
- UserService.getUserPfpURL("me")}
- pfp_route="/users/me/pic"
- open_resume={() =>
- void (async () => {
- closeEmployerModal();
- await syncResumeURL();
- openResumeModal();
- })()
- }
- open_calendar={() => {
- openURL(data?.calendar_link);
+
+
+
+
+
+ {
+ closeAddResumeModal();
+ void resumes.refetch();
+ void queryClient.invalidateQueries({
+ queryKey: ["my-profile"],
+ });
}}
- resume_url={resumeURL}
+ isAtResumeLimit={atResumeLimit}
/>
-
+
)
);
@@ -440,40 +499,54 @@ function HeaderLine({ profile }: { profile: PublicUser }) {
function ProfileReadOnlyTabs({
profile,
+ tab,
+ onTabChange,
+ resumes,
+ resumesLoading,
+ onViewResume,
+ onRenameResume,
+ onDeleteResume,
onEdit,
+ onAddResume,
+ onResumeUploaded,
}: {
profile: PublicUser;
+ tab: ProfileTabKey;
+ onTabChange: (tab: ProfileTabKey) => void;
+ resumes: Resume[];
+ resumesLoading: boolean;
+ onViewResume: (resumeId: string) => void | Promise
;
+ onRenameResume: (resumeId: string, label: string) => Promise;
+ onDeleteResume: (resume: Resume) => void | Promise;
onEdit: () => void;
+ onAddResume: () => void;
+ onResumeUploaded: () => void;
}) {
const internshipPreferences = profile.internship_preferences;
- const {
- to_university_name,
- to_college_name,
- to_department_name,
- job_modes,
- job_types,
- job_categories,
- } = useDbRefs();
+ const { to_university_name, job_modes, job_types, job_categories } =
+ useDbRefs();
- type TabKey = "Student Profile" | "Internship Details";
- const [tab, setTab] = useState("Student Profile");
+ const handleTabChange = (v: string) => {
+ onTabChange(v as ProfileTabKey);
+ };
const tabs = [
{ key: "Student Profile", label: "Student Profile" },
{ key: "Internship Details", label: "Internship Details" },
+ { key: "Resumes", label: "Resumes" },
] as const;
return (
setTab(v as TabKey)}
+ onChange={handleTabChange}
rightSlot={
-
+ tab !== "Resumes" && (
Edit
-
+ )
}
>
{/* Student Profile */}
@@ -521,18 +594,6 @@ function ProfileReadOnlyTabs({
label="Degree / Program"
value={profile.degree ?? "-"}
/>
-
-
{internshipPreferences?.internship_type === "credited" && (
+
+
+
+
);
}
@@ -712,11 +783,12 @@ function ProfileReadOnlyTabs({
const ProfileEditor = forwardRef<
{ save: () => Promise },
{
- updateProfile: (updatedProfile: Partial) => void;
+ updateProfile: (updatedProfile: Partial) => Promise;
+ initialTab: EditableProfileTabKey;
+ onTabChange?: (tab: EditableProfileTabKey) => void;
rightSlot?: React.ReactNode;
}
->(({ updateProfile, rightSlot }, ref) => {
- const qc = useQueryClient();
+>(({ updateProfile, initialTab, onTabChange, rightSlot }, ref) => {
const {
formData,
formErrors,
@@ -727,42 +799,58 @@ const ProfileEditor = forwardRef<
cleanFormData,
} = useProfileEditForm();
const { isMobile } = useAppContext();
- const {
- universities,
- colleges,
- departments,
- job_modes,
- job_types,
- job_categories,
- getUniversityFromDomain: get_universities_from_domain,
- get_colleges_by_university,
- get_departments_by_college,
- to_university_name,
- to_college_name,
- to_department_name,
- } = useDbRefs();
-
- type TabKey = "Student Profile" | "Internship Details" | "Calendar";
- const [tab, setTab] = useState("Student Profile");
+ const { universities, job_modes, job_types, job_categories } = useDbRefs();
+
+ type TabKey = EditableProfileTabKey | "Calendar";
+ const [tab, setTab] = useState(initialTab);
+ const selectTab = (nextTab: TabKey) => {
+ setTab(nextTab);
+ if (nextTab !== "Calendar") {
+ onTabChange?.(nextTab);
+ }
+ };
const hasProfileErrors = !!(
formErrors.first_name ||
formErrors.last_name ||
formErrors.phone_number ||
- formErrors.university ||
- formErrors.degree
+ formErrors.university
);
const hasPrefsErrors = !!formErrors.internship_preferences;
const hasCalendarErrors = !!formErrors.calendar_link;
+ const fieldErrorClassName = "mt-1 mb-0 mx-0";
+ const internshipPreferencesError = formErrors.internship_preferences ?? "";
+ const internshipStartError = internshipPreferencesError.includes(
+ "expected start month",
+ )
+ ? internshipPreferencesError
+ : null;
+ const internshipDurationError = internshipPreferencesError.includes(
+ "number of hours",
+ )
+ ? internshipPreferencesError
+ : null;
+ const internshipSetupError = internshipPreferencesError.includes("work setup")
+ ? internshipPreferencesError
+ : null;
+ const internshipCommitmentError = internshipPreferencesError.includes(
+ "work commitment",
+ )
+ ? internshipPreferencesError
+ : null;
+ const internshipCategoryError = internshipPreferencesError.includes(
+ "work category",
+ )
+ ? internshipPreferencesError
+ : null;
useImperativeHandle(ref, () => ({
save: async () => {
- validateFormData();
- const hasErrors = Object.values(formErrors).some(Boolean);
- if (hasErrors) {
- if (hasCalendarErrors) setTab("Calendar");
- else if (hasPrefsErrors) setTab("Internship Details");
- else setTab("Student Profile");
+ const isValid = validateFormData();
+ if (!isValid) {
+ if (hasCalendarErrors) selectTab("Calendar");
+ else if (hasPrefsErrors) selectTab("Internship Details");
+ else selectTab("Student Profile");
return false;
}
@@ -784,14 +872,12 @@ const ProfileEditor = forwardRef<
formData.internship_preferences?.job_category_ids ?? [],
},
};
- updateProfile(updatedProfile);
+ await updateProfile(updatedProfile);
return true;
},
}));
const [universityOptions, setUniversityOptions] = useState(universities);
- const [collegesOptions, setCollegesOptions] = useState(colleges);
- const [departmentOptions, setDepartmentOptions] = useState(departments);
const [jobModeOptions, setJobModeOptions] = useState(job_modes);
const [jobTypeOptions, setJobTypeOptions] = useState(job_types);
const [jobCategoryOptions, setJobCategoryOptions] = useState(job_categories);
@@ -801,25 +887,14 @@ const ProfileEditor = forwardRef<
];
useEffect(() => {
- setUniversityOptions(universities?.filter((u) => u.name !== "ADMU"));
- setDepartmentOptions(departments);
+ setUniversityOptions(universities);
setJobModeOptions((job_modes ?? []).slice());
setJobTypeOptions((job_types ?? []).slice());
setJobCategoryOptions((job_categories ?? []).slice());
const t = setTimeout(() => validateFormData(), 400);
return () => clearTimeout(t);
- }, [
- formData,
- universities,
- colleges,
- departments,
- job_modes,
- job_types,
- job_categories,
- get_universities_from_domain,
- get_departments_by_college,
- ]);
+ }, [formData, universities, job_modes, job_types, job_categories]);
useEffect(() => {
addValidator(
@@ -832,11 +907,10 @@ const ProfileEditor = forwardRef<
(name: string) =>
!isValidRequiredUserName(name) && `Last name is not valid.`,
);
- addValidator(
- "phone_number",
- (number: string) =>
- !isValidPHNumber(number) && "Invalid Philippine number.",
- );
+ addValidator("phone_number", (number: string) => {
+ if (!number?.trim()) return false;
+ return !isValidPHNumber(number) && "Invalid Philippine number.";
+ });
addValidator(
"portfolio_link",
(link: string) => !isValidOptionalURL(link) && "Invalid portfolio link.",
@@ -857,26 +931,16 @@ const ProfileEditor = forwardRef<
!universityOptions.some((u) => u.id === id) &&
"Select a valid university.",
);
- addValidator(
- "college",
- (id: string) =>
- !colleges.some((c) => c.id === id) && "Select a valid college.",
- );
- addValidator(
- "department",
- (id: string) =>
- !departmentOptions.some((d) => d.id === id) &&
- "Select a valid department.",
- );
addValidator("internship_preferences", (i: InternshipPreferences) => {
// Specify start month
if (!i.expected_start_date)
return "Please select an expected start month.";
- // If credited, check if number of hours are valid
- if (i?.internship_type === "credited") {
- if (!i.expected_duration_hours)
- return "Please enter expected duration.";
+ // If credited and duration is provided, check if number of hours is valid
+ if (
+ i?.internship_type === "credited" &&
+ i.expected_duration_hours != null
+ ) {
if (
!Number.isFinite(i.expected_duration_hours) ||
i.expected_duration_hours < 100 ||
@@ -906,9 +970,9 @@ const ProfileEditor = forwardRef<
return "Invalid work category selected.";
}
- return "";
+ return false;
});
- }, [universityOptions, jobModeOptions, jobTypeOptions]);
+ }, [universityOptions, jobModeOptions, jobTypeOptions, jobCategoryOptions]);
const [showCalendarHelp, setShowCalendarHelp] = useState(false);
const helpBtnRef = useRef(null);
@@ -946,110 +1010,6 @@ const ProfileEditor = forwardRef<
}
}, []);
- // realtime updating the department based on the college (and university fallback)
- useEffect(() => {
- const collegeId = formData.college;
- const universityId = formData.university;
-
- // If a specific college is selected -> show departments only for that college
- if (collegeId) {
- const list = get_departments_by_college?.(collegeId) ?? [];
- const mapped = list.map((d) => ({ id: d, name: to_department_name(d) }));
- setDepartmentOptions(mapped);
-
- // if selected department is not in new list -> clear it
- if (
- formData.department &&
- !mapped.some((m) => m.id === formData.department)
- ) {
- setField("department", undefined);
- }
-
- return;
- }
-
- // No college selected but a university is selected -> aggregate departments for all colleges of that university
- if (universityId) {
- const collegeIds = get_colleges_by_university?.(universityId) ?? [];
-
- // collect departments from each college, dedupe
- const deptSet = new Map();
- for (const cId of collegeIds) {
- const list = get_departments_by_college?.(cId) ?? [];
- for (const d of list) {
- if (!deptSet.has(d)) {
- deptSet.set(d, { id: d, name: to_department_name(d) });
- }
- }
- }
- const aggregated = Array.from(deptSet.values());
- setDepartmentOptions(aggregated);
-
- // If current selected department isn't part of aggregated -> clear it
- if (
- formData.department &&
- !aggregated.some((a) => a.id === formData.department)
- ) {
- setField("department", undefined);
- }
- return;
- }
-
- // Neither college nor university -> show all departments
- setDepartmentOptions(departments.map((d) => ({ id: d.id, name: d.name })));
- if (formData.department) setField("department", undefined);
- }, [
- formData.college,
- formData.department,
- formData.university,
- departments,
- get_departments_by_college,
- get_colleges_by_university,
- to_department_name,
- setField,
- ]);
-
- // for realtime updating the department based on the university
- useEffect(() => {
- const universityId = formData.university;
- console.log(
- "Selected university:",
- universityId,
- to_university_name(universityId),
- );
-
- if (universityId) {
- const list = get_colleges_by_university?.(universityId) ?? [];
- const mapped = list.map((d) => ({
- id: d,
- name: to_college_name(d) ?? "",
- short_name: "",
- university_id: universityId,
- }));
- setCollegesOptions(mapped);
-
- // If the currently selected college is not in the new mapped list, clear it (and department)
- if (formData.college && !mapped.some((c) => c.id === formData.college)) {
- setField("college", undefined);
- setField("department", undefined);
- }
- } else {
- // no university selected -> show all colleges and clear college/department
- setCollegesOptions(
- colleges.map((d) => ({
- id: d.id,
- name: d.name,
- short_name: d.short_name,
- university_id: d.university_id,
- })),
- );
-
- // Clear selected college and department because no university is chosen
- if (formData.college) setField("college", undefined);
- if (formData.department) setField("department", undefined);
- }
- }, [formData.university, formData.college, colleges]);
-
return (
setTab(v as TabKey)}
+ onChange={(v) => selectTab(v as TabKey)}
>
{/* Student Profile */}
@@ -1077,42 +1037,61 @@ const ProfileEditor = forwardRef<
Identity
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
@@ -1124,54 +1103,34 @@ const ProfileEditor = forwardRef<
-
-
- ({
- id: c.id,
- name: c.name,
- }))}
- placeholder="Indicate college"
- />
-
-
-
-
-
-
+ setField("degree", val === null ? "" : String(val))
+ }
placeholder="Indicate degree"
+ required={false}
+ allowCustomValue={true}
+ emptyText="type your own degree..."
/>
-
+
@@ -1200,30 +1163,43 @@ const ProfileEditor = forwardRef<
External Profiles
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
@@ -1284,13 +1260,15 @@ const ProfileEditor = forwardRef<
expected_start_date: ms ?? null, // FormMonthPicker gives ms
});
}}
- fromYear={2025}
- toYear={2030}
+ fromYear={new Date().getFullYear()}
+ toYear={new Date().getFullYear() + 4}
required={false}
placeholder="Select month"
/>
-
-
+
{/* TODO: CHECK LEGACY CODE THEN INTERNSHIP PREF */}
{formData.internship_preferences?.internship_type === "credited" && (
@@ -1304,16 +1282,15 @@ const ProfileEditor = forwardRef<
setter={(v: string) =>
setField("internship_preferences", {
...(formData.internship_preferences ?? {}),
- expected_duration_hours:
- v === ""
- ? null
- : Number.isFinite(Number(v))
- ? Number(v)
- : null,
+ expected_duration_hours: v.trim() === "" ? null : Number(v),
})
}
required={false}
/>
+
)}
@@ -1341,6 +1318,10 @@ const ProfileEditor = forwardRef<
}
placeholder="Select one or more"
/>
+
@@ -1358,6 +1339,10 @@ const ProfileEditor = forwardRef<
}
placeholder="Select one or more"
/>
+
@@ -1373,6 +1358,10 @@ const ProfileEditor = forwardRef<
}
placeholder="Select one or more"
/>
+
@@ -1381,95 +1370,238 @@ const ProfileEditor = forwardRef<
});
ProfileEditor.displayName = "ProfileEditor";
-const ResumeBox = ({
- profile,
- openResumeModal,
+const ResumeList = ({
+ resumes,
+ loading,
+ onViewResume,
+ onRenameResume,
+ isRenamingResume,
+ onDeleteResume,
+ onAddResume,
+ onResumeUploaded,
}: {
- profile: PublicUser;
- openResumeModal: () => void;
+ resumes: Resume[];
+ loading: boolean;
+ onViewResume: (resumeId: string) => void | Promise;
+ onRenameResume: (resumeId: string, label: string) => Promise;
+ isRenamingResume: boolean;
+ onDeleteResume: (resume: Resume) => void | Promise;
+ onAddResume: () => void;
+ onResumeUploaded: () => void;
}) => {
- const queryClient = useQueryClient();
+ const maxResumesEnv = Number(process.env.NEXT_PUBLIC_MAX_RESUMES_ALLOWED);
+ const maxResumesAllowed = Number.isFinite(maxResumesEnv) ? maxResumesEnv : 5;
+ const atResumeLimit = resumes.length >= maxResumesAllowed;
+
+ const [editingResumeId, setEditingResumeId] = useState(null);
+ const [editingLabel, setEditingLabel] = useState("");
+ const sortedResumes = useMemo(
+ () => [...resumes].sort(compareResumesByUploadedAtDesc),
+ [resumes],
+ );
- const {
- fileInputRef: resumeFileInputRef,
- upload: resumeUpload,
- isUploading: resumeIsUploading,
- } = useFileUpload({
- uploader: UserService.updateMyResume,
- filename: "resume",
- silent: true,
- });
+ const startEditing = (resume: Resume) => {
+ if (!resume.id) {
+ toast.error("Resume not found.");
+ return;
+ }
+ setEditingResumeId(resume.id);
+ setEditingLabel(resume.label ?? "");
+ };
- const hasResume = !!profile.resume;
+ const cancelEditing = () => {
+ setEditingResumeId(null);
+ setEditingLabel("");
+ };
return (
-
- {/* Header row: status + actions */}
-
-
-
-
-
-
- {hasResume && (
-
-
-
- )}
- resumeFileInputRef.current?.open()}
- disabled={resumeIsUploading}
- >
-
- {resumeIsUploading
- ? "Uploading…"
- : hasResume
- ? "Upload new"
- : "Upload"}
-
-
+
+
+ Resumes
- {/* Optional hint / empty state line */}
- {!hasResume && !resumeIsUploading && (
-
- No resume yet. Click Upload to
- add your PDF.
-
- )}
+
+ {loading && (
+
+ Loading resumes...
+
+ )}
+ {!loading && resumes.length === 0 && (
+
+ No resumes uploaded yet.
+
+ )}
+ {!loading &&
+ sortedResumes.map((resume) => {
+ const isEditing = editingResumeId === resume.id;
+ const currentLabel = resume.label ?? "";
+
+ const handleSave = () => {
+ if (!resume.id) {
+ toast.error("Resume not found.");
+ return;
+ }
+
+ const nextLabel = editingLabel.trim();
+ if (!nextLabel) {
+ toast.error("Resume label is required.");
+ return;
+ }
+
+ if (nextLabel === currentLabel) {
+ cancelEditing();
+ return;
+ }
- {/* Hidden input handler */}
-
{
- // filename display removed by design
- const success = await resumeUpload(file);
-
- if (success) {
- queryClient.invalidateQueries({ queryKey: ["my-profile"] });
- toast.success(
- "Resume uploaded successfully.",
- toastPresets.success,
+ void (async () => {
+ const success = await onRenameResume(resume.id, nextLabel);
+ if (success) cancelEditing();
+ })();
+ };
+
+ return (
+
+
+ {isEditing ? (
+
+
+ {
+ if (event.key === "Enter") {
+ event.preventDefault();
+ handleSave();
+ } else if (event.key === "Escape") {
+ event.preventDefault();
+ cancelEditing();
+ }
+ }}
+ disabled={isRenamingResume}
+ />
+
+
+
+ {isRenamingResume ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+ Uploaded {formatResumeUploadedAt(resume.uploaded_at)}
+
+
+
+
void onViewResume(resume.id)}
+ aria-label={`View ${resume.label || "resume"}`}
+ disabled={isRenamingResume || isEditing}
+ >
+
+
+ {!isEditing && (
+
void startEditing(resume)}
+ aria-label={`Rename ${resume.label || "resume"}`}
+ disabled={isRenamingResume}
+ >
+
+
+ )}
+
void onDeleteResume(resume)}
+ aria-label={`Delete ${resume.label || "resume"}`}
+ disabled={isRenamingResume || isEditing}
+ >
+
+
+
+
);
- }
- }}
- />
+ })}
+
- {/* Uploading hint */}
- {resumeIsUploading && (
- Uploading your resume…
+ {!loading && resumes.length === 0 && (
+
+ Upload a PDF resume to make it available when applying.
+
)}
-
+
+
+
+ You can have up to {maxResumesAllowed} resumes.
+
+
+
+ Add resume
+
+
+
);
};
+function compareResumesByUploadedAtDesc(first: Resume, second: Resume) {
+ const firstUploadedAt = new Date(first.uploaded_at).getTime();
+ const secondUploadedAt = new Date(second.uploaded_at).getTime();
+ const firstTime = Number.isNaN(firstUploadedAt) ? 0 : firstUploadedAt;
+ const secondTime = Number.isNaN(secondUploadedAt) ? 0 : secondUploadedAt;
+
+ return secondTime - firstTime;
+}
+
+function formatResumeUploadedAt(uploadedAt: string) {
+ const date = new Date(uploadedAt);
+ if (Number.isNaN(date.getTime())) return "unknown date";
+ return date.toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ });
+}
// ----------------------------
// Link Badge
// ----------------------------
@@ -1522,9 +1654,8 @@ function computeProfileScore(p?: Partial
): {
const u = p ?? {};
const parts = {
name: !!(u.first_name && u.last_name),
- phone: !!u.phone_number,
bio: !!u.bio && u.bio.trim().length >= 50, // richer bios
- school: !!(u.university && u.degree),
+ school: !!u.university,
links: !!(u.github_link || u.linkedin_link || u.portfolio_link),
prefs: !!(
u.internship_preferences?.job_category_ids?.length ||
@@ -1538,11 +1669,10 @@ function computeProfileScore(p?: Partial): {
// weights sum to 100
const weights: Record = {
name: 10,
- phone: 5,
bio: 15,
school: 20,
links: 10,
- prefs: 20,
+ prefs: 25,
dates: 10,
resume: 10,
};
@@ -1557,42 +1687,8 @@ function computeProfileScore(p?: Partial): {
if (!parts.links) tips.push("Add your LinkedIn/GitHub/Portfolio.");
if (!parts.prefs) tips.push("Pick work modes, types, and roles you want.");
if (!parts.dates) tips.push("Add expected internship dates.");
- if (!parts.school) tips.push("Complete university/degree fields.");
+ if (!parts.school) tips.push("Add your university.");
if (!parts.resume) tips.push("Upload a resume in PDF (≤2.5MB).");
return { score, parts, tips };
}
-
-function monthFromMs(ms?: number | null): string | null {
- if (ms == null || Number.isNaN(ms)) return null;
- const d = new Date(ms); // local time
- const y = d.getFullYear();
- const m = String(d.getMonth() + 1).padStart(2, "0");
- return `${y}-${m}`;
-}
-
-// Coerce many date-ish inputs to "YYYY-MM" or null
-function toYYYYMM(input?: string | number | null): string | null {
- if (input == null || input === "") return null;
-
- // Already "YYYY-MM"
- if (typeof input === "string" && /^\d{4}-\d{2}$/.test(input)) return input;
-
- // If string contains "YYYY-MM" at the start (e.g., "YYYY-MM-DD", ISO)
- if (typeof input === "string") {
- const m = input.match(/^(\d{4}-\d{2})/);
- if (m) return m[1];
- }
-
- // Timestamp (number or numeric string)
- const n = typeof input === "number" ? input : Number(input);
- if (Number.isFinite(n)) return monthFromMs(n);
-
- // Fallback: parseable string date
- if (typeof input === "string") {
- const parsed = Date.parse(input);
- if (!Number.isNaN(parsed)) return monthFromMs(parsed);
- }
-
- return null;
-}
diff --git a/app/student/register/page.tsx b/app/student/register/page.tsx
index ea94dfe4..4df4d1cc 100644
--- a/app/student/register/page.tsx
+++ b/app/student/register/page.tsx
@@ -1,106 +1,80 @@
"use client";
-import { useEffect, useMemo, useState } from "react";
+import { Suspense, useEffect, useState } from "react";
import { useForm } from "react-hook-form";
-import { Button } from "@/components/ui/button";
-import { Card } from "@/components/ui/card";
-import { AutocompleteTreeMulti } from "@/components/ui/autocomplete";
import { useDbRefs } from "@/lib/db/use-refs";
-import { POSITION_TREE } from "@/lib/consts/positions";
-import {
- FormDropdown,
- FormInput,
- FormMonthPicker,
-} from "@/components/EditForm";
-import { MultiChipSelect } from "@/components/ui/chip-select";
-import { SinglePickerBig } from "@/components/shared/SinglePickerBig";
import { useAuthContext } from "@/lib/ctx-auth";
-import { BooleanCheckIcon } from "@/components/ui/icons";
-import { useProfileData } from "@/lib/api/student.data.api";
-import { useRouter } from "next/navigation";
-
-interface FormInputs {
+import { usePathname, useRouter, useSearchParams } from "next/navigation";
+import { toast } from "sonner";
+import { DropdownGroup } from "@/components/ui/dropdown";
+import { RegisterStep } from "./steps/RegisterStep";
+import { OTPEmailStep } from "./steps/OTPEmailStep";
+import { OTPEnterStep } from "./steps/OTPEnterStep";
+import { RegisterCarousel } from "@/components/features/student/register/RegisterCarousel";
+import { isNoUniversity } from "../../../lib/student-forms-access";
+
+export interface FormInputs {
+ first_name?: string;
+ middle_name?: string;
+ last_name?: string;
university?: string;
- internship_type?: "credited" | "voluntary";
- job_setup_ids?: string[];
- job_commitment_ids?: string[];
- job_category_ids?: string[];
- expected_start_date?: number | null;
- expected_duration_hours?: number | null;
-}
-
-function FieldLabel({ children }: { children: React.ReactNode }) {
- return {children}
;
+ degree?: string;
}
-// Returns the UNIX timestamp of the first day of the current month
-const getNearestMonthTimestamp = () => {
- const date = new Date();
- const dateString = `${date.getFullYear()}-${(
- "0" + (date.getMonth() + 1).toString()
- ).slice(-2)}-01T00:00:00.000Z`;
- return Date.parse(dateString);
-};
+const NEXT_URL = "/search";
-const StepCheckIndicator = ({ checked }: { checked: boolean }) => {
- return (
-
-
-
- );
-};
-
-export default function RegisterPage() {
+export function RegisterPageContent() {
const refs = useDbRefs();
const auth = useAuthContext();
- const [submitting, setSubmitting] = useState(false);
- const profile = useProfileData();
const router = useRouter();
+ const searchParams = useSearchParams();
+ const pathname = usePathname();
- const nextUrl = "/search";
- const deciding = profile.data === undefined;
+ const [submitting, setSubmitting] = useState(false);
+ const [verificationEmail, setVerificationEmail] = useState("");
+ const [step, setStep] = useState(
+ searchParams.get("step") === "verify" ? 2 : 1,
+ );
- // Redirect only after we know the profile state
+ const skipOtpStep = searchParams.get("edu-email") === "true";
+
+ // modify url params based on current step.
useEffect(() => {
- if (deciding) return;
- if (profile.data?.is_verified) router.replace(nextUrl);
- }, [deciding, profile.data?.is_verified, router]);
+ const params = new URLSearchParams(searchParams.toString());
+
+ if (step === 2 || step === 3) {
+ params.set("step", "verify");
+ } else {
+ params.delete("step");
+ }
- // Prevent any flash
- if (deciding || profile.data?.is_verified) return null;
+ const search = params.toString();
+ const query = search ? `?${search}` : "";
+ console.log(`register skip: ${pathname}${query}`);
+ // router.replace(`${pathname}${query}`, { scroll: false });
+ }, [step, pathname, router, searchParams]);
+
+ // skip main register page if the user is already registered.
+ useEffect(() => {
+ if (step === 1 && auth.isAuthenticated()) {
+ if (skipOtpStep) {
+ router.replace(NEXT_URL);
+ } else {
+ setStep(2);
+ }
+ }
+ }, [step, auth, router, skipOtpStep]);
const regForm = useForm({
defaultValues: {
- internship_type: undefined,
- job_setup_ids: [],
- job_commitment_ids: [],
- job_category_ids: [],
- expected_start_date: getNearestMonthTimestamp(),
- expected_duration_hours: 300,
+ first_name: "",
+ middle_name: "",
+ last_name: "",
+ university: "",
+ degree: "",
},
});
- // Derived state
- const internshipType = regForm.watch("internship_type");
- const isCredited = internshipType === "credited";
- const isVoluntary = internshipType === "voluntary";
-
- // Build ALL ids for work modes and types
- const allJobModeIds = useMemo(
- () => (refs.job_modes ?? []).map((o: any) => String(o.id)),
- [refs.job_modes],
- );
-
- const allJobTypeIds = useMemo(
- () => (refs.job_types ?? []).map((o: any) => String(o.id)),
- [refs.job_types],
- );
-
- // Helpers to find specific ids
- const fullTimeJobTypeId = "2";
- const hybridModeId = "1";
- const remoteModeId = "2";
-
/**
* Handle form submit
*
@@ -108,37 +82,43 @@ export default function RegisterPage() {
*/
const handleSubmit = (values: FormInputs) => {
setSubmitting(true);
+ const shouldSkipOtp = skipOtpStep || isNoUniversity(values.university);
// Check for missing fields
- if (!values.university?.trim()) {
- alert("University is required.");
+ if (!values.first_name?.trim()) {
+ alert("Your first name is required.");
setSubmitting(false);
return;
}
- if (values.job_category_ids?.length === 0) {
- alert("Desired internship role is required");
+ if (!values.last_name?.trim()) {
+ alert("Your last name is required.");
setSubmitting(false);
return;
}
- // Cap internship hours
- if (
- (values.expected_duration_hours ?? 2000) > 2000 ||
- (values.expected_duration_hours ?? 100) < 100
- ) {
- alert("Duration hours must be between 100-2000..");
+ if (!values.university?.trim()) {
+ alert("Your university is required.");
+ setSubmitting(false);
+ return;
+ }
+
+ if (!values.degree?.trim()) {
+ alert("Your degree program is required.");
setSubmitting(false);
return;
}
// Extract fields
- const { university, ...internship_preferences } = values;
+ const { university, first_name, middle_name, last_name, degree } = values;
auth
.register({
university,
- internship_preferences,
+ first_name,
+ middle_name,
+ last_name,
+ degree,
})
.then((response) => {
if (response?.message) {
@@ -147,302 +127,90 @@ export default function RegisterPage() {
return;
}
- location.href = "/register/verify?next=/search"; // go to OTP verification page (next step)
+ setSubmitting(false);
+
+ if (shouldSkipOtp) {
+ router.replace(NEXT_URL);
+ return;
+ }
+
+ setStep(step + 1);
})
.catch((error) => {
setSubmitting(false);
console.log(error);
- alert("Something went wrong... Try again later.");
+ toast.error("Something went wrong... Try again later.");
});
};
- // Auto-select job_commitment_ids per rules
- useEffect(() => {
- if (!refs.job_types?.length) return;
-
- if (internshipType === "credited") {
- if (allJobTypeIds.length) {
- regForm.setValue("job_commitment_ids", allJobTypeIds, {
- shouldDirty: true,
- });
- }
- } else if (internshipType === "voluntary") {
- const filtered = fullTimeJobTypeId
- ? allJobTypeIds.filter((id) => id !== fullTimeJobTypeId)
- : allJobTypeIds;
- regForm.setValue("job_commitment_ids", filtered, { shouldDirty: true });
- } else {
- regForm.setValue("job_commitment_ids", [], { shouldDirty: true });
- }
- }, [
- internshipType,
- refs.job_types,
- allJobTypeIds,
- fullTimeJobTypeId,
- regForm,
- ]);
-
- // Auto-select job_setup_ids per rules
- useEffect(() => {
- if (!refs.job_modes?.length) return;
-
- if (internshipType === "credited") {
- regForm.setValue("job_setup_ids", allJobModeIds, { shouldDirty: true });
- } else if (internshipType === "voluntary") {
- const filtered = [hybridModeId, remoteModeId].filter(Boolean);
- regForm.setValue("job_setup_ids", filtered, { shouldDirty: true });
- } else {
- regForm.setValue("job_setup_ids", [], { shouldDirty: true });
- }
- }, [
- internshipType,
- refs.job_modes,
- allJobModeIds,
- hybridModeId,
- remoteModeId,
- regForm,
- ]);
-
- // Keep job_commitment_ids valid when refs load late
- useEffect(() => {
- if (!refs.job_types?.length) return;
- const current = regForm.getValues("job_commitment_ids") || [];
- if (!current.length) return;
- const next = current.filter((id) => allJobTypeIds.includes(id));
- if (next.length !== current.length) {
- regForm.setValue("job_commitment_ids", next, { shouldDirty: true });
- }
- }, [refs.job_types, allJobTypeIds, regForm]);
-
- // Clear internship hours when switching to voluntary
- useEffect(() => {
- if (internshipType === "voluntary") {
- regForm.setValue("expected_duration_hours", 300, {
- shouldDirty: true,
- });
- }
- }, [internshipType, regForm.getValues()]);
-
- // !TEMP -- disable ateneo
- const universityOptions = refs.universities?.filter((u) => u.name !== "ADMU");
-
return (
-
-
- {/* Header */}
-
-
-
-
Welcome to BetterInternship!
-
+
+
+ {/* Decorative area */}
+
+
-
);
}
+
+export default function RegisterPage() {
+ return (
+
}>
+
+
+ );
+}
diff --git a/app/student/register/steps/OTPEmailStep.tsx b/app/student/register/steps/OTPEmailStep.tsx
new file mode 100644
index 00000000..5c31096d
--- /dev/null
+++ b/app/student/register/steps/OTPEmailStep.tsx
@@ -0,0 +1,108 @@
+import { useProfileData } from "@/lib/api/student.data.api";
+import { FormInput } from "@/components/EditForm";
+import { useState, useEffect } from "react";
+import { Button } from "@/components/ui/button";
+import { toast } from "sonner";
+import { AlertTriangle } from "lucide-react";
+import { useStudentOtpVerification } from "@/hooks/use-student-otp-verification";
+import { isEduPhEmail } from "@/lib/utils/string-utils";
+
+/**
+ * The second step to registering where the user verifies their education email
+ * to access forms. This step can be skipped during initial onboarding.
+ * @param onNextAction Function that runs when the step is finished.
+ * @param onSkipAction Function that runs when the step is skipped.
+ * @returns Form for inputting the email to send the verification code to.
+ */
+export function OTPEmailStep({
+ onNextAction,
+ onSkipAction,
+}: {
+ onNextAction: (email: string) => void;
+ onSkipAction: () => void;
+}) {
+ const profile = useProfileData();
+ const [eduEmail, setEduEmail] = useState(
+ profile.data?.edu_verification_email ?? "",
+ );
+ const [isEmailValid, setIsEmailValid] = useState(false);
+ const { error, requestOtp, sending } = useStudentOtpVerification({
+ email: eduEmail,
+ });
+
+ // set the education email if it doesn't exist on the user account.
+ useEffect(() => {
+ if (!eduEmail && profile.data?.edu_verification_email)
+ setEduEmail(profile.data.edu_verification_email);
+ }, [eduEmail, profile.data?.edu_verification_email]);
+
+ // set the email validity flag when the value is changed.
+ useEffect(() => {
+ setIsEmailValid(isEduPhEmail(eduEmail));
+ }, [eduEmail]);
+
+ const requestOTP = async () => {
+ if (!isEmailValid || sending) return;
+
+ const result = await requestOtp({
+ failureMessage: "Couldn't send OTP. Try again.",
+ startCooldown: false,
+ });
+
+ if (result?.success !== true) return;
+
+ toast.success("OTP sent. Check your inbox for the six-digit code.");
+ onNextAction(eduEmail);
+ };
+
+ return (
+ <>
+
+
+ Verify your account
+
+
+ Some site features require you to verify your edu email. You can skip
+ this for now.
+
+
+
+
+
{
+ setEduEmail(value);
+ }}
+ placeholder="student@school.edu.ph"
+ />
+ {error && (
+
+ )}
+
+
+
+
+
+ Skip for now
+
+ void requestOTP()}
+ disabled={!isEmailValid || sending}
+ >
+ {sending ? "Sending..." : "Send OTP"}
+
+
+ >
+ );
+}
diff --git a/app/student/register/steps/OTPEnterStep.tsx b/app/student/register/steps/OTPEnterStep.tsx
new file mode 100644
index 00000000..24c5ac70
--- /dev/null
+++ b/app/student/register/steps/OTPEnterStep.tsx
@@ -0,0 +1,104 @@
+import { Button } from "@/components/ui/button";
+import { toast } from "sonner";
+import { AlertTriangle } from "lucide-react";
+import { useStudentOtpVerification } from "@/hooks/use-student-otp-verification";
+import { StudentOtpInput } from "@/components/features/student/register/StudentOtpInput";
+
+/**
+ * The third step to registering where the user inputs the verification code sent to their email.
+ * @param eduEmail The user's education email that the verification code was sent to.
+ * @param onFinishAction A function to run when clicking the finish button.
+ * @param onBackAction A function to run when clicking the back button.
+ * @returns Form forentering the verification code.
+ */
+export function OTPEnterStep({
+ eduEmail,
+ onFinishAction,
+ onBackAction,
+}: {
+ eduEmail: string;
+ onFinishAction: () => void;
+ onBackAction: () => void;
+}) {
+ const {
+ countdown,
+ error: otpError,
+ isCoolingDown,
+ otpInputProps,
+ requestOtp,
+ sending,
+ } = useStudentOtpVerification({
+ email: eduEmail,
+ autoActivate: {
+ onSuccess: onFinishAction,
+ },
+ initialCoolingDown: true,
+ });
+
+ // connect to the authentication service to request the otp.
+ const requestOTP = async () => {
+ // check if the otp can be requested before doing anything.
+ if (sending || isCoolingDown) return;
+
+ const result = await requestOtp({
+ failureMessage: "Couldn't send verification code. Try again.",
+ });
+
+ if (result?.success !== true) return;
+
+ toast.success(
+ "Verification code sent. Check your inbox for the six-digit code.",
+ );
+ };
+
+ return (
+ <>
+
+
+ Enter verification code
+
+
+ We sent a 6-digit code to {eduEmail} .
+
+
+
+ Enter the 6-digit code sent to your email
+
+
+
+
+ {otpError && (
+
+ )}
+
+
+
+ Back
+
+ void requestOTP()}
+ disabled={sending || isCoolingDown}
+ className="w-full sm:w-auto"
+ >
+ {sending
+ ? "Sending..."
+ : isCoolingDown
+ ? `Resend in ${countdown}s`
+ : "Resend code"}
+
+
+
+ >
+ );
+}
diff --git a/app/student/register/steps/RegisterStep.tsx b/app/student/register/steps/RegisterStep.tsx
new file mode 100644
index 00000000..da19ce39
--- /dev/null
+++ b/app/student/register/steps/RegisterStep.tsx
@@ -0,0 +1,134 @@
+import { IRefsContext, University } from "@/lib/db/db.types";
+import { FormInput } from "@/components/EditForm";
+import { Button } from "@/components/ui/button";
+import { UseFormReturn } from "react-hook-form";
+import { FormInputs } from "../page";
+import { Autocomplete } from "@/components/ui/autocomplete";
+import { DEGREES } from "./tempDegrees";
+import { sortUniversityOptions } from "../../../../lib/student-forms-access";
+import {
+ Accordion,
+ AccordionContent,
+ AccordionItem,
+ AccordionTrigger,
+} from "@/components/ui/accordion";
+
+/**
+ * The first step to registering where the user puts their personal information in.
+ * @param regForm The form data.
+ * @param refs Reference table hook.
+ * @returns Form for inputting personal information for student registration.
+ */
+export function RegisterStep({
+ regForm,
+ refs,
+ onSubmit,
+ submitting,
+}: {
+ regForm: UseFormReturn
;
+ refs: IRefsContext;
+ onSubmit: (values: FormInputs) => void;
+ submitting: boolean;
+}) {
+ const firstName = regForm.watch("first_name") || "";
+ const lastName = regForm.watch("last_name") || "";
+ const university = regForm.watch("university") || "";
+ const degree = regForm.watch("degree") || "";
+ const hasValidUniversity = refs.universities.some(
+ (option) => option.id === university,
+ );
+ const universityOptions = sortUniversityOptions(refs.universities);
+ const canCreateAccount =
+ firstName.trim() && lastName.trim() && hasValidUniversity && degree.trim();
+
+ return (
+ <>
+
+
+ Let's get started
+
+
+
+ {
+ regForm.setValue("first_name", val);
+ }}
+ placeholder="First name"
+ />
+
+ {
+ regForm.setValue("last_name", val);
+ }}
+ className="col-span-1 sm:col-span-2"
+ placeholder="Last name"
+ />
+
+
+ {
+ regForm.setValue("university", val === null ? "" : String(val));
+ }}
+ options={universityOptions as { id: string; name: string }[]}
+ value={university}
+ required={true}
+ preserveOptionOrder={true}
+ />
+
+
+
+
+ Don't see your university? Let us know!
+
+
+ Want to see your university on our site?
+
+ Talk to us now
+ {" "}
+ and become an ambassador for BetterInternship!
+
+
+
+
+ {
+ regForm.setValue("degree", val === null ? "" : String(val));
+ }}
+ value={degree}
+ required={true}
+ allowCustomValue={true}
+ emptyText="type your own degree..."
+ />
+
+ {/* create account */}
+
+
+ void regForm.handleSubmit(() => onSubmit(regForm.getValues()))(e)
+ }
+ >
+ {submitting ? "Creating account..." : "Create account"}
+
+
+ >
+ );
+}
diff --git a/app/student/register/steps/tempDegrees.ts b/app/student/register/steps/tempDegrees.ts
new file mode 100644
index 00000000..d5016981
--- /dev/null
+++ b/app/student/register/steps/tempDegrees.ts
@@ -0,0 +1,130 @@
+import { IAutocompleteOption } from "@/components/ui/autocomplete";
+
+// temp: this should not be in frontend. this will be changed soon.
+export const DEGREES: IAutocompleteOption[] = [
+ "Accountancy",
+ "Accounting Information Systems",
+ "Accounting Technology",
+ "Agribusiness",
+ "Agricultural and Biosystems Engineering",
+ "Agriculture",
+ "Agroforestry",
+ "Aircraft Maintenance Technology",
+ "Animal Science",
+ "Anthropology",
+ "Applied Mathematics",
+ "Applied Physics",
+ "Applied Statistics",
+ "Architecture",
+ "Biochemistry",
+ "Biology",
+ "Business Administration",
+ "Business Analytics",
+ "Business Management",
+ "Chemical Engineering",
+ "Chemistry",
+ "Civil Engineering",
+ "Communications",
+ "Community Development",
+ "Computer Engineering",
+ "Computer Science",
+ "Creative Writing",
+ "Criminology",
+ "Culinary Arts",
+ "Customs Administration",
+ "Cybersecurity",
+ "Data Science and Analytics",
+ "Dental Medicine",
+ "Development Communication",
+ "Disaster Risk Management",
+ "Early Childhood Education",
+ "Economics",
+ "Electrical Engineering",
+ "Electronics Engineering",
+ "Elementary Education",
+ "Entrepreneurship",
+ "Environmental Engineering",
+ "Environmental Planning",
+ "Environmental Science",
+ "Fashion Design",
+ "Film",
+ "Finance",
+ "Fine Arts",
+ "Fisheries",
+ "Food Engineering",
+ "Food Technology",
+ "Forestry",
+ "Game Development and Animation",
+ "Geodetic Engineering",
+ "Geology",
+ "History",
+ "Horticulture",
+ "Hospitality Management",
+ "Hotel and Restaurant Management",
+ "Human Resource Management",
+ "Human Services",
+ "Industrial Design",
+ "Industrial Engineering",
+ "Information Systems",
+ "Information Technology",
+ "Interior Design",
+ "International Studies",
+ "Journalism",
+ "Landscape Architecture",
+ "Law",
+ "Library and Information Science",
+ "Management Accounting",
+ "Manufacturing Engineering",
+ "Marine Biology",
+ "Marine Engineering",
+ "Marketing",
+ "Materials Engineering",
+ "Mathematics",
+ "Mechanical Engineering",
+ "Mechatronics Engineering",
+ "Medical Laboratory Science",
+ "Medical Technology",
+ "Midwifery",
+ "Mining Engineering",
+ "Multimedia Arts",
+ "Music",
+ "Naval Architecture and Marine Engineering",
+ "Nursing",
+ "Nutrition and Dietetics",
+ "Occupational Therapy",
+ "Operations Management",
+ "Optometry",
+ "Peace Studies",
+ "Petroleum Engineering",
+ "Pharmacy",
+ "Philosophy",
+ "Physical Education",
+ "Physical Therapy",
+ "Physics",
+ "Political Science",
+ "Psychology",
+ "Public Administration",
+ "Public Health",
+ "Public Management",
+ "Radiologic Technology",
+ "Real Estate Management",
+ "Renewable Energy",
+ "Robotics Engineering",
+ "Secondary Education",
+ "Social Work",
+ "Sociology",
+ "Software Engineering",
+ "Special Needs Education",
+ "Speech and Language Pathology",
+ "Sports and Exercise Science",
+ "Statistics",
+ "Supply Chain Management",
+ "Technology and Livelihood Education",
+ "Theater Arts",
+ "Tourism Management",
+ "Veterinary Medicine",
+ "Other",
+].map((degree) => ({
+ id: degree,
+ name: degree,
+}));
diff --git a/app/student/register/verify/page.tsx b/app/student/register/verify/page.tsx
index c0e70cf8..ba51ed44 100644
--- a/app/student/register/verify/page.tsx
+++ b/app/student/register/verify/page.tsx
@@ -1,33 +1,46 @@
"use client";
-import { useEffect, useState } from "react";
-import { useRouter } from "next/navigation";
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { useRouter, useSearchParams } from "next/navigation";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
-import {
- InputOTP,
- InputOTPGroup,
- InputOTPSlot,
-} from "@/components/ui/input-otp";
-import { AlertTriangle, Repeat } from "lucide-react";
+import { AlertTriangle, ArrowLeft, Repeat } from "lucide-react";
import { useProfileData } from "@/lib/api/student.data.api";
-import { AuthService } from "@/lib/api/services";
import { useQueryClient } from "@tanstack/react-query";
import { useAuthContext } from "@/lib/ctx-auth";
import { Loader } from "@/components/ui/loader";
import { toast } from "sonner";
import { toastPresets } from "@/components/ui/sonner-toast";
+import { useStudentOtpVerification } from "@/hooks/use-student-otp-verification";
+import { StudentOtpInput } from "@/components/features/student/register/StudentOtpInput";
+import { isEduPhEmail } from "@/lib/utils/string-utils";
+
+const DEFAULT_VERIFICATION_REDIRECT = "/search";
+
+const resolveVerificationRedirect = (redirect: string | null) => {
+ const requestedRedirect = redirect?.trim();
+ if (!requestedRedirect) return DEFAULT_VERIFICATION_REDIRECT;
+ return `/${redirect}`;
+};
export default function VerifyPage() {
const router = useRouter();
+ const searchParams = useSearchParams();
const { redirectIfNotLoggedIn } = useAuthContext();
- const nextUrl = "/search";
+ const nextUrl = useMemo(
+ () => resolveVerificationRedirect(searchParams.get("redirect")),
+ [searchParams],
+ );
const profile = useProfileData();
const queryClient = useQueryClient();
const [mounted, setMounted] = useState(false);
+ const handleBack = () => {
+ router.push("/search");
+ };
+
useEffect(() => {
setMounted(true);
}, []);
@@ -37,10 +50,23 @@ export default function VerifyPage() {
// Redirect only after we know the profile state
useEffect(() => {
if (profile.isPending) return;
- if (profile.data?.is_verified) router.replace(nextUrl);
+ if (profile.data?.is_verified) {
+ if (nextUrl) router.replace(nextUrl);
+ else return;
+ }
if (!profile.data)
void queryClient.invalidateQueries({ queryKey: ["my-profile"] });
- }, [profile.isPending, profile.data?.is_verified, router]);
+ }, [
+ nextUrl,
+ profile.isPending,
+ profile.data?.is_verified,
+ queryClient,
+ router,
+ ]);
+
+ const finishVerification = useCallback(() => {
+ if (nextUrl) router.replace(nextUrl);
+ }, [nextUrl, router]);
// Prevent hydration mismatch when client restores persisted query cache.
// Server render and first client render both return null.
@@ -48,7 +74,14 @@ export default function VerifyPage() {
// Wait for profile and auth checks; unauthenticated users are redirected
if (profile.isPending) return Loading... ;
- if (profile.data?.is_verified) return router.replace(nextUrl);
+ if (profile.data?.is_verified) {
+ if (nextUrl) {
+ router.replace(nextUrl);
+ return null;
+ } else {
+ return Loading... ;
+ }
+ }
if (!profile.data) return Loading... ;
return (
@@ -71,8 +104,21 @@ export default function VerifyPage() {
+
+
- router.replace(nextUrl)} />
+
@@ -87,18 +133,26 @@ export default function VerifyPage() {
function StepActivateOTP({ onFinish }: { onFinish: () => void }) {
const profile = useProfileData();
- const queryClient = useQueryClient();
- const [otp, setOtp] = useState("");
- const [otpError, setOtpError] = useState("");
- const [sending, setSending] = useState(false);
const [sent, setSent] = useState(false);
const [eduEmail, setEduEmail] = useState(
profile.data?.edu_verification_email ?? "",
);
const [isEmailValid, setIsEmailValid] = useState(false);
- const [isCoolingDown, setIsCoolingDown] = useState(false);
- const [activating, setActivating] = useState(false);
- const [countdown, setCountdown] = useState(0);
+ const {
+ activating,
+ countdown,
+ error: otpError,
+ isCoolingDown,
+ otpInputProps,
+ requestOtp,
+ sending,
+ } = useStudentOtpVerification({
+ email: eduEmail,
+ autoActivate: {
+ failureMessage: "OTP not valid.",
+ onSuccess: onFinish,
+ },
+ });
useEffect(() => {
if (!eduEmail && profile.data?.edu_verification_email) {
@@ -107,69 +161,24 @@ function StepActivateOTP({ onFinish }: { onFinish: () => void }) {
}, [eduEmail, profile.data?.edu_verification_email]);
useEffect(() => {
- if (!eduEmail?.trim()) return setIsEmailValid(false);
- if (!eduEmail.endsWith(".edu.ph")) return setIsEmailValid(false);
- setIsEmailValid(true);
+ setIsEmailValid(isEduPhEmail(eduEmail));
}, [eduEmail]);
- // auto-activate once 6 digits entered
- useEffect(() => {
- if (otp.length !== 6) return;
- setActivating(true);
- setOtpError("");
- AuthService.activate(eduEmail, otp)
- .then(async (response) => {
- await queryClient.invalidateQueries({ queryKey: ["my-profile"] });
-
- if (response?.success === true) {
- onFinish();
- } else {
- setOtpError(
- response?.message?.trim() ||
- response?.error?.trim() ||
- "OTP not valid.",
- );
- }
- })
- .catch(() => setOtpError("Couldn't verify your code. Try again."))
- .finally(() => setActivating(false));
- }, [otp, eduEmail, onFinish, queryClient]);
-
- const requestOTP = () => {
+ const requestOTP = async () => {
if (!isEmailValid || sending || isCoolingDown) return;
- setSending(true);
- setOtpError("");
- AuthService.requestActivation(eduEmail)
- .then((response) => {
- if (response?.success !== true) {
- console.log("OTP request failed:", response);
- const err =
- response?.message?.trim() ||
- response?.error?.trim() ||
- "Couldn't send OTP. Try again.";
- setOtpError(err);
- return;
- }
- toast.success(
- "OTP sent. Check your inbox for the 6-digit code.",
- toastPresets.success,
- );
- setSent(true);
- setIsCoolingDown(true);
- setCountdown(60);
- })
- .catch(() => setOtpError("Couldn't send OTP. Try again."))
- .finally(() => setSending(false));
- };
- useEffect(() => {
- if (countdown > 0) {
- const timer = setTimeout(() => setCountdown(countdown - 1), 1000);
- return () => clearTimeout(timer);
- } else if (countdown === 0 && isCoolingDown) {
- setIsCoolingDown(false);
- }
- }, [countdown, isCoolingDown]);
+ const result = await requestOtp({
+ failureMessage: "Couldn't send OTP. Try again.",
+ });
+
+ if (result?.success !== true) return;
+
+ toast.success(
+ "OTP sent. Check your inbox for the 6-digit code.",
+ toastPresets.success,
+ );
+ setSent(true);
+ };
return (
<>
@@ -184,7 +193,9 @@ function StepActivateOTP({ onFinish }: { onFinish: () => void }) {
type="email"
placeholder="email@uni.edu.ph"
className="h-11"
- onChange={(e) => setEduEmail(e.currentTarget.value)}
+ onChange={(e) => {
+ setEduEmail(e.currentTarget.value);
+ }}
/>
@@ -194,21 +205,7 @@ function StepActivateOTP({ onFinish }: { onFinish: () => void }) {
Enter the 6-digit code sent to your email
-
-
-
-
-
-
-
-
-
-
+
)}
@@ -231,7 +228,7 @@ function StepActivateOTP({ onFinish }: { onFinish: () => void }) {
void requestOTP()}
size={"md"}
className="w-full sm:w-auto"
disabled={sending || !isEmailValid || isCoolingDown}
diff --git a/app/student/search/[job_id]/page.tsx b/app/student/search/[job_id]/page.tsx
index b8074fa1..0c3dd80c 100644
--- a/app/student/search/[job_id]/page.tsx
+++ b/app/student/search/[job_id]/page.tsx
@@ -14,11 +14,11 @@ 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 { ApplyConfirmModal } from "@/components/modals/ApplyConfirmModal";
-import { applyToJob } from "@/lib/application";
import { useApplicationActions } from "@/lib/api/student.actions.api";
import { ShareJobButton } from "@/components/features/student/job/share-job-button";
import { useAuthContext } from "@/lib/ctx-auth";
+import type { ApplyPayload } from "@/components/modals/components/ApplyModal";
+import { toast } from "sonner";
/**
* The individual job page.
@@ -31,18 +31,34 @@ export default function JobPage() {
const job = useJobData(job_id as string);
const [isActionsSheetOpen, setIsActionsSheetOpen] = useState(false);
const { isMobile } = useMobile();
- const applyConfirmModalRef = useModalRef();
- const successModalRef = useModalRef();
const applySuccessModalRef = useModalRef();
const applicationActions = useApplicationActions();
const profile = useProfileData();
const { isAuthenticated } = useAuthContext();
- const goProfile = useCallback(() => {
- applyConfirmModalRef.current?.close();
- router.push("/profile");
- }, [applyConfirmModalRef, router]);
+ const handleApply = useCallback(
+ async ({ resumeId, coverLetter }: ApplyPayload) => {
+ if (!job.data?.id || !resumeId) return;
+
+ const response = await applicationActions.create.mutateAsync({
+ job_id: job.data.id,
+ resume_id: resumeId,
+ cover_letter:
+ job.data.internship_preferences?.require_cover_letter === true
+ ? coverLetter
+ : "",
+ });
+
+ if (response.message) {
+ toast.error(response.message);
+ return;
+ }
+
+ applySuccessModalRef.current?.open();
+ },
+ [applicationActions.create, job.data],
+ );
if (job.error)
return (
@@ -126,9 +142,7 @@ export default function JobPage() {
- applyConfirmModalRef.current?.open()
- }
+ onApply={handleApply}
/>
)}
@@ -169,7 +183,7 @@ export default function JobPage() {
applyConfirmModalRef.current?.open()}
+ onApply={handleApply}
className="w-full"
/>
@@ -219,23 +233,6 @@ export default function JobPage() {
-
applyConfirmModalRef.current?.close()}
- onAddNow={goProfile}
- onSubmit={async (payload) => {
- applyConfirmModalRef.current?.close();
- await applyToJob(applicationActions, job.data, payload).then(
- (response) => {
- if (!response.success) return alert(response.message);
- applySuccessModalRef.current?.open();
- },
- );
- }}
- />
-
-
>
);
}
diff --git a/app/student/search/page.tsx b/app/student/search/page.tsx
index 6a6e9a0e..5e2c2f77 100644
--- a/app/student/search/page.tsx
+++ b/app/student/search/page.tsx
@@ -20,22 +20,16 @@ import { ApplySuccessModal } from "@/components/modals/ApplySuccessModal";
import { JobModal } from "@/components/modals/JobModal";
import { useMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils";
-import {
- isProfileBaseComplete,
- isProfileResume,
- isProfileVerified,
-} from "@/lib/profile";
-import { useRouter } from "next/navigation";
import { SaveJobButton } from "@/components/features/student/job/save-job-button";
import { ApplyToJobButton } from "@/components/features/student/job/apply-to-job-button";
import { ShareJobButton } from "@/components/features/student/job/share-job-button";
-import { ApplyConfirmModal } from "@/components/modals/ApplyConfirmModal";
-import { applyToJob } from "@/lib/application";
import { PageError } from "@/components/ui/error";
import { useApplicationActions } from "@/lib/api/student.actions.api";
import useModalRegistry from "@/components/modals/modal-registry";
import { Loader } from "@/components/ui/loader";
import { motion, AnimatePresence } from "framer-motion";
+import type { ApplyPayload } from "@/components/modals/components/ApplyModal";
+import { toast } from "sonner";
export default function SearchPage() {
const searchParams = useSearchParams();
@@ -46,7 +40,6 @@ export default function SearchPage() {
// selection + bulk apply
const [selectMode, setSelectMode] = useState(false);
const [selectedIds, setSelectedIds] = useState>(new Set());
- const [bulkCoverLetter, setBulkCoverLetter] = useState("");
// job list & filters
const [selectedJob, setSelectedJob] = useState(null);
@@ -83,7 +76,6 @@ export default function SearchPage() {
// Modals
const jobModalRef = useModalRef();
const applySuccessModalRef = useModalRef();
- const applyConfirmModalRef = useModalRef();
// computed pages
const jobsPage = jobs.getJobsPage({ page: _jobsPage, limit: jobsPageSize });
@@ -197,34 +189,26 @@ export default function SearchPage() {
window.location.href = `${process.env.NEXT_PUBLIC_API_URL}/auth/google`;
return;
}
- if (!isProfileVerified(profile.data)) {
- return router.push("/register/verify");
- }
-
- if (
- !isProfileResume(profile.data) ||
- !isProfileBaseComplete(profile.data) ||
- profile.data?.acknowledged_auto_apply === false
- ) {
- return router.push(`profile/complete-profile?dest=search`);
- }
const allApplied =
selectedJobsList.length > 0 &&
- selectedJobsList.every((j) => jobs.isJobApplied(j.id!));
+ selectedJobsList.every((j) => jobs.isJobApplied(j.id));
if (!selectedJobsList.length || allApplied) {
- alert(
+ toast.error(
"No eligible jobs selected. Select jobs you haven’t applied to yet.",
);
return;
}
- modalRegistry.massApplyCompose.open({
- bulkCoverLetter,
- setBulkCoverLetter,
- runMassApply,
- massApplying,
- selectedCount: selectedIds.size + "",
+ modalRegistry.completeProfileApply.open({
+ profile: profile.data,
+ applyLabel: `Apply to ${selectedIds.size || 0}`,
+ requiresCoverLetter: selectedJobsList.some(
+ (job) => job.internship_preferences?.require_cover_letter === true,
+ ),
+ onApply: ({ resumeId, coverLetter }: ApplyPayload) => {
+ void runMassApply(resumeId, coverLetter);
+ },
});
};
@@ -234,7 +218,7 @@ export default function SearchPage() {
const isSubmittingRef = useRef(false);
const runMassApply = useCallback(
- async (coverLetter: string) => {
+ async (resumeId: string, coverLetter: string) => {
if (isSubmittingRef.current) return;
isSubmittingRef.current = true;
@@ -245,7 +229,7 @@ export default function SearchPage() {
const eligible: Job[] = [];
for (const job of selectedJobsList) {
- if (jobs.isJobApplied(job.id!)) {
+ if (jobs.isJobApplied(job.id)) {
skipped.push({ job, reason: "Already applied" });
continue;
}
@@ -256,8 +240,6 @@ export default function SearchPage() {
const needsPortfolio =
internshipPreferences?.require_portfolio &&
!profile.data?.portfolio_link?.trim();
- const needsCover =
- internshipPreferences?.require_cover_letter && !coverLetter.trim();
if (needsGithub) {
skipped.push({ job, reason: "Requires GitHub profile" });
@@ -267,10 +249,6 @@ export default function SearchPage() {
skipped.push({ job, reason: "Requires portfolio link" });
continue;
}
- if (needsCover) {
- skipped.push({ job, reason: "Requires a cover letter" });
- continue;
- }
eligible.push(job);
}
@@ -280,7 +258,6 @@ export default function SearchPage() {
skipped,
failed: [] as { job: Job; error: string }[],
};
- modalRegistry.massApplyCompose.close();
modalRegistry.massApplyResult.open({
massApplyResultsData: data,
clearSelection,
@@ -298,7 +275,11 @@ export default function SearchPage() {
try {
await applicationActions.create.mutateAsync({
job_id: job.id ?? "",
- cover_letter: coverLetter || "",
+ resume_id: resumeId,
+ cover_letter:
+ job.internship_preferences?.require_cover_letter === true
+ ? coverLetter
+ : "",
});
if (applicationActions.create.error) {
failed.push({
@@ -320,7 +301,6 @@ export default function SearchPage() {
setMassApplying(false);
const data = { ok, skipped, failed };
- modalRegistry.massApplyCompose.close();
modalRegistry.massApplyResult.open({
massApplyResultsData: data,
clearSelection,
@@ -332,12 +312,28 @@ export default function SearchPage() {
},
[profile.data, applicationActions, clearSelection, setSelectMode],
);
- const router = useRouter();
+ const handleSingleApply = useCallback(
+ async ({ resumeId, coverLetter }: ApplyPayload) => {
+ if (!selectedJob?.id || !resumeId) return;
+
+ const response = await applicationActions.create.mutateAsync({
+ job_id: selectedJob.id,
+ resume_id: resumeId,
+ cover_letter:
+ selectedJob.internship_preferences?.require_cover_letter === true
+ ? coverLetter
+ : "",
+ });
+
+ if (response.message) {
+ toast.error(response.message);
+ return;
+ }
- const goProfile = useCallback(() => {
- applyConfirmModalRef.current?.close();
- router.push("/profile");
- }, [applyConfirmModalRef, router]);
+ applySuccessModalRef.current?.open();
+ },
+ [applicationActions.create, selectedJob],
+ );
if (jobs.error)
return (
@@ -483,7 +479,7 @@ export default function SearchPage() {
{/* Mobile mass apply toolbar */}
{selectMode && (
applyConfirmModalRef.current?.open()}
+ onApply={handleSingleApply}
/>,
]}
isAuthenticated={isAuthenticated()}
@@ -646,31 +642,13 @@ export default function SearchPage() {
{selectedJob && (
applyConfirmModalRef.current?.open()}
+ onApply={handleSingleApply}
ref={jobModalRef}
/>
)}
{/* Success Modal */}
-
- {/* Single-apply Confirmation Modal */}
- applyConfirmModalRef.current?.close()}
- onAddNow={goProfile}
- onSubmit={(payload) => {
- return applyToJob(applicationActions, selectedJob, payload).then(
- (response) => {
- if (!response.success) return alert(response.message);
- applyConfirmModalRef.current?.close();
- applySuccessModalRef.current?.open();
- return;
- },
- );
- }}
- />
>
);
}
diff --git a/components/EditForm.tsx b/components/EditForm.tsx
index 3f4cf342..8a593961 100644
--- a/components/EditForm.tsx
+++ b/components/EditForm.tsx
@@ -206,40 +206,46 @@ export function LabelWithTooltip({
);
}
-export const FormInput = ({
- label,
- value,
- setter,
- required = true,
- className,
- tooltip,
- tooltipId,
- labelAddon,
- ...props
-}: FormInputProps) => {
- return (
-
- {label && (
-
- )}
-
setter && setter(e.target.value)}
- className={cn(
- className,
- "placeholder:text-gray-400 placeholder:italic focus:placeholder:text-primary/70",
+export const FormInput = React.forwardRef
(
+ function FormInput(
+ {
+ label,
+ value,
+ setter,
+ required = true,
+ className,
+ tooltip,
+ tooltipId,
+ labelAddon,
+ ...props
+ },
+ ref,
+ ) {
+ return (
+
+ {label && (
+
)}
- {...props}
- />
-
- );
-};
+ setter && setter(e.target.value)}
+ className={cn(
+ className,
+ "placeholder:text-gray-400 placeholder:italic focus:placeholder:text-primary/70",
+ )}
+ {...props}
+ />
+
+ );
+ },
+);
/**
* Big input
diff --git a/components/features/hire/chat/ConversationPage.tsx b/components/features/hire/chat/ConversationPage.tsx
deleted file mode 100644
index cd66a32a..00000000
--- a/components/features/hire/chat/ConversationPage.tsx
+++ /dev/null
@@ -1,902 +0,0 @@
-import { useConversation, useConversations } from "@/hooks/use-conversation";
-import { useAuthContext } from "@/lib/ctx-auth";
-import { Card } from "@/components/ui/card";
-import { EmployerPfp, UserPfp } from "@/components/shared/pfp";
-import { ChevronLeft,
- ChevronRight,
- SendHorizonal,
- Plus,
- CircleEllipsis,
- FileUser,
- Calendar,
- ContactRound,
- GraduationCap,
- ListCheck,
- MessageCircle,
- School,
- FileText,
- Award,
- } from "lucide-react";
-import { cn } from "@/lib/utils";
-import { useAppContext } from "@/lib/ctx-app";
-import { Ref, useEffect, useMemo, useRef, useState, useCallback } from "react";
-import { Textarea } from "@/components/ui/textarea";
-import { Message } from "@/components/ui/messages";
-import { Button } from "@/components/ui/button";
-import { EmployerConversationService, UserService } from "@/lib/api/services";
-import { Loader } from "@/components/ui/loader";
-import { useProfile, useEmployerApplications } from "@/hooks/use-employer-api";
-import { useUserName, getUserById } from "@/hooks/use-student-api";
-import { Badge } from "@/components/ui/badge";
-import { getFullName } from "@/lib/profile";
-import { FilterButton } from "@/components/ui/filter";
-import { EmployerApplication } from "@/lib/db/db.types";
-import { useDbRefs } from "@/lib/db/use-refs";
-import { fmtISO } from "@/lib/utils/date-utils";
-import { useModal } from "@/hooks/use-modal";
-import { PDFPreview } from "@/components/shared/pdf-preview";
-import { useFile } from "@/hooks/use-file";
-import { useRouter } from "next/navigation";
-import { motion, AnimatePresence } from "framer-motion";
-
-
-interface ConversationProps {
- applicantId?: string;
- chatId?:string;
-};
-
-interface InternshipPreferences {
- expected_duration_hours?: number,
- expected_start_date?: number,
- internship_type?: string,
- job_category_ids?: string[],
- job_commitment_ids?: string[],
- job_setup_ids?: string[],
-}
-interface Message {
- sender_id: string;
- message: string;
- timestamp: string;
-}
-
-interface Conversation {
- messages: Message[];
- senderId: string;
- loading: boolean;
- unsubscribe: Function;
-}
-
-export function ConversationPage({
- applicantId,
- chatId,
-} : ConversationProps) {
- const { redirectIfNotLoggedIn } = useAuthContext();
- const profile = useProfile();
- const conversations = useConversations();
- const applications = useEmployerApplications();
- const { unreads } = useConversations();
- const { isMobile } = useAppContext();
- const router = useRouter();
-
- // selection + message composing state
- const [conversationId, setConversationId] = useState("");
- const conversation = useConversation("employer", conversationId);
- const [message, setMessage] = useState("");
- const [sending, setSending] = useState(false);
-
- //for filtering
- const [chatFilter, setChatFilter] = useState<"all" | "unread">("all")
-
- // mobile "router": list or chat
- const [mobileView, setMobileView] = useState<"list" | "chat" | "profile">("list");
-
- // open and close profile view
- const [profileView, setProfileView] = useState(false);
- const [ellipsisState, setEllipsisState] = useState(true);
-
- // checking if user data jis fully loaded
- const [userDataLoading, setUserDataLoading] = useState(true);
-
- // anchor for autoscroll
- const chatAnchorRef = useRef(null);
-
- //redirectIfNotLoggedIn();
-
- // auto-switch to chat view on mobile when a conversation is selected
- useEffect(() => {
- if (isMobile && conversationId) setMobileView("chat");
- }, [isMobile, conversationId]);
-
- // unsubscribe on conversation change (keeps your original behavior)
- useEffect(() => {
- if (conversationId) {
- conversation.unsubscribe();
- }
- }, [conversationId]);
-
- useEffect(() => {
- if(conversationId && ellipsisState) {
- setProfileView(true);
- }
- }, [conversationId]);
-
- //scroll to bottom of the chat if messages exist
- useEffect(() => {
- if (conversationId && !conversation.loading && conversation.messages?.length) {
- chatAnchorRef.current?.scrollIntoView({ behavior: "instant" });
- }
- }, [conversationId, conversation.messages?.length, conversation.loading]);
-
- useEffect(() => {
- if(chatId) {
- setTimeout(() => {
- setConversationId(chatId);
- }, 300);
- router.replace('/conversations', { scroll: false });
- }
- }, [chatId])
-
-
- const sortedConvos = useMemo(
- () =>
- (conversations.data?.filter((c, index, self) => (c?.subscribers?.length > 1) &&
- index === self.findIndex((ca) => (
- ca.id === c.id
- )))
- ?? []).toSorted(
- (a, b) =>
- (b.last_unread?.timestamp ?? 0) - (a.last_unread?.timestamp ?? 0)
- ),
- [conversations.data],
- );
-
- const sortedUnreads = useMemo(
- () =>
- (unreads.filter((c, index, self) => (c?.subscribers?.length > 1) &&
- index === self.findIndex((ca) => (
- ca.id === c.id
- ))) ?? []).toSorted(
- (a, b) =>
- (b.last_unread?.timestamp ?? 0) - (a.last_unread?.timestamp ?? 0),
- ),
- [unreads],
- );
-
- const uniqueChats = useMemo(() => {
- return chatFilter === "all" ? sortedConvos : sortedUnreads
- }, [chatFilter, sortedConvos, unreads]);
-
- const visibleConvos = Array.from(new Set(uniqueChats));
-
- const endSend = () => {
- setMessage("");
- setSending(false);
- chatAnchorRef.current?.scrollIntoView({ behavior: "smooth" });
- };
-
- // Handle message
- const handleMessage = async (studentId: string | undefined, message: string) => {
- if (message.trim() === "") return;
-
- setSending(true);
- let userConversation = conversations.data?.find((c) =>
- c?.subscribers?.includes(studentId));
-
- if (!userConversation && studentId) {
- const response =
- await EmployerConversationService.createConversation(studentId).catch(
- endSend,
- );
-
- if (!response?.success) {
- alert("Could not initiate conversation with user.");
- endSend();
- return;
- }
-
- setConversationId(response.conversation?.id ?? "");
- userConversation = response.conversation;
- }
-
- setTimeout(async () => {
- if (!userConversation) return endSend();
- await EmployerConversationService.sendToUser(
- userConversation?.id,
- message,
- ).catch(endSend);
- endSend();
- });
- };
-
- const hasConversations = (conversations.data?.length ?? 0) > 0;
-
- // determine if all user data is loaded before loading entire page
- useEffect(() => {
- if (!conversations.data && hasConversations) {
- setUserDataLoading(true);
- return;
- }
-
- const allUsersLoaded = visibleConvos.every((convo) => {
- const userId = convo?.subscribers?.find(
- (id: string) => id !== profile.data?.id
- );
- return userId;
- });
-
- if (allUsersLoaded) {
- const timer = setTimeout(() => {
- setUserDataLoading(false);
- }, 200);
- return () => clearTimeout(timer);
- }
- }, [conversations.data, visibleConvos, profile.data?.id]);
-
- return (
- <>
- {userDataLoading ? (
-
-
-
-
Loading Conversations...
-
-
- ) : (
-
- {hasConversations ? (
- <>
- {/* ===== Left: List (Desktop always visible; Mobile only when in "list" view) ===== */}
-
-
- setChatFilter(status as "all" | "unread")}
- />
-
-
- { visibleConvos.length ? (
-
setConversationId(id)}
- currId={conversationId}
- />
- ) : (
-
-
No unread conversation.
-
- )
- }
-
-
-
- {/* ===== Right: Chat (Desktop always visible; Mobile only when in "chat" view) ===== */}
-
-
-
- {/* Chat body */}
- {conversation?.loading ? (
-
-
-
-
Loading Conversation...
-
-
- ) : conversationId ? (
- <>
- {/*top bar */}
-
-
- {isMobile && (
- {
- setMobileView("list");
- setConversationId("");
- }}
- aria-label="Back to conversations"
- >
-
-
- )}
-
-
-
{
- setEllipsisState(!ellipsisState);
- setProfileView(!profileView);
- if (isMobile && !profileView) {
- setMobileView("profile");
- }
- }}
- className="inline-flex h-12 w-12 items-center justify-center rounded-md hover:bg-gray-100 text-gray-500 mt-1 shrink-0">
-
-
-
-
-
-
- {
- handleMessage(conversation.senderId, message)
- }}
- />
- >
- ) : (
-
- )}
-
-
- {profileView &&
-
- {isMobile && (
-
- {
- setMobileView("chat");
- setConversationId(conversationId);
- setProfileView(false);
- }}
- aria-label="Back to previous conversation"
- >
-
-
-
- )}
-
-
-
-
-
-
- }
- >
- ) : (
-
- )}
-
- )}
- >
- );
- }
-
- /* ======================= Subcomponents ======================= */
-
- function ConversationFilter({
- conversations,
- unreadConvosCount,
- status,
- onFilterChange,
- }:
- {
- conversations: any[];
- unreadConvosCount: number;
- status: string;
- onFilterChange: (status: string) => void;}) {
-
- return (
-
- {
- onFilterChange("all")
- }}
- className="text-sm rounded-full">
-
- {
- onFilterChange("unread")
- }}
- className="text-sm rounded-full"
- >
-
-
- );
- }
-
- function ConversationProfile({
- conversation,
- profileId,
- applications,
- }: {
- conversation: any;
- profileId: string;
- applications?: EmployerApplication[];
- }) {
- const { isMobile } = useAppContext();
- const { user } = getUserById(conversation.senderId || "")
- const userName = getFullName(user || undefined)
- const userId = user?.id
- const userApplications = applications?.filter(a => profileId === a.user_id)
-
- const { to_university_name } = useDbRefs();
- const preferences = (user?.internship_preferences || {}) as InternshipPreferences
-
- const {
- open: openResumeModal,
- close: closeResumeModal,
- Modal: ResumeModal,
- } = useModal("resume-modal");
-
- const router = useRouter();
-
- const { url: resumeURL, sync: syncResumeURL } = useFile({
- fetcher: useCallback(
- async () =>
- await UserService.getUserResumeURL(user?.id ?? ""),
- [user?.id],
- ),
- route: user
- ? `/users/${user?.id}/resume`
- : "",
- });
-
- useEffect(() => {
- if (user?.id) {
- syncResumeURL();
- }
- }, [user?.id, syncResumeURL]);
-
- return(
- <>
- {user ? (
- <>
-
-
-
{userName || "User"}
- {preferences?.internship_type &&
-
-
-
- Credited internship
-
-
- }
-
-
-
-
-
- Resume
-
-
- router.push(`dashboard/applicant?userId=${user?.id}`)
- }
- >
- View Full Application
-
-
-
-
-
-
- {to_university_name(user?.university) || ""}
-
-
-
-
-
- {user?.degree}
-
-
-
-
-
- {preferences.expected_start_date ? (
- <>
- {fmtISO(preferences.expected_start_date.toString())}
- >
- ) : (
- No start date provided
- )}
-
-
-
-
-
Applied for:
- {userApplications?.map((a) =>
-
- {a.job?.title}
- {a !== userApplications?.at(-1) && <>, >}
-
- )}
-
- {/*
-
- Delete Conversation
-
-
*/}
-
-
-
- {user?.resume ? (
-
-
- {getFullName(user)} - Resume
-
-
-
- ) : (
-
-
-
-
- No Resume Available
-
-
- This applicant has not uploaded a resume yet.
-
-
-
- )}
-
- >
- ) : (
-
-
-
-
Loading Applicant...
-
-
- )}
- >
- )
- }
-
- function ConversationList({
- conversations,
- unreadConversations,
- profileId,
- onPick,
- currId,
- }: {
- conversations: any[];
- unreadConversations: any[];
- profileId?: string;
- onPick: (id: string) => void;
- currId: string;
- }) {
-
- return (
-
- {conversations.map((c, index) => (
- unreadConv.id === c.id)
- }
- isPicked={currId === c.id}
- />
- ))}
-
- );
- }
-
- function ChatHeaderTitle({ conversation, applications }: { conversation?: any, applications?:EmployerApplication[] }) {
- const { userName } = useUserName(conversation.senderId || "")
- const userApplications = applications?.filter(a => conversation.senderId === a.user_id)
- return (
-
-
- {userName || "Conversations"}
-
-
-
Applied for:
- {userApplications?.map((a) =>
-
- {a.job?.title}
- {a !== userApplications?.at(-1) && <>, >}
-
- )}
-
-
- );
- }
-
-
-
- function ComposerBar({
- value,
- onChange,
- onSend,
- disabled,
- }: {
- value: string;
- onChange: (v: string) => void;
- onSend: () => void;
- disabled?: boolean;
- }) {
- return (
-
- );
- }
-
- function EmptyChatHint() {
- return (
-
-
- Welcome to your conversations!
-
- Click on a conversation to start chatting.
-
-
-
- );
- }
-
- function NoConversationsEmptyState() {
- const { isMobile } = useAppContext();
- return (
-
-
-
-
- BetterInternship
-
-
- Better Internships Start Here
-
-
-
- You currently don't have any conversations.
-
-
-
- );
- }
-
- const ConversationPane = ({
- conversation,
- chatAnchorRef,
- }: {
- // keep "any" per your note; consider adding a strong type later
- conversation: Conversation;
- chatAnchorRef: Ref;
- }) => {
- const { isMobile } = useAppContext()
- const profile = useProfile();
- const { userName } = useUserName(conversation.senderId);
- let lastSelf = false;
-
- return (
- <>
- {conversation?.loading ? (
-
-
-
-
Loading Conversation...
-
-
- ) : (
-
-
- {conversation?.messages?.length ? (
- <>
- {conversation.messages
- ?.map((message: any, idx: number) => {
- if (!idx) lastSelf = false;
- const oldLastSelf = lastSelf;
- lastSelf = message.sender_id === profile.data?.id;
- return {
- key: idx,
- message: message.message,
- self: message.sender_id === profile.data?.id,
- prevSelf: oldLastSelf,
- them: userName,
- };
- })
- ?.toReversed()
- ?.map((d: any) => (
-
- ))}
- >
- ) : (
-
-
-
-
No Messages Yet
-
Start the conversation!
-
-
- )}
-
- )}
- >
- );
- };
-
- const ConversationCard = ({
- conversation,
- latestIsYou,
- latestMessage,
- setConversationId,
- isUnread,
- isPicked,
- index = 0,
- }: {
- conversation: any; // consider replacing with a proper type
- latestIsYou: boolean;
- latestMessage: string;
- setConversationId: (id: string) => void;
- isUnread?: boolean;
- isPicked: boolean;
- index?: number;
- }) => {
- const profile = useProfile();
- const [userId, setUserId] = useState("");
-
- const MAX_STAGGER_ROWS = 50;
- const staggerDelay = index < MAX_STAGGER_ROWS ? index * 0.05 : 0;
-
- useEffect(() => {
- setUserId(
- conversation?.subscribers?.find(
- (subscriberId: string) => subscriberId !== profile.data?.id,
- ) ?? "",
- );
- }, [conversation, profile.data?.id]);
-
- const { userName } = useUserName(userId);
- const name = userName.trim().split(" ");
- const surname = name[name.length - 1];
-
- return (
- setConversationId(conversation.id)}
- >
-
-
- {userName &&
}
-
-
- {userName ?? "Conversation"}
-
-
- {latestMessage ? ((latestIsYou ? "You: " : surname + ": ") + (latestMessage ?? "")) :
- ("No message history")}
-
-
-
- {isUnread ? (
-
- ):(
- <>>
- )}
-
-
- );
-};
diff --git a/components/features/hire/content-layout.tsx b/components/features/hire/content-layout.tsx
index dc65b626..876af125 100644
--- a/components/features/hire/content-layout.tsx
+++ b/components/features/hire/content-layout.tsx
@@ -1,11 +1,9 @@
"use client";
-import { useConversation, useConversations } from "@/hooks/use-conversation";
import { useMobile } from "@/hooks/use-mobile";
-import { LayoutDashboard, Plus, MessageCircleMore, FileText, FileUser, Briefcase, MessageCircleQuestion, HelpCircle } from "lucide-react";
+import { Plus, Briefcase, HelpCircle } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
-import { useMemo } from "react";
import React from "react";
import { useAuthContext } from "@/app/hire/authctx";
@@ -29,11 +27,6 @@ const navItems: NavItem[] = [
icon: ,
label: "Job listings",
},
- // {
- // href: "/conversations",
- // icon: ,
- // label: "Chats",
- // },
{
href: "/help",
icon: ,
@@ -44,23 +37,10 @@ const navItems: NavItem[] = [
function SideNav({ items }: { items: NavItem[] }) {
const pathname = usePathname();
const { god } = useAuthContext();
- const { unreads } = useConversations();
-
- const sortedUnreads = useMemo(
- () =>
- (unreads.filter((c, index, self) => (c?.subscribers?.length > 1) &&
- index === self.findIndex((ca) => (
- ca.id === c.id
- ))) ?? []).toSorted(
- (a, b) =>
- (b.last_unread?.timestamp ?? 0) - (a.last_unread?.timestamp ?? 0),
- ),
- [unreads],
- );
return (
- {items.map(({ href, label, icon}) => {
+ {items.map(({ href, label, icon }) => {
const isActive = pathname.includes(href);
return (
@@ -77,33 +57,19 @@ function SideNav({ items }: { items: NavItem[] }) {
className={cn(
"w-full h-10 pl-4 flex flex-row justify-between border-0 hover:bg-primary/15 hover:text-primary",
isActive ? "text-primary bg-primary/10" : "font-normal",
- label === "Add Listing" ? "bg-primary text-white hover:bg-primary hover:text-white" : "",
- isActive && "[&_svg]:fill-primary/20"
+ label === "Add Listing"
+ ? "bg-primary text-white hover:bg-primary hover:text-white"
+ : "",
+ isActive && "[&_svg]:fill-primary/20",
)}
>
-
- {icon}
- {
- (label === "Chats" && unreads.length > 0) &&
-
- }
-
-
- {label}
-
-
-
- {
- (label === "Chats" && unreads.length > 0) &&
-
- {unreads.length}
-
- }
+
{icon}
+
{label}
- )
+ );
})}
);
@@ -114,29 +80,30 @@ interface ContentLayoutProps {
className?: string;
}
-const ContentLayout: React.FC = ({ children, className }) => {
+const ContentLayout: React.FC = ({
+ children,
+ className,
+}) => {
const { isMobile } = useMobile();
-
+
return (
{!isMobile ? (
<>
-
+
>
) : (
<>>
- )}
-
+ )}
+
{children}
diff --git a/components/features/hire/dashboard/ApplicantPage.tsx b/components/features/hire/dashboard/ApplicantPage.tsx
index ac657608..481fd717 100644
--- a/components/features/hire/dashboard/ApplicantPage.tsx
+++ b/components/features/hire/dashboard/ApplicantPage.tsx
@@ -11,7 +11,10 @@ import { EmployerApplication, PublicUser } from "@/lib/db/db.types";
import { useDbRefs } from "@/lib/db/use-refs";
import { getFullName } from "@/lib/profile";
import { cn } from "@/lib/utils";
-import { formatMonth, formatTimestampDate } from "@/lib/utils/date-utils";
+import {
+ formatMonth,
+ formatOptionalTimestampDate,
+} from "@/lib/utils/date-utils";
import {
ArrowLeft,
Award,
@@ -27,7 +30,7 @@ import {
ArchiveRestore,
} from "lucide-react";
import { useRouter } from "next/navigation";
-import { useCallback, useEffect, useState, useRef } from "react";
+import { useCallback, useEffect, useState, useMemo } from "react";
import { Divider } from "@/components/ui/divider";
import { Badge } from "@/components/ui/badge";
import {
@@ -43,7 +46,6 @@ import StatusBadge from "@/components/ui/status-badge";
interface ApplicantPageProps {
application: EmployerApplication | undefined;
- jobID?: string | undefined;
userApplications?: EmployerApplication[] | undefined;
statuses: ActionItem[];
onArchive?: () => void;
@@ -52,7 +54,6 @@ interface ApplicantPageProps {
export function ApplicantPage({
application,
- jobID,
userApplications,
statuses,
onArchive,
@@ -94,10 +95,17 @@ export function ApplicantPage({
} = useFile({
fetcher: useCallback(
async () =>
- await UserService.getUserResumeURL(application?.user_id ?? ""),
- [user?.id],
+ await UserService.getUserResumeURL(
+ application?.user_id ?? "",
+ application?.resume_id ?? "",
+ ),
+ [application?.user_id, application?.resume_id],
+ ),
+ route: useMemo(
+ () =>
+ application ? `/users/${user?.id}/resume/${application.resume_id}` : "",
+ [application?.user_id, application?.resume_id],
),
- route: application ? `/users/${user?.id}/resume` : "",
});
const handleBack = () => {
@@ -111,9 +119,9 @@ export function ApplicantPage({
useEffect(() => {
if (application?.user_id) {
- syncResumeURL();
+ void syncResumeURL();
}
- }, [application?.user_id, syncResumeURL]);
+ }, [application?.user_id, application?.resume_id, syncResumeURL]);
if (loading || resumeLoading) {
return Getting applicant information... ;
@@ -433,7 +441,7 @@ export function ApplicantPage({
Expected Start Date
- {formatTimestampDate(
+ {formatOptionalTimestampDate(
internshipPreferences?.expected_start_date,
)}
@@ -499,7 +507,7 @@ export function ApplicantPage({
Applicant Information
- {jobID && (
+ {application?.job && (
Applying for: {application?.job?.title}
@@ -550,7 +558,7 @@ export function ApplicantPage({
Expected Start Date
- {formatTimestampDate(
+ {formatOptionalTimestampDate(
internshipPreferences?.expected_start_date,
)}
@@ -569,7 +577,7 @@ export function ApplicantPage({
{/* other roles *note: will make this look better */}
- {jobID ? (
+ {application?.job ? (
Other Applied Roles
@@ -590,7 +598,7 @@ export function ApplicantPage({
))
) : (
<>
- {jobID ? (
+ {application?.job ? (
{" "}
No applied roles
@@ -610,7 +618,7 @@ export function ApplicantPage({
{/* resume */}
- {application?.user?.resume ? (
+ {application?.resume_id ? (
void;
- updateConversationId: (userId: string) => void;
onApplicationClick: (application: EmployerApplication) => void;
setSelectedApplication: (application: EmployerApplication) => void;
onAction: (
@@ -64,7 +60,6 @@ export const ApplicationsContent = forwardRef<
isSuperListing = false,
isLoading,
openChatModal,
- updateConversationId,
onApplicationClick,
setSelectedApplication,
onAction,
@@ -146,33 +141,6 @@ export const ApplicationsContent = forwardRef<
};
});
};
- const dummyStatuses = app_statuses
- .filter((status) => status.id !== 7 && status.id !== 5 && status.id !== 0)
- .map((status): ActionItem => {
- const config = DB_STATUS_MAP[status.id];
- const filterKey =
- config?.key || (status.name.toLowerCase() as ApplicationFilter);
- const uiProps = UI_STATUS_MAP.get(filterKey);
-
- return {
- id: status.id.toString(),
- label: status.name,
- icon: uiProps?.icon,
- onClick: () => {},
- destructive: uiProps?.destructive,
- bgColor: uiProps?.bgColor,
- fgColor: uiProps?.fgColor,
- };
- });
- const dummyDefaultStatus: ActionItem = {
- id: "0",
- label: get_app_status(0)?.name,
- active: true,
- disabled: false,
- destructive: false,
- highlighted: true,
- highlightColor: UI_STATUS_MAP.get("pending")?.bgColor,
- };
const applyActiveFilter = (apps: typeof sortedApplications) => {
if (activeFilter === "archived")
@@ -203,17 +171,6 @@ export const ApplicationsContent = forwardRef<
(application) => application.status !== undefined,
);
- const showSuperDummyApplication =
- isSuperListing &&
- visibleApplications.length === 0 &&
- SHOW_SUPER_DUMMY_APPLICATION;
- const openDummyApplicantProfile = () => {
- const query = selectedJobId
- ? `?dummy=1&jobId=${selectedJobId}`
- : "?dummy=1";
- router.push(`/dashboard/applicant${query}`);
- };
-
const {
selectedApplications,
toggleSelect,
@@ -367,31 +324,12 @@ export const ApplicationsContent = forwardRef<
}
}}
setSelectedApplication={setSelectedApplication}
- updateConversationId={updateConversationId}
checkboxSelected={selectedApplications.has(application.id!)}
onToggleSelect={(v) => toggleSelect(application.id!, v)}
onAction={onAction}
statuses={getRowStatuses(application)}
/>
))
- ) : showSuperDummyApplication ? (
-
-
-
Sample Applicant
- Pending
-
-
-
Submission
-
- This is a dummy super listing submission preview used for layout
- checks. Remove it by setting SHOW_SUPER_DUMMY_APPLICATION to
- false.
-
-
-
) : (
No applications under this category.
@@ -520,61 +458,12 @@ export const ApplicationsContent = forwardRef<
}
}}
setSelectedApplication={setSelectedApplication}
- updateConversationId={updateConversationId}
checkboxSelected={selectedApplications.has(application.id!)}
onToggleSelect={(v) => toggleSelect(application.id!, v)}
onAction={onAction}
statuses={getRowStatuses(application)}
/>
))
- ) : showSuperDummyApplication ? (
- <>
-
- e.stopPropagation()}
- >
- {}}
- disabled={true}
- />
-
- Sample Applicant
- Mar 9, 2026
- e.stopPropagation()}
- >
-
-
-
-
-
-
-
-
-
- Submission
-
-
- This is a dummy super listing submission preview used
- for layout checks. Remove it by setting
- SHOW_SUPER_DUMMY_APPLICATION to false.
-
-
-
-
- >
) : (
diff --git a/components/features/hire/dashboard/JobTabs.tsx b/components/features/hire/dashboard/JobTabs.tsx
index 00a205da..9f78f1db 100644
--- a/components/features/hire/dashboard/JobTabs.tsx
+++ b/components/features/hire/dashboard/JobTabs.tsx
@@ -40,11 +40,14 @@ export default function JobTabs({ selectedJob }: JobTabsProps) {
const { url: resumeURL, sync: syncResumeURL } = useFile({
fetcher: useCallback(
async () =>
- await UserService.getUserResumeURL(selectedApplication?.user_id ?? ""),
- [selectedApplication?.user_id],
+ await UserService.getUserResumeURL(
+ selectedApplication?.user_id ?? "",
+ selectedApplication?.resume_id ?? "",
+ ),
+ [selectedApplication?.user_id, selectedApplication?.resume_id],
),
route: selectedApplication
- ? `/users/${selectedApplication.user_id}/resume`
+ ? `/users/${selectedApplication.user_id}/resume/${selectedApplication.resume_id}`
: "",
});
@@ -80,9 +83,7 @@ export default function JobTabs({ selectedJob }: JobTabsProps) {
const handleApplicationClick = (application: EmployerApplication) => {
setSelectedApplication(application); // set first
- router.push(
- `/dashboard/applicant?userId=${application?.user_id}&jobId=${selectedJobId}`,
- );
+ router.push(`/dashboard/applicant?applicationId=${application.id}`);
};
if (isLoading || !isAuthenticated()) return null;
diff --git a/components/features/hire/god/ui.tsx b/components/features/hire/god/ui.tsx
index 4219c7e6..dddccd00 100644
--- a/components/features/hire/god/ui.tsx
+++ b/components/features/hire/god/ui.tsx
@@ -1,27 +1,13 @@
"use client";
-import React, {
- ReactNode,
- useCallback,
- useEffect,
- useMemo,
- useState,
-} from "react";
+import React, { ReactNode } from "react";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
-import {
- MoreHorizontal,
- X,
- Plus,
- Filter,
- Search,
- ChevronDown,
-} from "lucide-react";
-import { cn } from "@/lib/utils";
+import { MoreHorizontal } from "lucide-react";
/** Page section with a top toolbar and a bordered list container */
export function ListShell({
@@ -137,195 +123,6 @@ export function RowCard(props: {
);
}
-/* ===========================================================
- Tag utilities (frontend-only, localStorage persistence)
- =========================================================== */
-
-export type TagMap = Record;
-
-/** Local, namespaced tag map with persistence */
-export function useLocalTagMap(storageKey: string) {
- const [tagMap, setTagMap] = useState({});
-
- // load once
- useEffect(() => {
- try {
- const raw = localStorage.getItem(storageKey);
- if (raw) setTagMap(JSON.parse(raw));
- } catch (e) {
- console.warn("[useLocalTagMap] failed to parse storage", e);
- }
- }, [storageKey]);
-
- // persist
- useEffect(() => {
- try {
- localStorage.setItem(storageKey, JSON.stringify(tagMap));
- } catch (e) {
- console.warn("[useLocalTagMap] failed to save storage", e);
- }
- }, [storageKey, tagMap]);
-
- const sanitize = (t: string) => t.trim().replace(/\s+/g, " ");
-
- const addTag = useCallback((id: string, raw: string) => {
- const tag = sanitize(raw);
- if (!tag) return;
- setTagMap((prev) => {
- const cur = prev[id] ?? [];
- if (cur.includes(tag)) return prev;
- return { ...prev, [id]: [...cur, tag] };
- });
- }, []);
-
- const removeTag = useCallback((id: string, tag: string) => {
- setTagMap((prev) => {
- const cur = prev[id] ?? [];
- return { ...prev, [id]: cur.filter((t) => t !== tag) };
- });
- }, []);
-
- const clearTags = useCallback((id: string) => {
- setTagMap((prev) => {
- const next = { ...prev };
- delete next[id];
- return next;
- });
- }, []);
-
- const allTags = useMemo(() => {
- const set = new Set();
- Object.values(tagMap).forEach((arr) => arr?.forEach((t) => set.add(t)));
- return Array.from(set).sort((a, b) => a.localeCompare(b));
- }, [tagMap]);
-
- return { tagMap, setTagMap, addTag, removeTag, clearTags, allTags };
-}
-
-/** Small tag pill */
-export function TagPill({
- label,
- onRemove,
- onClick,
- active = false,
- className,
-}: {
- label: string;
- onRemove?: () => void;
- onClick?: () => void;
- active?: boolean;
- className?: string;
-}) {
- return (
- {
- e.stopPropagation();
- onClick?.();
- }}
- className={cn(
- "inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs",
- active ? "bg-slate-800 text-white border-slate-800" : "text-slate-700",
- onClick && "cursor-pointer hover:bg-slate-100",
- className,
- )}
- >
- {label}
- {onRemove ? (
- {
- e.stopPropagation(); // already present for remove safety
- onRemove();
- }}
- className="ml-0.5 -mr-0.5 rounded hover:bg-black/10 p-[2px] transition-all"
- aria-label={`Remove ${label}`}
- >
-
-
- ) : null}
-
- );
-}
-
-/** Inline editor shown under a row: existing tags + input to add new */
-export function EditableTags({
- id,
- tags,
- onAdd,
- onRemove,
- suggestions = [],
- placeholder = "add tag…",
-}: {
- id: string;
- tags: string[];
- onAdd: (id: string, tag: string) => void;
- onRemove: (id: string, tag: string) => void;
- suggestions?: string[];
- placeholder?: string;
-}) {
- const [draft, setDraft] = useState("");
- const remaining = suggestions
- .filter(
- (t) => !tags.includes(t) && t.toLowerCase().includes(draft.toLowerCase()),
- )
- .slice(0, 6);
-
- const commit = () => {
- const t = draft.trim();
- if (t) onAdd(id, t);
- setDraft("");
- };
-
- return (
- e.stopPropagation()}
- onMouseDown={(e) => e.stopPropagation()}
- onKeyDown={(e) => e.stopPropagation()}
- >
-
-
- {draft && remaining.length > 0 ? (
-
- {remaining.map((t) => (
- {
- onAdd(id, t);
- setDraft("");
- }}
- />
- ))}
-
- ) : null}
-
- );
-}
-
/** Compact summary chip: "Students · 42 (showing 17)" + extras */
export function ListSummary({
label,
@@ -363,159 +160,3 @@ export function ListSummary({
);
}
-
-/** Toolbar filter bar with ANY/ALL toggle + popover picker */
-export function TagFilterBar({
- allTags,
- active,
- onToggle,
- onClear,
- mode,
- setMode,
-}: {
- allTags: string[];
- active: string[];
- onToggle: (tag: string) => void;
- onClear: () => void;
- mode: "any" | "all";
- setMode: (m: "any" | "all") => void;
-}) {
- const [open, setOpen] = React.useState(false);
- const [q, setQ] = React.useState("");
-
- const filtered = React.useMemo(
- () => allTags.filter((t) => t.toLowerCase().includes(q.toLowerCase())),
- [allTags, q],
- );
-
- const visibleActive = active.slice(0, 4);
- const restCount = Math.max(0, active.length - 4);
-
- return (
-
- {/* Label + segmented mode */}
-
-
-
Tags
-
- {/* Segmented ANY / ALL */}
-
- setMode("any")}
- >
- ANY
-
- setMode("all")}
- >
- ALL
-
-
-
-
- {/* Popover selector */}
-
-
-
-
- Select tags
- {active.length > 0 && (
-
- {active.length}
-
- )}
-
-
-
-
- {/* Search */}
-
-
- setQ(e.target.value)}
- placeholder="Find a tag…"
- className="h-8 w-full rounded border pl-8 pr-2 text-sm outline-none focus:ring-1 focus:ring-slate-300"
- />
-
-
- {/* List */}
-
- {filtered.length === 0 ? (
-
No tags
- ) : (
-
- )}
-
-
- {/* Footer actions */}
-
- {
- onClear();
- setQ("");
- }}
- disabled={active.length === 0}
- >
- Clear
-
- setOpen(false)}>
- Close
-
-
-
-
-
- {/* Active chips */}
-
- {active.length === 0 ? (
- allTags.length === 0 ? (
- no tags yet
- ) : null
- ) : (
- <>
- {visibleActive.map((t) => (
- onToggle(t)} />
- ))}
- {restCount > 0 && (
- setOpen(true)}
- className="bg-slate-100"
- />
- )}
- >
- )}
-
-
- );
-}
diff --git a/components/features/hire/header.tsx b/components/features/hire/header.tsx
index 0534a0f5..8dc64a83 100644
--- a/components/features/hire/header.tsx
+++ b/components/features/hire/header.tsx
@@ -21,7 +21,6 @@ import Link from "next/link";
import { MyEmployerPfp } from "@/components/shared/pfp";
import { useProfile } from "@/hooks/use-employer-api";
import { useMobile } from "@/hooks/use-mobile";
-import { useConversations } from "@/hooks/use-conversation";
import { MyUserPfp } from "@/components/shared/pfp";
import { Separator } from "@/components/ui/separator";
@@ -134,14 +133,6 @@ export const ProfileButton = () => {
>
}
>
- {/*
-
- Company Profile
- */}
- {/*
-
- Manage Accounts
- */}
Sign Out
@@ -173,7 +164,6 @@ function MobileDrawer({
}) {
const profile = useProfile();
const { isAuthenticated, logout } = useAuthContext();
- const conversations = useConversations();
const router = useRouter();
const pathname = usePathname();
const params = useSearchParams();
@@ -262,46 +252,6 @@ function MobileDrawer({
- {/*
-
-
-
-
- Forms Automation
-
-
-
-
- */}
- {/* {isAuthenticated() && (
-
-
-
-
- Chats
-
- {conversations?.unreads?.length ? (
-
- {conversations.unreads.length}
-
- ) : (
-
- )}
-
-
-
- )} */}
- {/*
-
-
-
-
- Company Profile
-
-
-
-
- */}
diff --git a/components/features/student/forms/form-renderer.ctx.tsx b/components/features/student/forms/form-renderer.ctx.tsx
index 412c396e..e2010b3e 100644
--- a/components/features/student/forms/form-renderer.ctx.tsx
+++ b/components/features/student/forms/form-renderer.ctx.tsx
@@ -57,6 +57,7 @@ export interface IFormRendererContext {
// Setters
updateFormName: (newFormName: string) => void;
refreshPreviews: () => void;
+ updateWetSignatureMode: (enabled: boolean) => void;
updateFieldsWithParams: (newParams: Record) => void;
}
@@ -117,6 +118,7 @@ export const FormRendererContextProvider = ({
const [previewFields, setPreviewFields] = useState([]);
const [blocks, setBlocks] = useState[]>([]);
const [fields, setFields] = useState[]>([]);
+ const [wetSignatureMode, setWetSignatureMode] = useState(false);
const [params, setParams] = useState({});
const [previews, setPreviews] = useState>(
{},
@@ -188,9 +190,9 @@ export const FormRendererContextProvider = ({
const loadedFields = fm.getFieldsForClientService("initiator");
setFields(loadedFields);
- setBlocks(fm.getBlocksForClientService("initiator"));
+ setBlocks(getBlocksForCurrentMode(fm, wetSignatureMode));
setPreviewFields(fm.getFieldsForSigningService());
- }, [form]);
+ }, [form, wetSignatureMode]);
// Clear fields on refresh?
useEffect(() => {
@@ -225,6 +227,7 @@ export const FormRendererContextProvider = ({
document: { name: documentName, url: documentUrl },
updateFormName: (newFormName: string) => setFormName(newFormName),
refreshPreviews: refreshPreviews,
+ updateWetSignatureMode: (enabled: boolean) => setWetSignatureMode(enabled),
updateFieldsWithParams: (newParams: Record) => {
// Merge new params with existing ones (don't replace)
const mergedParams = { ...params, ...newParams };
@@ -242,6 +245,22 @@ export const FormRendererContextProvider = ({
);
};
+function getBlocksForCurrentMode(
+ formMetadata: FormMetadata,
+ wetSignatureMode: boolean,
+) {
+ const blocks = formMetadata.getBlocksForClientService(
+ wetSignatureMode ? undefined : "initiator",
+ );
+
+ if (!wetSignatureMode) return blocks;
+
+ return blocks.filter((block) => {
+ const field = block.field_schema ?? block.phantom_field_schema;
+ return !!field && field.type !== "signature";
+ });
+}
+
/**
* A preview of what the field will look like on the document.
*
diff --git a/components/features/student/forms/previewer.tsx b/components/features/student/forms/previewer.tsx
index 629ecde2..1ddf287e 100644
--- a/components/features/student/forms/previewer.tsx
+++ b/components/features/student/forms/previewer.tsx
@@ -44,6 +44,8 @@ interface FormPreviewPdfDisplayProps {
autoScrollToSelectedField?: boolean;
fieldErrors?: Record;
signingParties?: IFormSigningParty[];
+ wetSignatureMode?: boolean;
+ hiddenFieldNames?: string[];
}
const clamp = (value: number, min: number, max: number) =>
@@ -66,6 +68,8 @@ export const FormPreviewPdfDisplay = ({
autoScrollToSelectedField = true,
fieldErrors = {},
signingParties = [],
+ wetSignatureMode = false,
+ hiddenFieldNames = [],
}: FormPreviewPdfDisplayProps) => {
const { isMobile } = useAppContext();
const refs = useDbRefs();
@@ -99,9 +103,24 @@ export const FormPreviewPdfDisplay = ({
() => toPreviewFields(blocks ?? []),
[blocks],
);
+ const hiddenFieldNameSet = useMemo(
+ () => new Set(hiddenFieldNames.map(normalizePreviewFieldKey)),
+ [hiddenFieldNames],
+ );
+ const displayFields = useMemo(
+ () =>
+ wetSignatureMode
+ ? normalizedFields.filter(
+ (field) =>
+ field.type !== "signature" &&
+ !hiddenFieldNameSet.has(normalizePreviewFieldKey(field.field)),
+ )
+ : normalizedFields,
+ [hiddenFieldNameSet, normalizedFields, wetSignatureMode],
+ );
const fieldsByPage = useMemo(
- () => groupFieldsByPage(normalizedFields),
- [normalizedFields],
+ () => groupFieldsByPage(displayFields),
+ [displayFields],
);
const signingPartyLabelById = useMemo(() => {
const partyLabelById = new Map();
@@ -377,6 +396,7 @@ export const FormPreviewPdfDisplay = ({
fieldErrors={fieldErrors}
resolveDisplayValue={resolveDisplayValue}
signingPartyLabelById={signingPartyLabelById}
+ wetSignatureMode={wetSignatureMode}
/>
))}
@@ -402,6 +422,7 @@ interface PdfPageWithFieldsProps {
fieldErrors: Record;
resolveDisplayValue: (field: PreviewField, rawValue: unknown) => string;
signingPartyLabelById: Map;
+ wetSignatureMode: boolean;
}
const PdfPageWithFields = ({
@@ -421,6 +442,7 @@ const PdfPageWithFields = ({
fieldErrors,
resolveDisplayValue,
signingPartyLabelById,
+ wetSignatureMode,
}: PdfPageWithFieldsProps) => {
const containerRef = useRef(null);
const canvasRef = useRef(null);
@@ -591,13 +613,14 @@ const PdfPageWithFields = ({
const hasAssignedOwner = owner.length > 0;
const isOwnedByInitiator =
!hasAssignedOwner || owner === "initiator" || owner === "student";
+ const shouldDisplayValue = wetSignatureMode || isOwnedByInitiator;
const normalizedFieldName = normalizePreviewFieldKey(fieldName);
- const rawValue = isOwnedByInitiator
+ const rawValue = shouldDisplayValue
? (values[fieldName] ??
values[normalizedFieldName + ":default"] ??
values[normalizedFieldName])
: "";
- const valueStr = isOwnedByInitiator
+ const valueStr = shouldDisplayValue
? resolveDisplayValue(field, rawValue)
: "";
const isFilled = valueStr.trim().length > 0;
@@ -665,17 +688,17 @@ const PdfPageWithFields = ({
const hasFieldError = !!fieldErrors[fieldName];
const isFieldValid = isFilled && !hasFieldError;
- const borderColor = isOwnedByInitiator
+ const isClickable = wetSignatureMode || isOwnedByInitiator;
+ const borderColor = isClickable
? isFieldValid
? "#16a34a"
: "#dc2626"
: "#d1d5db";
- const fillColor = isOwnedByInitiator
+ const fillColor = isClickable
? isFieldValid
? "rgba(34, 197, 94, 0.2)"
: "rgba(239, 68, 68, 0.2)"
: "transparent";
- const isClickable = isOwnedByInitiator;
const ownerLabel = field.signing_party_id
? (signingPartyLabelById.get(field.signing_party_id) ??
"Unassigned")
diff --git a/components/features/student/header.tsx b/components/features/student/header.tsx
index 9e320469..f266c648 100644
--- a/components/features/student/header.tsx
+++ b/components/features/student/header.tsx
@@ -31,12 +31,8 @@ import { useAuthContext } from "@/lib/ctx-auth";
import { useHeaderContext } from "@/lib/ctx-header";
import { FormsNavigation } from "@/components/features/student/forms/FormsNavigation";
import { useProfileData } from "@/lib/api/student.data.api";
+import { hasFormsEnabledUniversity } from "@/lib/student-forms-access";
import { cn } from "@/lib/utils";
-import {
- isProfileBaseComplete,
- isProfileResume,
- isProfileVerified,
-} from "@/lib/profile";
import {
JobFilterProvider,
JobFilters,
@@ -180,20 +176,10 @@ export const HeaderButtons: React.FC = () => {
const router = useRouter();
const pathname = usePathname();
const handleLogout = () => logout().then(() => router.push("/"));
+ const showFormsTab = hasFormsEnabledUniversity(profile.data);
const handleProfileClick = useCallback(() => {
- console.log("current profile: ", profile);
-
- if (!isProfileVerified(profile.data)) {
- router.push(`/register/verify`);
- } else if (
- !isProfileResume(profile.data) ||
- !isProfileBaseComplete(profile.data)
- ) {
- router.push(`profile/complete-profile?dest=profile`);
- } else {
- router.push(`/profile`);
- }
+ router.push(`/profile`);
}, [router, profile]);
if (!isAuthenticated()) {
@@ -231,22 +217,23 @@ export const HeaderButtons: React.FC = () => {
- {/* Forms Button */}
-
-
-
- Forms
-
-
+ {showFormsTab && (
+
+
+
+ Forms
+
+
+ )}
{/* My Jobs Dropdown */}
= ({
initialFilterValues,
showFilters,
}) => {
- const pathname = usePathname();
-
return (
void;
+ onApply: (payload: ApplyPayload) => void | Promise
;
className?: string;
}) => {
const auth = useAuthContext();
+ const modalRegistry = useModalRegistry();
const jobs = useJobsData();
const applied = useMemo(() => !!jobs.isJobApplied(job.id!), [jobs]);
- const router = useRouter();
const isSuperListing = Boolean(job.challenge);
/**
@@ -40,24 +37,25 @@ export const ApplyToJobButton = ({
window.location.href = `${process.env.NEXT_PUBLIC_API_URL}/auth/google`;
return;
}
- if (!isProfileVerified(profile)) {
- return router.push("/register/verify");
- }
- if (
- !isProfileResume(profile) ||
- !isProfileBaseComplete(profile) ||
- profile.acknowledged_auto_apply === false
- ) {
- return router.push(`/profile/complete-profile?dest=search/${job.id}`);
+ if (applied) {
+ toast.error("You have already applied to this job!");
+ return;
}
- if (applied) {
- alert("You have already applied to this job!");
+ // Check if the profile meets the listing's requirements
+ const { eligible, missing } = isProfileEligibleForListing(profile, job);
+ if (!eligible) {
+ modalRegistry.missingRequirements.open({ missing });
return;
}
- openAppModal();
+ modalRegistry.completeProfileApply.open({
+ profile,
+ requiresCoverLetter:
+ job.internship_preferences?.require_cover_letter === true,
+ onApply,
+ });
};
return (
diff --git a/components/features/student/profile/AddResumeModal.tsx b/components/features/student/profile/AddResumeModal.tsx
new file mode 100644
index 00000000..48d19e27
--- /dev/null
+++ b/components/features/student/profile/AddResumeModal.tsx
@@ -0,0 +1,92 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import { Loader2 } from "lucide-react";
+import { useState } from "react";
+import { toast } from "sonner";
+import { toastPresets } from "@/components/ui/sonner-toast";
+import {
+ ResumeUploadFormFields,
+ useResumeUploadForm,
+} from "@/components/features/student/resume-parser/ResumeUploadForm";
+
+export function AddResumeModal({
+ onCancel,
+ onComplete,
+ isAtResumeLimit,
+}: {
+ onCancel: () => void;
+ onComplete: () => void;
+ isAtResumeLimit: boolean;
+}) {
+ const resumeUpload = useResumeUploadForm();
+ const [saving, setSaving] = useState(false);
+
+ const canUpload = resumeUpload.canUpload && !isAtResumeLimit;
+
+ async function handleUpload() {
+ if (!canUpload) return;
+
+ try {
+ setSaving(true);
+ const response = await resumeUpload.uploadResume();
+ const ok = !!response && response.success !== false;
+
+ if (ok) {
+ toast.success("Resume uploaded successfully.", toastPresets.success);
+ onComplete();
+ } else {
+ toast.error("Could not upload your resume. Please try again.");
+ }
+ } catch (error) {
+ console.error(error);
+ resumeUpload.resetUploadState();
+ toast.error("Could not upload your resume. Please try again.");
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ return (
+
+
+
+
+ Upload new resume
+
+
+ Upload a PDF resume to make it available when applying.
+
+
+
+
+
+
+
+
+
+ Cancel
+
+
+ void handleUpload()}
+ >
+ {saving && }
+ Upload Resume
+
+
+
+
+
+ );
+}
diff --git a/components/features/student/profile/AutoApplyCard.tsx b/components/features/student/profile/AutoApplyCard.tsx
deleted file mode 100644
index 4ebe122c..00000000
--- a/components/features/student/profile/AutoApplyCard.tsx
+++ /dev/null
@@ -1,209 +0,0 @@
-"use client";
-
-import { useState, useMemo, useEffect } from "react";
-import { Card } from "@/components/ui/card";
-import { Button } from "@/components/ui/button";
-import { Switch } from "@/components/ui/switch";
-import { Badge } from "@/components/ui/badge";
-import { cn } from "@/lib/utils";
-import {
- AlertDialog,
- AlertDialogContent,
- AlertDialogHeader,
- AlertDialogTitle,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogCancel,
- AlertDialogAction,
-} from "@/components/landingHire/ui/alert-dialog";
-
-export function AutoApplyCard({
- initialEnabled,
- enabledAt,
- onSave,
- saving,
- error,
-}: {
- initialEnabled: boolean;
- enabledAt: string | null;
- onSave: (next: boolean) => Promise;
- saving?: boolean;
- error?: string | null;
-}) {
- const [enabled, setEnabled] = useState(!!initialEnabled);
- const [confirmOpen, setConfirmOpen] = useState(false);
- const [pending, setPending] = useState(false);
- const [localError, setLocalError] = useState(null);
-
- const daysRemaining = useMemo(() => {
- if (!enabled || !enabledAt) return 0;
-
- const enabledDate = new Date(enabledAt)
- const endDate = new Date(enabledDate.getTime() + (14 * 24 * 60 * 60 * 1000))
- const dateNow = new Date()
- const daysLeft = Math.ceil((endDate.getTime() - dateNow.getTime())/ (24 * 60 * 60 * 1000))
-
- // console.log(" endDate:", endDate);
- // console.log(" dateNow:", dateNow);
- // console.log(" daysLeft:", daysLeft);
-
- return Math.max(0, daysLeft);
- }, [enabled, enabledAt])
-
- useEffect(() => {
- setEnabled(!!initialEnabled);
- }, [initialEnabled]);
-
- const isBusy = pending || !!saving;
-
- const tone = useMemo(
- () =>
- enabled
- ? {
- card: "bg-emerald-50 border-emerald-200",
- heading: "text-emerald-900",
- subtext: "text-emerald-700",
- pill: "bg-transparent border-emerald-700 text-emerald-700",
- }
- : {
- card: "bg-red-50 border-red-200",
- heading: "text-red-900",
- subtext: "text-red-700",
- pill: "bg-transparent border-red-700 text-red-700",
- },
- [enabled]
- );
-
- async function handleEnable() {
- setLocalError(null);
- setPending(true);
-
- // Optimistic UI
- const prev = enabled;
- setEnabled(true);
-
- try {
- await onSave(true);
- } catch (e: any) {
- // Roll back on failure
- setEnabled(prev);
- setLocalError(e?.message ?? "Failed to enable Apply for Me.");
- } finally {
- setPending(false);
- }
- }
-
- function handleRequestDisable() {
- // Do not flip yet; keep switch ON until confirmed
- setConfirmOpen(true);
- }
-
- async function confirmDisable() {
- setLocalError(null);
- setPending(true);
-
- try {
- await onSave(false);
- setEnabled(false);
- setConfirmOpen(false);
- } catch (e: any) {
- setLocalError(e?.message ?? "Failed to turn off Apply for Me.");
- // Keep dialog open so user sees the error; user can dismiss
- } finally {
- setPending(false);
- }
- }
-
- return (
- <>
-
- {/* Header */}
-
-
-
- Apply for Me
-
- Recommended
-
-
-
- {
- if (next) handleEnable();
- else handleRequestDisable();
- }}
- />
-
-
-
- {/* Explanation */}
-
- Automatically send your resume to matching companies. Only companies that fit
- your profile will see your resume.
-
-
- {/* Status */}
- {enabled ? (
-
- {daysRemaining > 0 ?
- (isBusy ? "Saving…" : `Apply for Me is currently active, ${daysRemaining} days remaining.`) :
- ""}
-
- ) : (
-
- {isBusy ? "Saving…" : "Apply for Me is currently off."}
-
- )}
-
- {/* Errors */}
- {(error || localError) && (
-
- {error || localError}
-
- )}
-
-
- {/* Turn off confirmation */}
-
-
-
- Turn off Apply for Me?
-
- Don’t worry — we only apply to companies that fit your profile. Keep this on
- to get interviews faster.
- {(error || localError) && (
- {error || localError}
- )}
-
-
-
- {/* Turn off */}
-
- {isBusy ? "Turning off…" : "Turn off — I’ll apply manually instead"}
-
-
- {/* Keep on */}
-
- Keep it on
-
-
-
-
- >
- );
-}
diff --git a/components/features/student/register/RegisterCarousel.tsx b/components/features/student/register/RegisterCarousel.tsx
new file mode 100644
index 00000000..cb69d34a
--- /dev/null
+++ b/components/features/student/register/RegisterCarousel.tsx
@@ -0,0 +1,49 @@
+"use client";
+
+import * as React from "react";
+import Fade from "embla-carousel-fade";
+
+import {
+ Carousel,
+ CarouselContent,
+ CarouselItem,
+ type CarouselApi,
+} from "@/components/ui/carousel";
+
+import { slides } from "./slides";
+
+export function RegisterCarousel() {
+ const [api, setApi] = React.useState();
+
+ React.useEffect(() => {
+ if (!api) return;
+
+ const intervalId = setInterval(() => {
+ api.scrollNext();
+ }, 10000);
+
+ return () => clearInterval(intervalId);
+ }, [api]);
+
+ return (
+ any)()]}
+ className="w-full h-full"
+ >
+
+ {slides.map((slide) => (
+
+
+ {slide.content}
+
+
+ ))}
+
+
+ );
+}
diff --git a/components/features/student/register/StudentOtpInput.tsx b/components/features/student/register/StudentOtpInput.tsx
new file mode 100644
index 00000000..9033d0c4
--- /dev/null
+++ b/components/features/student/register/StudentOtpInput.tsx
@@ -0,0 +1,33 @@
+import {
+ InputOTP,
+ InputOTPGroup,
+ InputOTPSlot,
+} from "@/components/ui/input-otp";
+import { STUDENT_OTP_LENGTH } from "@/hooks/use-student-otp-verification";
+
+type StudentOtpInputProps = {
+ containerClassName?: string;
+ onChange: (value: string) => void;
+ value: string;
+};
+
+export function StudentOtpInput({
+ containerClassName = "justify-center",
+ onChange,
+ value,
+}: StudentOtpInputProps) {
+ return (
+
+
+ {Array.from({ length: STUDENT_OTP_LENGTH }, (_, index) => (
+
+ ))}
+
+
+ );
+}
diff --git a/components/features/student/register/slides.tsx b/components/features/student/register/slides.tsx
new file mode 100644
index 00000000..210ab5d4
--- /dev/null
+++ b/components/features/student/register/slides.tsx
@@ -0,0 +1,100 @@
+const Hero = () => {
+ return (
+
+
+
+ Better internships start{" "}
+ here .
+
+
+ );
+};
+
+const SuperListings = () => {
+ return (
+
+
+
+
+
+
+ Stand out from other interns
+
+ even with no experience.
+
+
+ Not all internships need resumes. {" "}
+
+
+ Some of our companies{" "}
+
+ give challenges instead to prove yourself as an applicant.
+ {" "}
+
+
+ If you impress the company, you get in.
+ Even with no experience.
+
+
+
+ );
+};
+
+const FormAutomation = () => {
+ return (
+
+
+
+ 100s of pages of paperwork,{" "}
+ automated.
+
+
+ );
+};
+
+const Connect = () => {
+ return (
+
+
+
+ Connect with hundreds of{" "}
+ employers.
+
+
+ );
+};
+
+export const slides = [
+ {
+ id: 1,
+ content: ,
+ },
+ {
+ id: 2,
+ content: ,
+ },
+ {
+ id: 3,
+ content: ,
+ },
+ {
+ id: 4,
+ content: ,
+ },
+];
diff --git a/components/features/student/resume-parser/ResumeUploadForm.tsx b/components/features/student/resume-parser/ResumeUploadForm.tsx
new file mode 100644
index 00000000..380fbd4a
--- /dev/null
+++ b/components/features/student/resume-parser/ResumeUploadForm.tsx
@@ -0,0 +1,211 @@
+"use client";
+
+import { FormInput } from "@/components/EditForm";
+import { UserService } from "@/lib/api/services";
+import { AnimatePresence, motion } from "framer-motion";
+import { useCallback, useEffect, useRef, useState } from "react";
+import ResumeUpload from "./ResumeUpload";
+
+export type ResumeUploadStatus = "idle" | "uploading" | "uploaded";
+
+type UploadResumeResponse = {
+ success?: boolean;
+ resume?: { id?: string };
+};
+
+export function useResumeUploadForm() {
+ const fileInputRef = useRef(null);
+ const resumeNameInputRef = useRef(null);
+
+ const [selectedFile, setSelectedFile] = useState(null);
+ const [resumeLabel, setResumeLabel] = useState("");
+ const [uploadStatus, setUploadStatus] = useState("idle");
+ const [uploadProgress, setUploadProgress] = useState(0);
+
+ const resetUploadState = useCallback(() => {
+ setUploadStatus("idle");
+ setUploadProgress(0);
+ }, []);
+
+ const selectFile = useCallback((file: File) => {
+ setSelectedFile(file);
+ setResumeLabel(stripPdfExtension(file.name));
+ setUploadStatus("idle");
+ setUploadProgress(0);
+ }, []);
+
+ const updateLabel = useCallback((label: string) => {
+ setResumeLabel(label);
+ setUploadStatus("idle");
+ setUploadProgress(0);
+ }, []);
+
+ const clear = useCallback(() => {
+ setSelectedFile(null);
+ setResumeLabel("");
+ setUploadStatus("idle");
+ setUploadProgress(0);
+ }, []);
+
+ useEffect(() => {
+ if (!selectedFile) return;
+
+ const focusTimer = window.setTimeout(() => {
+ resumeNameInputRef.current?.focus();
+ resumeNameInputRef.current?.select();
+ }, 180);
+ return () => window.clearTimeout(focusTimer);
+ }, [selectedFile]);
+
+ useEffect(() => {
+ if (uploadStatus !== "uploading") return;
+
+ let timeout: number | undefined;
+ const tick = () => {
+ setUploadProgress((current) => {
+ if (current >= 91) return current;
+ return Math.min(
+ 91,
+ current + Math.max(1, Math.round(Math.random() * 8)),
+ );
+ });
+ timeout = window.setTimeout(tick, 140 + Math.random() * 360);
+ };
+
+ timeout = window.setTimeout(tick, 140 + Math.random() * 360);
+ return () => {
+ if (timeout) window.clearTimeout(timeout);
+ };
+ }, [uploadStatus]);
+
+ const createResumeForm = useCallback(() => {
+ if (!selectedFile) return null;
+
+ const form = new FormData();
+ const label = resumeLabel.trim() || selectedFile.name;
+ const uploadFilename = /\.pdf$/i.test(label) ? label : `${label}.pdf`;
+ form.append("resume", selectedFile, uploadFilename);
+ form.append("label", label);
+ return form;
+ }, [resumeLabel, selectedFile]);
+
+ const uploadResume = useCallback(async () => {
+ const form = createResumeForm();
+ if (!form) return null;
+
+ setUploadProgress(5);
+ setUploadStatus("uploading");
+ const [response] = (await Promise.all([
+ UserService.uploadMyResume(form),
+ sleep(2000),
+ ])) as [UploadResumeResponse, void];
+
+ const ok = !!response && response.success !== false;
+ setUploadProgress(ok ? 100 : 0);
+ setUploadStatus(ok ? "uploaded" : "idle");
+
+ return response;
+ }, [createResumeForm]);
+
+ return {
+ fileInputRef,
+ resumeNameInputRef,
+ selectedFile,
+ resumeLabel,
+ uploadStatus,
+ uploadProgress,
+ canUpload: !!selectedFile && !!resumeLabel.trim(),
+ selectFile,
+ updateLabel,
+ clear,
+ resetUploadState,
+ uploadResume,
+ };
+}
+
+type ResumeUploadFormState = ReturnType;
+
+export function ResumeUploadFormFields({
+ form,
+ placeholder = "e.g. Frontend Developer Resume",
+ inputClassName,
+}: {
+ form: ResumeUploadFormState;
+ placeholder?: string;
+ inputClassName?: string;
+}) {
+ return (
+
+
{}}
+ />
+
+
+ {form.selectedFile && (
+
+
+
+
+ )}
+
+
+ );
+}
+
+function ResumeUploadProgress({
+ status,
+ progress,
+}: {
+ status: ResumeUploadStatus;
+ progress: number;
+}) {
+ if (status === "idle") return null;
+
+ const label =
+ status === "uploading" ? "Uploading resume..." : "Resume uploaded";
+
+ return (
+
+
+ {label}
+ {progress}%
+
+
+
+
+
+ );
+}
+
+function stripPdfExtension(name: string) {
+ return name.replace(/\.pdf$/i, "");
+}
+
+function sleep(ms: number) {
+ return new Promise((resolve) => window.setTimeout(resolve, ms));
+}
diff --git a/components/features/student/search/AutoApplyToast.tsx b/components/features/student/search/AutoApplyToast.tsx
deleted file mode 100644
index f13e2273..00000000
--- a/components/features/student/search/AutoApplyToast.tsx
+++ /dev/null
@@ -1,37 +0,0 @@
-"use client";
-
-import { Toast } from "@/components/ui/toast";
-
-export function AutoApplyToast({
- enabled,
- dismissed,
- onDismissForever,
-}: {
- enabled: boolean; // profile.data?.auto_apply
- dismissed: boolean; // profile.data?.acknowledged_auto_apply
- onDismissForever: () => Promise;
-}) {
- return (
-
- We’ll automatically send your resume to matching companies. Only
- companies that fit your profile will see your resume. Manage it in{" "}
- Profile .
- >
- }
- position="bottom-right"
- indicator="ping-dot"
- actions={[
- { type: "link", label: "Open Profile", href: "/profile" },
- {
- type: "button",
- label: "Don’t show again",
- onClick: onDismissForever,
- },
- ]}
- />
- );
-}
diff --git a/components/modals/DeleteResumeModal.tsx b/components/modals/DeleteResumeModal.tsx
new file mode 100644
index 00000000..9cbd72c9
--- /dev/null
+++ b/components/modals/DeleteResumeModal.tsx
@@ -0,0 +1,50 @@
+import { Resume } from "@/lib/db/db.types";
+import { Trash2 } from "lucide-react";
+import { HeaderIcon } from "../ui/text";
+import { Button } from "../ui/button";
+
+interface DeleteResumeProps {
+ resume: Resume;
+ isProcessing: boolean;
+ onConfirm: () => void;
+ onCancel: () => void;
+}
+
+export default function DeleteResumeModal({
+ resume,
+ isProcessing,
+ onConfirm,
+ onCancel,
+}: DeleteResumeProps) {
+ if (!resume) return null;
+
+ return (
+
+
+
+
Delete {resume.label}?
+
+
This action is permanent and cannot be undone.
+
+ {/* action buttons */}
+
+
+ Cancel
+
+
+ {isProcessing ? "Deleting resume..." : "Delete"}
+
+
+
+ );
+}
diff --git a/components/modals/JobModal.tsx b/components/modals/JobModal.tsx
index 1327295e..6d9f3652 100644
--- a/components/modals/JobModal.tsx
+++ b/components/modals/JobModal.tsx
@@ -22,16 +22,17 @@ import { SaveJobButton } from "../features/student/job/save-job-button";
import { ApplyToJobButton } from "../features/student/job/apply-to-job-button";
import { ShareJobButton } from "../features/student/job/share-job-button";
import { MissingNotice } from "../shared/jobs";
+import type { ApplyPayload } from "./components/ApplyModal";
export const JobModal = ({
job,
- openAppModal,
+ onApply,
applySuccessModalRef,
ref,
user,
}: {
job: Job;
- openAppModal: () => void;
+ onApply: (payload: ApplyPayload) => void | Promise;
ref?: RefObject;
applySuccessModalRef?: RefObject;
user?: {
@@ -153,7 +154,7 @@ export const JobModal = ({
diff --git a/components/modals/RenameResumeModal.tsx b/components/modals/RenameResumeModal.tsx
new file mode 100644
index 00000000..15b5d063
--- /dev/null
+++ b/components/modals/RenameResumeModal.tsx
@@ -0,0 +1,54 @@
+import { Resume } from "@/lib/db/db.types";
+import { Pencil } from "lucide-react";
+import { HeaderIcon } from "../ui/text";
+import { Button } from "../ui/button";
+import { FormInput } from "../EditForm";
+import { useState } from "react";
+
+interface RenameResumeProps {
+ resume: Resume;
+ isProcessing: boolean;
+ onConfirm: () => void;
+ onCancel: () => void;
+}
+
+export default function RenameResumeModal({
+ resume,
+ isProcessing,
+ onConfirm,
+ onCancel,
+}: RenameResumeProps) {
+ if (!resume) return null;
+
+ const [label, setLabel] = useState(resume.label || "Default resume");
+
+ return (
+
+
+
+
Rename {resume.label}?
+
+
+
+
+ {/* action buttons */}
+
+
+ Cancel
+
+
+ {isProcessing ? "Renaming resume..." : "Rename"}
+
+
+
+ );
+}
diff --git a/components/modals/components/ApplyModal.tsx b/components/modals/components/ApplyModal.tsx
new file mode 100644
index 00000000..2db196dd
--- /dev/null
+++ b/components/modals/components/ApplyModal.tsx
@@ -0,0 +1,285 @@
+"use client";
+
+import { useResumeUploadForm } from "@/components/features/student/resume-parser/ResumeUploadForm";
+import { UserService } from "@/lib/api/services";
+import { PublicUser } from "@/lib/db/db.types";
+import {
+ monthYearToTimestampMs,
+ timestampMsToMonthYear,
+} from "@/lib/utils/date-utils";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { useEffect, useMemo, useRef, useState } from "react";
+import { toast } from "sonner";
+import {
+ ApplyStepHeader,
+ CoverLetterStep,
+ InternshipDetailsStep,
+ ResumeStep,
+} from "./apply-modal-steps";
+
+type InternshipType = "credited" | "voluntary";
+type ApplyStep = 1 | 2 | 3;
+
+export type ApplyPayload = {
+ resumeId: string;
+ coverLetter: string;
+};
+
+export function ApplyModal({
+ profile,
+ onCancel,
+ onApply,
+ applyLabel = "Apply",
+ requiresCoverLetter = false,
+}: {
+ profile: PublicUser | null;
+ onCancel: () => void;
+ onApply: (payload: ApplyPayload) => void | Promise;
+ applyLabel?: string;
+ requiresCoverLetter?: boolean;
+}) {
+ const queryClient = useQueryClient();
+ const coverLetterRef = useRef(null);
+ const resumeUpload = useResumeUploadForm();
+
+ const { data: resumesData, isPending: resumesLoading } = useQuery({
+ queryKey: ["my-resumes"],
+ queryFn: () => UserService.getMyResumes(),
+ });
+
+ const resumes = useMemo(() => resumesData?.resumes ?? [], [resumesData]);
+ const hasExistingResumes = resumes.length > 0;
+ const maxResumesEnv = Number(process.env.NEXT_PUBLIC_MAX_RESUMES_ALLOWED);
+ const maxResumesAllowed = Number.isFinite(maxResumesEnv) ? maxResumesEnv : 5;
+ const atResumeLimit = resumes.length >= maxResumesAllowed;
+
+ const initialDate = useMemo(
+ () =>
+ timestampMsToMonthYear(
+ profile?.internship_preferences?.expected_start_date,
+ ),
+ [profile?.internship_preferences?.expected_start_date],
+ );
+
+ const [step, setStep] = useState(1);
+ const [resumeChoice, setResumeChoice] = useState("new");
+ const [showUpload, setShowUpload] = useState(true);
+ const [internshipType, setInternshipType] = useState(
+ profile?.internship_preferences?.internship_type ?? null,
+ );
+ const [month, setMonth] = useState(initialDate.month);
+ const [year, setYear] = useState(initialDate.year);
+ const [saving, setSaving] = useState(false);
+ const [uploadedResumeId, setUploadedResumeId] = useState(null);
+ const [coverLetter, setCoverLetter] = useState("");
+
+ const hasSavedInternshipDetails = !!(
+ profile?.internship_preferences?.internship_type &&
+ profile?.internship_preferences?.expected_start_date
+ );
+ const totalSteps = requiresCoverLetter ? 3 : 2;
+ const years = useMemo(() => {
+ const current = new Date().getFullYear();
+ return Array.from({ length: 6 }, (_, i) => current + i);
+ }, []);
+
+ const hasInitialized = useRef(false);
+ useEffect(() => {
+ if (resumesLoading || hasInitialized.current) return;
+ hasInitialized.current = true;
+
+ if (hasExistingResumes) {
+ setResumeChoice(resumes[0].id);
+ setShowUpload(false);
+ } else {
+ setResumeChoice("new");
+ setShowUpload(true);
+ }
+ }, [resumesLoading, hasExistingResumes, resumes]);
+
+ useEffect(() => {
+ if (step !== 3) return;
+ const focusTimer = window.setTimeout(() => {
+ coverLetterRef.current?.focus();
+ }, 120);
+ return () => window.clearTimeout(focusTimer);
+ }, [step]);
+
+ useEffect(() => {
+ if (!hasSavedInternshipDetails) return;
+ setInternshipType(profile?.internship_preferences?.internship_type ?? null);
+ setMonth(initialDate.month);
+ setYear(initialDate.year);
+ }, [
+ hasSavedInternshipDetails,
+ initialDate.month,
+ initialDate.year,
+ profile?.internship_preferences?.internship_type,
+ ]);
+
+ const canProceedFromResume =
+ resumeChoice !== "new" ? !!resumeChoice : resumeUpload.canUpload;
+ const canApply =
+ hasSavedInternshipDetails ||
+ (!!internshipType && month !== "" && year !== "");
+ const canSubmit = canApply && (!requiresCoverLetter || !!coverLetter.trim());
+
+ async function uploadResumeIfNeeded(): Promise {
+ if (resumeChoice !== "new" || !resumeUpload.selectedFile)
+ return resumeChoice !== "new" ? resumeChoice : null;
+
+ const response = await resumeUpload.uploadResume();
+ if (response?.success !== false && response?.resume?.id)
+ return response.resume.id;
+
+ return null;
+ }
+
+ async function handleResumeNext() {
+ if (!canProceedFromResume) return;
+
+ try {
+ setSaving(true);
+ if (resumeChoice === "new") {
+ const id = await uploadResumeIfNeeded();
+ if (!id) {
+ toast.error("Could not upload your resume. Please try again.");
+ return;
+ }
+
+ await queryClient.invalidateQueries({ queryKey: ["my-resumes"] });
+ setUploadedResumeId(id);
+ setResumeChoice(id);
+ setShowUpload(false);
+ resumeUpload.clear();
+ }
+ setStep(2);
+ } catch (error) {
+ console.error(error);
+ resumeUpload.resetUploadState();
+ toast.error("Could not upload your resume. Please try again.");
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ async function handleApply() {
+ if (!canSubmit) return;
+
+ try {
+ setSaving(true);
+ const profileUpdate: Partial = {
+ acknowledged_auto_apply: true,
+ };
+
+ if (!hasSavedInternshipDetails) {
+ profileUpdate.internship_preferences = {
+ ...(profile?.internship_preferences ?? {}),
+ internship_type: internshipType ?? undefined,
+ expected_start_date: monthYearToTimestampMs(
+ Number(month),
+ Number(year),
+ ),
+ };
+ }
+
+ await UserService.updateMyProfile(profileUpdate);
+
+ await queryClient.invalidateQueries({ queryKey: ["my-profile"] });
+ onCancel();
+
+ const targetResumeId =
+ resumeChoice === "new" ? uploadedResumeId : resumeChoice;
+ if (!targetResumeId) throw new Error("No resume selected");
+
+ await onApply({
+ resumeId: targetResumeId,
+ coverLetter: requiresCoverLetter ? coverLetter.trim() : "",
+ });
+ } catch (error) {
+ console.error(error);
+ toast.error("Could not save your application preferences.");
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ function handleDetailsNext() {
+ if (!canApply) return;
+ if (requiresCoverLetter) {
+ setStep(3);
+ return;
+ }
+ void handleApply();
+ }
+
+ function goBack() {
+ setStep(step === 3 ? 2 : 1);
+ }
+
+ return (
+
+
+
+
+ {step === 1 ? (
+
{
+ setResumeChoice(id);
+ setShowUpload(false);
+ }}
+ onShowUpload={() => {
+ setResumeChoice("new");
+ setShowUpload(true);
+ }}
+ onContinue={() => void handleResumeNext()}
+ />
+ ) : step === 2 ? (
+ setStep(1)}
+ onInternshipTypeChange={setInternshipType}
+ onMonthChange={setMonth}
+ onYearChange={setYear}
+ onContinue={handleDetailsNext}
+ />
+ ) : (
+ void handleApply()}
+ />
+ )}
+
+
+ );
+}
diff --git a/components/modals/components/MassApplyJobsSelector.tsx b/components/modals/components/MassApplyJobsSelector.tsx
index 6e432d1e..50e567ad 100644
--- a/components/modals/components/MassApplyJobsSelector.tsx
+++ b/components/modals/components/MassApplyJobsSelector.tsx
@@ -7,6 +7,7 @@ import { useJobsData } from "@/lib/api/student.data.api";
import { useMassApply } from "@/lib/api/god.api";
import { Job } from "@/lib/db/db.types";
import { useAuthContext } from "@/lib/ctx-auth";
+import { toast } from "sonner";
interface MassApplyJobsSelectorProps {
selectedStudentIds: Set;
@@ -47,12 +48,12 @@ export function MassApplyJobsSelector({
const handleApply = async () => {
if (!selectedJobId) {
- alert("Please select a job");
+ toast.error("Please select a job");
return;
}
if (!coverLetter.trim()) {
- alert("Please enter a cover letter");
+ toast.error("Please enter a cover letter");
return;
}
@@ -62,13 +63,13 @@ export function MassApplyJobsSelector({
studentIds: Array.from(selectedStudentIds),
coverLetter: coverLetter.trim(),
});
- alert(
+ toast.success(
`Successfully applied ${selectedStudentIds.size} student(s) to the job!`,
);
onClose();
} catch (error) {
console.error("Mass apply error:", error);
- alert("Failed to apply students. Check console for details.");
+ toast.error("Failed to apply students. Check console for details.");
}
};
diff --git a/components/modals/components/MassApplyResults.tsx b/components/modals/components/MassApplyResults.tsx
index 0384134f..1e0d8348 100644
--- a/components/modals/components/MassApplyResults.tsx
+++ b/components/modals/components/MassApplyResults.tsx
@@ -21,7 +21,7 @@ export function MassApplyResults({
onClearSelection: () => void;
}) {
return (
-
+
Applied
diff --git a/components/modals/components/MissingRequirementsModal.tsx b/components/modals/components/MissingRequirementsModal.tsx
new file mode 100644
index 00000000..77f63272
--- /dev/null
+++ b/components/modals/components/MissingRequirementsModal.tsx
@@ -0,0 +1,54 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import { ArrowRight } from "lucide-react";
+import { useRouter } from "next/navigation";
+
+export function MissingRequirementsModal({
+ missing,
+ onCancel,
+}: {
+ missing: string[];
+ onCancel: () => void;
+}) {
+ const router = useRouter();
+ return (
+
+
+
+
+ Missing requirements!
+
+
+ This listing requires details that are not yet on your profile.
+
+
+
+
+
+ {missing.map((item) => (
+
+
+ {item}
+
+ ))}
+
+
+
+
+ Cancel
+
+
{
+ router.push("/profile?edit=true");
+ onCancel();
+ }}
+ >
+ Update my profile
+
+
+
+
+ );
+}
diff --git a/components/modals/components/apply-modal-steps.tsx b/components/modals/components/apply-modal-steps.tsx
new file mode 100644
index 00000000..573fd78a
--- /dev/null
+++ b/components/modals/components/apply-modal-steps.tsx
@@ -0,0 +1,476 @@
+"use client";
+
+import {
+ ResumeUploadFormFields,
+ useResumeUploadForm,
+} from "@/components/features/student/resume-parser/ResumeUploadForm";
+import { Button } from "@/components/ui/button";
+import { Textarea } from "@/components/ui/textarea";
+import { Resume } from "@/lib/db/db.types";
+import { cn } from "@/lib/utils";
+import { MONTH_NAMES } from "@/lib/utils/date-utils";
+import { AnimatePresence, motion } from "framer-motion";
+import { CheckCircle2, FileText } from "lucide-react";
+import Link from "next/link";
+import { RefObject } from "react";
+
+type ApplyStep = 1 | 2 | 3;
+type InternshipType = "credited" | "voluntary";
+type ResumeUploadFormState = ReturnType
;
+
+export function ApplyStepHeader({
+ step,
+ totalSteps,
+}: {
+ step: ApplyStep;
+ totalSteps: number;
+}) {
+ const title =
+ step === 1
+ ? "Choose your resume"
+ : step === 2
+ ? "Internship details"
+ : "Cover letter";
+ const description =
+ step === 1
+ ? "Pick an existing resume or upload a new one before applying."
+ : step === 2
+ ? "These details help companies understand what kind of internship you need."
+ : "This listing requires a cover letter.";
+
+ return (
+
+
+ Step {step} of {totalSteps}
+
+
{title}
+
{description}
+
+ );
+}
+
+export function ResumeStep({
+ resumes,
+ resumesLoading,
+ hasExistingResumes,
+ atResumeLimit,
+ resumeChoice,
+ showUpload,
+ resumeUpload,
+ canProceed,
+ saving,
+ onCancel,
+ onResumeChoice,
+ onShowUpload,
+ onContinue,
+}: {
+ resumes: Resume[];
+ resumesLoading: boolean;
+ hasExistingResumes: boolean;
+ atResumeLimit: boolean;
+ resumeChoice: string;
+ showUpload: boolean;
+ resumeUpload: ResumeUploadFormState;
+ canProceed: boolean;
+ saving: boolean;
+ onCancel: () => void;
+ onResumeChoice: (id: string) => void;
+ onShowUpload: () => void;
+ onContinue: () => void;
+}) {
+ return (
+
+ {resumesLoading ? (
+
Loading resumes...
+ ) : (
+ hasExistingResumes && (
+
+ )
+ )}
+
+ {!resumesLoading && hasExistingResumes && !showUpload && (
+
+ Upload new resume
+
+ )}
+
+
+ {showUpload && (
+
+
+
+
+
+ )}
+
+
+
+
+ Cancel
+
+
+ {saving ? "Saving..." : "Continue"}
+
+
+
+ );
+}
+
+function ResumeChoiceList({
+ resumes,
+ selectedResumeId,
+ onSelect,
+}: {
+ resumes: Resume[];
+ selectedResumeId: string;
+ onSelect: (id: string) => void;
+}) {
+ return (
+
+ {resumes.map((resume) => (
+
onSelect(resume.id)}
+ className={cn(
+ "w-full rounded-[0.33em] border p-3 text-left transition bg-white",
+ selectedResumeId === resume.id
+ ? "border-primary ring-2 ring-primary/15"
+ : "border-gray-200 hover:border-primary/70",
+ )}
+ >
+
+
+
+
+
+
+ {resume.label || resume.filename || "Untitled resume"}
+
+
+ event.stopPropagation()}
+ >
+ View in profile
+
+
+
+ {selectedResumeId === resume.id && (
+
+ )}
+
+
+ ))}
+
+ );
+}
+
+export function InternshipDetailsStep({
+ internshipType,
+ month,
+ year,
+ years,
+ saving,
+ canApply,
+ isReadOnly,
+ requiresCoverLetter,
+ applyLabel,
+ onCancel,
+ onBack,
+ onInternshipTypeChange,
+ onMonthChange,
+ onYearChange,
+ onContinue,
+}: {
+ internshipType: InternshipType | null;
+ month: string;
+ year: string;
+ years: number[];
+ saving: boolean;
+ canApply: boolean;
+ isReadOnly: boolean;
+ requiresCoverLetter: boolean;
+ applyLabel: string;
+ onCancel: () => void;
+ onBack: () => void;
+ onInternshipTypeChange: (type: InternshipType) => void;
+ onMonthChange: (month: string) => void;
+ onYearChange: (year: string) => void;
+ onContinue: () => void;
+}) {
+ return (
+
+
+ {isReadOnly ? (
+
+ ) : (
+ <>
+
+
+ >
+ )}
+
+
+
+
+ Cancel
+
+
+
+ Back
+
+
+ {saving
+ ? "Applying..."
+ : requiresCoverLetter
+ ? "Continue"
+ : applyLabel}
+
+
+
+
+ );
+}
+
+function InternshipDetailsSummary({
+ internshipType,
+ month,
+ year,
+}: {
+ internshipType: InternshipType | null;
+ month: string;
+ year: string;
+}) {
+ const startMonth =
+ month !== "" && year !== ""
+ ? // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
+ `${MONTH_NAMES[Number(month)]} ${year}`
+ : "Not specified";
+
+ return (
+
+
+ You are applying for{" "}
+ {{internshipType} }{" "}
+ internships
+ and expect to start on{" "}
+ {{startMonth} }.
+
+
+
+ Edit in profile
+
+
+
+ );
+}
+
+function InternshipTypeSelector({
+ value,
+ onChange,
+}: {
+ value: InternshipType | null;
+ onChange: (type: InternshipType) => void;
+}) {
+ return (
+
+
+ Internship Type
+
+
+ {(["credited", "voluntary"] as const).map((type) => (
+ onChange(type)}
+ className={cn(
+ "rounded-[0.33em] border p-3 text-center text-sm font-medium transition bg-white",
+ value === type
+ ? "border-primary bg-primary/5 text-primary ring-1 ring-primary"
+ : "border-gray-200 text-gray-600 hover:border-primary/50 hover:bg-gray-50",
+ )}
+ >
+ {type === "credited" ? "Credited" : "Voluntary"}
+
+ ))}
+
+
+ );
+}
+
+function ExpectedStartDateSelect({
+ month,
+ year,
+ years,
+ onMonthChange,
+ onYearChange,
+}: {
+ month: string;
+ year: string;
+ years: number[];
+ onMonthChange: (month: string) => void;
+ onYearChange: (year: string) => void;
+}) {
+ return (
+
+
+ Expected Start Date
+
+
+ onMonthChange(event.target.value)}
+ >
+
+ Month
+
+ {MONTH_NAMES.map((monthName, index) => (
+
+ {monthName}
+
+ ))}
+
+ onYearChange(event.target.value)}
+ >
+
+ Year
+
+ {years.map((yearOption) => (
+
+ {yearOption}
+
+ ))}
+
+
+
+ );
+}
+
+export function CoverLetterStep({
+ coverLetter,
+ coverLetterRef,
+ saving,
+ canSubmit,
+ applyLabel,
+ onCancel,
+ onBack,
+ onCoverLetterChange,
+ onSubmit,
+}: {
+ coverLetter: string;
+ coverLetterRef: RefObject;
+ saving: boolean;
+ canSubmit: boolean;
+ applyLabel: string;
+ onCancel: () => void;
+ onBack: () => void;
+ onCoverLetterChange: (coverLetter: string) => void;
+ onSubmit: () => void;
+}) {
+ return (
+
+
+
+ Cover letter
+
+
+
+
+
+ Cancel
+
+
+
+ Back
+
+
+ {saving ? "Applying..." : applyLabel}
+
+
+
+
+ );
+}
diff --git a/components/modals/modal-registry.tsx b/components/modals/modal-registry.tsx
index 2f98bd9e..e50ace8e 100644
--- a/components/modals/modal-registry.tsx
+++ b/components/modals/modal-registry.tsx
@@ -1,6 +1,5 @@
import { useGlobalModal } from "../providers/modal-provider/ModalProvider";
import { LucideIcon } from "lucide-react";
-import { MassApplyComposer } from "./components/MassApplyComposer";
import { FormSubmissionSuccessModal } from "./components/FormSubmissionSuccessModal";
import { FollowUpFormModal } from "./components/ResendFormModal";
import { CancelFormModal } from "./components/CancelFormModal";
@@ -17,13 +16,18 @@ import {
MassApplyResults,
MassApplyResultsData,
} from "./components/MassApplyResults";
+import { ApplyModal } from "./components/ApplyModal";
+import type { ApplyPayload } from "./components/ApplyModal";
+import { MissingRequirementsModal } from "./components/MissingRequirementsModal";
import { FormPreviewPdfDisplay } from "../features/student/forms/previewer";
import { IFormSigningParty } from "@betterinternship/core/forms";
import { ApplicationAction } from "@/lib/consts/application";
-import { EmployerApplication } from "@/lib/db/db.types";
+import { EmployerApplication, Resume } from "@/lib/db/db.types";
import ApplicationActionModal from "./ApplicationActionModal";
import DeleteJobListingModal from "./DeleteJobListingModal";
-import { Job } from "@/lib/db/db.types";
+import { Job, PublicUser } from "@/lib/db/db.types";
+import DeleteResumeModal from "./DeleteResumeModal";
+import RenameResumeModal from "./RenameResumeModal";
/**
* Simplifies modal config since we usually reuse each of these modal stuffs.
@@ -35,6 +39,35 @@ export const useModalRegistry = () => {
const modalRegistry = useMemo(
() => ({
+ // modal for deleting a resume.
+ deleteResume: {
+ open: ({
+ resume,
+ isProcessing,
+ onConfirm,
+ }: {
+ resume: Resume;
+ isProcessing: boolean;
+ onConfirm: () => void;
+ }) =>
+ open(
+ "delete-resume",
+ DefaultModalLayout,
+ close("delete-resume")}
+ />,
+ {
+ title: `Delete ${resume.label}`,
+ closeOnBackdropClick: true,
+ closeOnEscapeKey: true,
+ showHeaderDivider: true,
+ },
+ ),
+ close: () => close("delete-resume"),
+ },
// modal for deleting a job listing.
deleteListing: {
open: ({
@@ -96,44 +129,54 @@ export const useModalRegistry = () => {
),
close: () => close("application-action"),
},
- // Mass apply fill-out modal
- massApplyCompose: {
+ completeProfileApply: {
open: ({
- bulkCoverLetter,
- runMassApply,
- setBulkCoverLetter,
- massApplying,
- selectedCount,
+ profile,
+ onApply,
+ applyLabel,
+ requiresCoverLetter,
}: {
- bulkCoverLetter: string;
- runMassApply: (text: string) => Promise;
- setBulkCoverLetter: (bulkCoverLetter: string) => void;
- massApplying: boolean;
- selectedCount: string;
+ profile: PublicUser | null;
+ onApply: (payload: ApplyPayload) => void | Promise;
+ applyLabel?: string;
+ requiresCoverLetter?: boolean;
}) =>
open(
- "mass-apply-compose",
+ "complete-profile-apply",
DefaultModalLayout,
- close("mass-apply-compose")}
- onSubmit={async (text) => {
- setBulkCoverLetter(text);
- await runMassApply(text);
- close("mass-apply-compose");
- }}
+ close("complete-profile-apply")}
/>,
{
- title: `Apply to ${selectedCount} selected`,
closeOnBackdropClick: false,
+ showCloseButton: false,
+ showHeaderDivider: false,
},
),
- close: () => close("mass-apply-compose"),
+ close: () => close("complete-profile-apply"),
+ },
+ missingRequirements: {
+ open: ({ missing }: { missing: string[] }) =>
+ open(
+ "missing-requirements",
+ DefaultModalLayout,
+ close("missing-requirements")}
+ />,
+ {
+ closeOnBackdropClick: true,
+ closeOnEscapeKey: true,
+ showCloseButton: false,
+ showHeaderDivider: false,
+ },
+ ),
+ close: () => close("missing-requirements"),
},
-
// The modal shown after performing a mass apply
massApplyResult: {
open: ({
diff --git a/components/providers/modal-provider/ModalProvider.tsx b/components/providers/modal-provider/ModalProvider.tsx
index 9394df0a..1fffa058 100644
--- a/components/providers/modal-provider/ModalProvider.tsx
+++ b/components/providers/modal-provider/ModalProvider.tsx
@@ -131,20 +131,43 @@ export function ModalProvider({ children }: { children: React.ReactNode }) {
const count = Object.keys(registry).length;
if (count === 0) return;
+ const scrollY = window.scrollY;
const originalOverflow = document.body.style.overflow;
+ const originalPosition = document.body.style.position;
+ const originalTop = document.body.style.top;
+ const originalLeft = document.body.style.left;
+ const originalRight = document.body.style.right;
+ const originalWidth = document.body.style.width;
+ const originalOverscrollBehavior = document.body.style.overscrollBehavior;
+
document.body.style.overflow = "hidden";
+ document.body.style.position = "fixed";
+ document.body.style.top = `-${scrollY}px`;
+ document.body.style.left = "0";
+ document.body.style.right = "0";
+ document.body.style.width = "100%";
+ document.body.style.overscrollBehavior = "none";
let focusUpdateTimeout: ReturnType | null = null;
const setVH = () => {
+ const visualViewport = window.visualViewport;
const viewportHeight =
- window.visualViewport?.height &&
- Number.isFinite(window.visualViewport.height)
- ? window.visualViewport.height
+ visualViewport?.height && Number.isFinite(visualViewport.height)
+ ? visualViewport.height
: window.innerHeight;
+ const viewportOffsetTop =
+ visualViewport?.offsetTop && Number.isFinite(visualViewport.offsetTop)
+ ? visualViewport.offsetTop
+ : 0;
+
document.documentElement.style.setProperty(
"--vh",
`${viewportHeight * 0.01}px`,
);
+ document.documentElement.style.setProperty(
+ "--modal-viewport-top",
+ `${viewportOffsetTop}px`,
+ );
};
const handleFocusChange = () => {
@@ -163,7 +186,14 @@ export function ModalProvider({ children }: { children: React.ReactNode }) {
return () => {
document.body.style.overflow = originalOverflow;
+ document.body.style.position = originalPosition;
+ document.body.style.top = originalTop;
+ document.body.style.left = originalLeft;
+ document.body.style.right = originalRight;
+ document.body.style.width = originalWidth;
+ document.body.style.overscrollBehavior = originalOverscrollBehavior;
document.documentElement.style.removeProperty("--vh");
+ document.documentElement.style.removeProperty("--modal-viewport-top");
window.removeEventListener("resize", setVH);
window.removeEventListener("orientationchange", setVH);
window.visualViewport?.removeEventListener("resize", setVH);
@@ -171,6 +201,7 @@ export function ModalProvider({ children }: { children: React.ReactNode }) {
document.removeEventListener("focusin", handleFocusChange);
document.removeEventListener("focusout", handleFocusChange);
if (focusUpdateTimeout) clearTimeout(focusUpdateTimeout);
+ window.scrollTo(0, scrollY);
};
}, [registry]);
@@ -224,6 +255,8 @@ export function ModalProvider({ children }: { children: React.ReactNode }) {
? undefined
: {
height: "calc(var(--vh, 1vh) * 100)",
+ top: "var(--modal-viewport-top, 0px)",
+ bottom: "auto",
}
}
onClick={(e) => {
diff --git a/components/shared/applicant-modal.tsx b/components/shared/applicant-modal.tsx
index 064078a4..6a52d2ac 100644
--- a/components/shared/applicant-modal.tsx
+++ b/components/shared/applicant-modal.tsx
@@ -1,7 +1,7 @@
/**
* @ Author: BetterInternship
* @ Create Time: 2025-06-19 04:14:35
- * @ Modified time: 2025-10-07 08:12:26
+ * @ Modified time: 2026-05-02 04:11:16
* @ Description:
*
* What employers see when clicking on an applicant to view.
@@ -18,28 +18,21 @@ import { Award, FileText } from "lucide-react";
import { getFullName } from "@/lib/profile";
import { MyUserPfp, UserPfp } from "./pfp";
import { Divider } from "../ui/divider";
-import { fmtISO, formatMonth, formatTimestampDate } from "@/lib/utils/date-utils";
+import { formatMonth, formatTimestampDate } from "@/lib/utils/date-utils";
import { Badge } from "../ui/badge";
-import { formatDate } from "date-fns";
export const ApplicantModalContent = ({
applicant = {} as Partial,
clickable = true,
- open_resume,
- open_calendar,
is_employer = false,
job = {} as Partial,
- resume_url,
}: {
applicant?: Partial;
clickable?: boolean;
pfp_fetcher: () => Promise<{ hash?: string }>;
pfp_route: string;
- open_resume: () => void;
- open_calendar?: () => void;
is_employer?: boolean;
job?: Partial;
- resume_url?: string;
}) => {
const {
to_job_type_name,
@@ -51,17 +44,8 @@ export const ApplicantModalContent = ({
const internshipPreferences = applicant.internship_preferences;
- // actions
- const handleResumeClick = useCallback(async () => {
- if (!clickable || !applicant?.resume || !applicant?.id) return;
- open_resume();
- }, [clickable, applicant?.resume, applicant?.id, open_resume]);
-
- // use the presigned URL for embedding
- const resumeSrc = resume_url && resume_url.length ? resume_url : "";
-
return (
-
+
{/* LEFT: original content */}
{/* Fixed Header Section */}
@@ -83,7 +67,8 @@ export const ApplicantModalContent = ({
Applying for {job?.title ?? "Sample Position"}{" "}
- {job?.internship_preferences?.job_commitment_ids?.[0] !== undefined
+ {job?.internship_preferences?.job_commitment_ids?.[0] !==
+ undefined
? `• ${to_job_type_name(job?.internship_preferences?.job_commitment_ids?.[0])}`
: ""}
@@ -99,19 +84,6 @@ export const ApplicantModalContent = ({
)}
-
- {/* Quick Action Buttons */}
-
-
-
- {applicant.resume ? "View Resume" : "No Resume"}
-
-
{/* Scrollable Content Section - Optimized for small screens */}
@@ -248,8 +220,10 @@ export const ApplicantModalContent = ({
Expected Start Date
- {applicant.internship_preferences?.expected_start_date
- ? formatTimestampDate(applicant.internship_preferences?.expected_start_date)
+ {applicant.internship_preferences?.expected_start_date
+ ? formatTimestampDate(
+ applicant.internship_preferences?.expected_start_date,
+ )
: "-"}
@@ -273,11 +247,12 @@ export const ApplicantModalContent = ({
Work Modes
{(() => {
- const ids = (internshipPreferences?.job_setup_ids ?? []) as (string | number)[];
+ const ids = (internshipPreferences?.job_setup_ids ??
+ []) as (string | number)[];
const items = ids
.map((id) => {
const m = job_modes.find(
- (x) => String(x.id) === String(id)
+ (x) => String(x.id) === String(id),
);
return m ? { id: String(m.id), name: m.name } : null;
})
@@ -300,11 +275,12 @@ export const ApplicantModalContent = ({
Workload Types
{(() => {
- const ids = (internshipPreferences?.job_commitment_ids ?? []) as (string | number)[];
+ const ids = (internshipPreferences?.job_commitment_ids ??
+ []) as (string | number)[];
const items = ids
.map((id) => {
const t = job_types.find(
- (x) => String(x.id) === String(id)
+ (x) => String(x.id) === String(id),
);
return t ? { id: String(t.id), name: t.name } : null;
})
@@ -329,7 +305,7 @@ export const ApplicantModalContent = ({
{(() => {
- const ids = (internshipPreferences?.job_category_ids ?? []) as string[];
+ const ids = internshipPreferences?.job_category_ids ?? [];
const items = ids
.map((id) => {
const c = job_categories.find((x) => x.id === id);
@@ -367,19 +343,6 @@ export const ApplicantModalContent = ({
)}
-
- {/* RIGHT (desktop): embed only presigned URL */}
-
- {resumeSrc ? (
-
- ) : (
-
-
-
No resume to display.
-
-
- )}
-
);
};
diff --git a/components/shared/jobs.tsx b/components/shared/jobs.tsx
index 369809e2..1c916dbc 100644
--- a/components/shared/jobs.tsx
+++ b/components/shared/jobs.tsx
@@ -23,7 +23,8 @@ import { Property } from "../ui/labels";
import { Toggle } from "../ui/toggle";
import { useMobile } from "@/hooks/use-mobile";
import { useAppContext } from "@/lib/ctx-app";
-import { useAuthContext } from "@/lib/ctx-auth";
+import { useProfileData } from "@/lib/api/student.data.api";
+import { toAbbreviation } from "../../lib/utils/string-utils";
export const JobHead = ({
title,
@@ -155,18 +156,21 @@ export const JobBadges = ({
job: Job;
excludes?: string[];
}) => {
- const { universities } = useDbRefs();
+ const profile = useProfileData();
+ const universityId =
+ typeof profile.data?.university === "string"
+ ? profile.data.university
+ : undefined;
+ const employerId =
+ typeof job.employer_id === "string" ? job.employer_id : undefined;
const workModes = job.internship_preferences?.job_setup_ids ?? [];
const workLoads = job.internship_preferences?.job_commitment_ids ?? [];
return (
- {!excludes.includes("moa") && (
-
+ {!excludes.includes("moa") && universityId && employerId && (
+
)}
{!excludes.includes("unlisted") && job.is_unlisted && (
@@ -196,10 +200,12 @@ export const EmployerMOA = ({
const { check } = useDbMoa();
const { get_university } = useDbRefs();
- return check(employer_id ?? "", university_id ?? "") ? (
+ if (!employer_id || !university_id) return <>>;
+
+ return check(employer_id, university_id) ? (
- {get_university(university_id)?.name?.split(" ")[0]} MOA
+ {toAbbreviation(get_university(university_id)?.name)} MOA
) : (
<>>
@@ -598,9 +604,6 @@ function HeaderWithActions({
{job.location}
)}
- {/*
- Listed on {formatDate(job.created_at ?? "")}
-
*/}
{/* right: CTAs */}
@@ -719,446 +722,6 @@ export const JobDetailsSummary = ({ job }: { job: Job }) => {
);
};
-/**
- * The right panel that describes job details.
- *
- * @component
- */
-// export const EmployerJobDetails = ({
-// job,
-// is_editing = false,
-// set_is_editing = () => { },
-// saving = false,
-// update_job,
-// actions = [],
-// }: {
-// job: Job;
-// is_editing: boolean;
-// set_is_editing: (is_editing: boolean) => void;
-// saving?: boolean;
-// update_job: (
-// job_id: string,
-// job: Partial
-// ) => Promise<{ success: boolean }>;
-// actions?: React.ReactNode[];
-// }) => {
-// const {
-// job_modes,
-// job_types,
-// job_allowances,
-// job_pay_freq,
-// to_job_pay_freq_name,
-// to_job_allowance_name,
-// to_job_mode_name, to_job_type_name
-// } = useDbRefs();
-// const { formData, setField, setFields, fieldSetter } = useFormData();
-// const workModes =
-// (job.internship_preferences?.job_setup_ids ?? [])
-// .map((id) => to_job_mode_name(id))
-// .filter(Boolean)
-// .join(", ") || "None";
-
-// const workLoads =
-// (job.internship_preferences?.job_commitment_ids ?? [])
-// .map((id) => to_job_type_name(id))
-// .filter(Boolean)
-// .join(", ") || "None";
-
-// const internshipTypes =
-// (job.internship_preferences?.internship_types ?? [])
-// .filter(Boolean)
-// .map((type) => type.charAt(0).toUpperCase() + type.slice(1).toLowerCase())
-// .join(", ") || "None";
-
-// useEffect(() => {
-// if (job) {
-// setFields(job);
-// }
-// }, [job, is_editing]);
-
-// useEffect(() => {
-// if (job && saving) {
-// const edited_job: Partial = {
-// id: formData.id,
-// title: formData.title ?? "",
-// description: formData.description ?? "",
-// requirements: formData.requirements ?? "",
-// location: formData.location ?? "",
-// allowance: formData.allowance ?? undefined,
-// salary: formData.salary ?? null,
-// salary_freq: formData.salary_freq ?? undefined,
-// is_unlisted: formData.is_unlisted,
-// internship_preferences: formData.internship_preferences ?? {},
-// };
-
-// update_job(edited_job.id ?? "", edited_job).then(
-// // @ts-ignore
-// ({ job: updated_job }) => {
-// if (!updated_job) alert("Invalid input provided for job update.");
-// set_is_editing(false);
-// }
-// );
-// }
-// }, [saving]);
-
-// return (
-//
-//
-//
-
-//
-
-// {/* Job Details Grid */}
-// {/*
-//
Job Details
-//
-//
-//
-//
-// Location:
-//
-//
-//
-//
-//
-
-// {formData.internship_preferences?.job_setup_ids?.[0] && (
-//
-//
-//
-// Work Mode:
-//
-//
-// setField("internship_preferences", {
-// ...formData.internship_preferences,
-// job_setup_ids: [v],
-// })
-// }
-// options={job_modes}
-// >
-//
-//
-//
-// )}
-
-// {formData.internship_preferences?.job_commitment_ids?.[0] && (
-//
-//
-//
-// Work Load:
-//
-//
-// setField("internship_preferences", {
-// ...formData.internship_preferences,
-// job_commitment_ids: [v],
-// })
-// }
-// name={"job_type"}
-// options={job_types}
-// >
-//
-//
-//
-// )}
-
-// {is_editing ? (
-//
-//
-//
-//
-//
-// Compensation:
-//
-//
-//
-//
-//
-
-// {formData.allowance === 0 && (
-// <>
-//
-//
-//
-// Salary Amount:
-//
-//
-//
-//
-//
-//
-//
-// Pay Frequency:
-//
-//
-//
-//
-//
-// >
-// )}
-//
-//
-// ) : (
-//
-//
-//
-// {formData.allowance ? "Allowance:" : "Salary:"}
-//
-//
-//
-// )}
-
-//
-//
-//
-//
-//
-//
-//
-//
-// Unlisted?
-//
-//
-//
-// Unlisted jobs can only be viewed through a direct link and
-// will not show up when searching through the platform. Use this
-// when you want to share a job only with specific people.
-//
-//
-
-//
-//
-//
-// setField("internship_preferences", {
-// ...formData.internship_preferences,
-// expected_start_date: v === "true" ? 0 : undefined,
-// })
-// }
-// />
-//
-// When do you need applicants?
-//
-//
-// {formData.internship_preferences?.expected_start_date !== 0 && (
-//
-//
-//
-// Start Date *
-//
-//
-// setField("internship_preferences", {
-// ...formData.internship_preferences,
-// expected_start_date: v,
-// })
-// }
-// />
-//
-//
-// )}
-//
-//
-//
-//
-//
*/}
-
-// {/* Job Description and Requirements - Side by Side */}
-//
-// {/*
-//
-// Role overview
-//
-// {!is_editing ? (
-//
-// {job.description?.replace("/", ";")}
-//
-// ) : (
-//
setField("description", value)}
-// />
-// )}
-// */}
-
-//
-
-// {/* Job Requirements */}
-//
-//
-//
-// Requirements
-//
-
-// {/* Application Requirements - Checkboxes */}
-//
-//
-// Application Requirements:
-//
-//
-//
-// { }}
-// >
-//
-//
-//
-// Require Resume?
-//
-//
-//
-//
-// setField("internship_preferences", {
-// ...formData.internship_preferences,
-// require_github: v,
-// })
-// }
-// >
-//
-//
-//
-// Require Github?
-//
-//
-//
-//
-// setField("internship_preferences", {
-// ...formData.internship_preferences,
-// require_portfolio: v,
-// })
-// }
-// >
-//
-//
-//
-// Require Portfolio?
-//
-//
-//
-//
-// setField("internship_preferences", {
-// ...formData.internship_preferences,
-// require_cover_letter: v,
-// })
-// }
-// >
-//
-//
-//
-// Require Cover Letter?
-//
-//
-//
-// {is_editing && (
-//
-// *Note that resumes will always be required for applicants.
-//
-// )}
-//
-
-// {/* Requirements Content */}
-// {!is_editing ? (
-//
-// {job.requirements && (
-// {job.requirements}
-// )}
-//
-// ) : (
-//
setField("requirements", value)}
-// />
-// )}
-//
-//
-//
-// );
-// };
-
function ReqPill({ ok, label }: { ok: boolean; label: string }) {
return ;
}
diff --git a/components/shared/mobile-bottom-nav.tsx b/components/shared/mobile-bottom-nav.tsx
index 10b790e0..32f3dade 100644
--- a/components/shared/mobile-bottom-nav.tsx
+++ b/components/shared/mobile-bottom-nav.tsx
@@ -14,20 +14,18 @@ import {
LogIn,
} from "lucide-react";
import { cn } from "@/lib/utils";
+import { hasFormsEnabledUniversity } from "@/lib/student-forms-access";
+import { useProfileData } from "@/lib/api/student.data.api";
+import type { PublicUser } from "@/lib/db/db.types";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
-import {
- isProfileBaseComplete,
- isProfileResume,
- isProfileVerified,
-} from "@/lib/profile";
import { useAuthContext } from "@/lib/ctx-auth";
interface MobileBottomNavProps {
- profileData?: any;
+ profileData?: PublicUser | null;
}
interface NavButtonProps {
@@ -87,6 +85,8 @@ export const MobileBottomNav: React.FC = ({
const router = useRouter();
const pathname = usePathname();
const { logout, isAuthenticated } = useAuthContext();
+ const profile = useProfileData();
+ const showFormsTab = hasFormsEnabledUniversity(profileData ?? profile.data);
// Not logged in: show minimal nav with Search and Sign In
if (!isAuthenticated()) {
@@ -125,13 +125,14 @@ export const MobileBottomNav: React.FC = ({
onClick={() => router.push("/search")}
/>
- {/* Forms Button */}
- }
- label="Forms"
- isActive={pathname === "/forms"}
- onClick={() => router.push("/forms")}
- />
+ {showFormsTab && (
+ }
+ label="Forms"
+ isActive={pathname === "/forms"}
+ onClick={() => router.push("/forms")}
+ />
+ )}
{/* My Jobs Button with Popover Menu */}
= ({
{
- try {
- if (profileData && "data" in profileData) {
- // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
- const profile = profileData.data;
- // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
- if (!isProfileVerified(profile || null)) {
- router.push(`/register/verify`);
- } else if (
- // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
- !isProfileResume(profile || null) ||
- // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
- !isProfileBaseComplete(profile || null)
- ) {
- router.push(`/profile/complete-profile?dest=profile`);
- } else {
- router.push(`/profile`);
- }
- } else {
- router.push(`/profile`);
- }
- } catch {
- router.push(`/profile`);
- }
+ router.push(`/profile`);
}}
className="w-full text-left px-3 py-2 rounded hover:bg-gray-100 transition-colors text-sm"
>
diff --git a/components/shared/pdf-preview.tsx b/components/shared/pdf-preview.tsx
index 93eba944..d967e9e3 100644
--- a/components/shared/pdf-preview.tsx
+++ b/components/shared/pdf-preview.tsx
@@ -6,23 +6,38 @@ export const PDFPreview = ({ url }: { url: string }) => {
const { clientWidth, clientHeight } = useClientDimensions();
return (
-
-
+
+ {url ? (
+
+ ) : (
+
+
+ No resume provided.
+
+
+
+
+
+ )}
);
};
diff --git a/components/ui/autocomplete.tsx b/components/ui/autocomplete.tsx
index b27a4a30..1a422881 100644
--- a/components/ui/autocomplete.tsx
+++ b/components/ui/autocomplete.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useId, useMemo, useRef, useState } from "react";
+import { useEffect, useId, useMemo, useRef, useState } from "react";
import { Input } from "./input";
import { useDetectClickOutside } from "react-detect-click-outside";
import { cn } from "@/lib/utils";
@@ -27,6 +27,9 @@ function AutocompleteBase
({
multiple = true,
label,
labelAddon,
+ allowCustomValue = false,
+ preserveOptionOrder = false,
+ emptyText = "...",
...props
}: {
required?: boolean;
@@ -38,15 +41,50 @@ function AutocompleteBase({
multiple?: boolean;
label?: React.ReactNode;
labelAddon?: string;
+ allowCustomValue?: boolean;
+ preserveOptionOrder?: boolean;
+ emptyText?: string;
}) {
const [query, setQuery] = useState("");
const [isOpen, setIsOpen] = useState(false);
+ const [dropdownMaxHeight, setDropdownMaxHeight] = useState();
const { isMobile } = useAppContext();
- const ref = useDetectClickOutside({ onTriggered: () => setIsOpen(false) });
+ const clickOutsideRef = useDetectClickOutside({
+ onTriggered: () => setIsOpen(false),
+ });
+ const rootRef = useRef(null);
const inputRef = useRef(null);
+ const lastSelectionRef = useRef(0);
const inputId = useId();
+ const findExactOption = (value: string) => {
+ const normalizedValue = value.trim().toLowerCase();
+ return options.find(
+ (o) => o.name?.trim().toLowerCase() === normalizedValue,
+ );
+ };
+
+ const resolveQuerySelection = (nextQuery: string) => {
+ const text = nextQuery.trim();
+ if (!text) return false;
+
+ const exact = findExactOption(text);
+ if (exact) {
+ setter([exact.id]);
+ setQuery("");
+ return true;
+ }
+
+ if (allowCustomValue) {
+ setter([text as unknown as ID]);
+ setQuery("");
+ return true;
+ }
+
+ return false;
+ };
+
const selectedSet = useMemo(() => new Set(value ?? []), [value]);
const filtered = useMemo(() => {
@@ -54,10 +92,13 @@ function AutocompleteBase({
const base = q
? options.filter((o) => o.name?.toLowerCase().includes(q))
: options;
- return base.slice().sort((a, b) => a.name.localeCompare(b.name));
- }, [query, options]);
+ return preserveOptionOrder
+ ? base
+ : base.slice().sort((a, b) => a.name.localeCompare(b.name));
+ }, [query, options, preserveOptionOrder]);
const toggle = (id: ID) => {
+ lastSelectionRef.current = Date.now();
if (!multiple) {
setter([id]); // single-select
setIsOpen(false);
@@ -79,22 +120,71 @@ function AutocompleteBase({
const selectedLabels = useMemo(
() =>
(value ?? [])
- .map((id) => options.find((o) => o.id === id)?.name)
+ .map((id) => {
+ const label = options.find((o) => o.id === id)?.name;
+ return label ?? (allowCustomValue ? String(id) : undefined);
+ })
.filter(Boolean) as string[],
- [value, options],
+ [value, options, allowCustomValue],
);
const singleDisplay = !multiple && (selectedLabels[0] ?? "");
+ const useInlineMobileDropdown = isMobile;
+
+ useEffect(() => {
+ if (!isOpen || !useInlineMobileDropdown) {
+ setDropdownMaxHeight(undefined);
+ return;
+ }
+
+ const updateDropdownMaxHeight = () => {
+ const rootRect = rootRef.current?.getBoundingClientRect();
+ if (!rootRect) return;
+
+ const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
+ const gap = 8;
+ const availableHeight = viewportHeight - rootRect.bottom - gap;
+ setDropdownMaxHeight(Math.max(0, Math.min(400, availableHeight)));
+ };
+
+ updateDropdownMaxHeight();
+ window.addEventListener("resize", updateDropdownMaxHeight);
+ window.visualViewport?.addEventListener("resize", updateDropdownMaxHeight);
+ window.visualViewport?.addEventListener("scroll", updateDropdownMaxHeight);
+
+ return () => {
+ window.removeEventListener("resize", updateDropdownMaxHeight);
+ window.visualViewport?.removeEventListener(
+ "resize",
+ updateDropdownMaxHeight,
+ );
+ window.visualViewport?.removeEventListener(
+ "scroll",
+ updateDropdownMaxHeight,
+ );
+ };
+ }, [isOpen, useInlineMobileDropdown]);
+ const suppressMobileKeyboard = isMobile && !allowCustomValue;
// --- keyboard helpers for multi
const onMultiKeyDown = (e: React.KeyboardEvent) => {
if (!multiple) return;
+ if (allowCustomValue && e.key === "Enter" && query.trim.length === 0) {
+ const text = query.trim();
+ const exact = findExactOption(text);
+ const nextId = exact ? exact.id : (text as unknown as ID);
+ const set = new Set(value ?? []);
+ set.add(nextId);
+ setter(Array.from(set));
+ setQuery("");
+ setIsOpen(true);
+ e.preventDefault();
+ }
if (
e.key === "Backspace" &&
query.length === 0 &&
(value?.length ?? 0) > 0
) {
- // remove last chip
const last = value[value.length - 1];
removeAt(last);
e.preventDefault();
@@ -115,7 +205,12 @@ function AutocompleteBase({
return (
{
+ rootRef.current = node;
+ (
+ clickOutsideRef as React.MutableRefObject
+ ).current = node;
+ }}
>
{label ? (
@@ -134,7 +229,12 @@ function AutocompleteBase
({
"px-2 py-1 flex flex-wrap items-center gap-1",
"focus-within:border-primary focus-within:border-opacity-50",
)}
- onClick={() => inputRef.current?.focus()}
+ onClick={() => {
+ setIsOpen(true);
+ if (!suppressMobileKeyboard) {
+ inputRef.current?.focus();
+ }
+ }}
>
{(value ?? []).map((id) => {
const label = options.find((o) => o.id === id)?.name ?? String(id);
@@ -165,8 +265,24 @@ function AutocompleteBase({
setQuery(e.target.value);
setIsOpen(true);
}}
+ onBlur={() => {
+ // Delay slightly to let dropdown option clicks register first
+ setTimeout(() => {
+ if (Date.now() - lastSelectionRef.current < 250) {
+ return;
+ }
+ if (allowCustomValue && query.trim().length > 0) {
+ const text = query.trim();
+ const exact = findExactOption(text);
+ const nextId = exact ? exact.id : (text as unknown as ID);
+ setter([nextId]);
+ setQuery("");
+ }
+ }, 150);
+ }}
onKeyDown={onMultiKeyDown}
onFocus={() => setIsOpen(true)}
+ readOnly={suppressMobileKeyboard}
placeholder={(value?.length ?? 0) === 0 ? placeholder : ""}
className={cn(
"flex-1 min-w-[8ch] h-7 text-sm",
@@ -180,35 +296,66 @@ function AutocompleteBase({
{
- // typing starts a new search; selection is set on option click
- setQuery(e.target.value);
+ const nextQuery = e.target.value;
+
+ if ((value?.length ?? 0) > 0) {
+ setter([]);
+ }
+
+ const exact = findExactOption(nextQuery);
+ if (exact) {
+ setter([exact.id]);
+ setQuery("");
+ } else {
+ setQuery(nextQuery);
+ }
+
setIsOpen(true);
}}
onFocus={() => setIsOpen(true)}
- onClick={() => setIsOpen(true)}
+ onClick={(e) => {
+ setIsOpen(true);
+ if (suppressMobileKeyboard) {
+ e.currentTarget.blur();
+ }
+ }}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" && resolveQuerySelection(query)) {
+ setIsOpen(false);
+ e.preventDefault();
+ }
+ }}
+ onBlur={() => {
+ // slight delay to let dropdown option clicks register first
+ setTimeout(() => {
+ if (Date.now() - lastSelectionRef.current < 250) {
+ return;
+ }
+ resolveQuerySelection(query);
+ }, 150);
+ }}
/>
)}
{isOpen && (
<>
- {isMobile && (
- setIsOpen(false)}
- />
- )}
+ {allowCustomValue && (
+
+ Suggestions
+
+ )}
{filtered.length ? (
filtered.map((option) => {
const active = selectedSet.has(option.id);
@@ -244,8 +391,8 @@ function AutocompleteBase({
);
})
) : (
-
- No matching results.
+
+ {emptyText}
)}
@@ -267,6 +414,9 @@ export const Autocomplete = ({
value,
label,
props,
+ allowCustomValue = false,
+ preserveOptionOrder = false,
+ emptyText,
}: {
required?: boolean;
options: IAutocompleteOption[];
@@ -276,6 +426,9 @@ export const Autocomplete = ({
value?: ID | null;
label?: React.ReactNode;
props?: any[];
+ allowCustomValue?: boolean;
+ preserveOptionOrder?: boolean;
+ emptyText?: string;
}) => {
return (
@@ -287,6 +440,9 @@ export const Autocomplete = ({
placeholder={placeholder}
className={className}
label={label}
+ allowCustomValue={allowCustomValue}
+ preserveOptionOrder={preserveOptionOrder}
+ emptyText={emptyText}
{...props}
/>
);
@@ -303,6 +459,8 @@ export const AutocompleteMulti = ({
className,
value,
label,
+ allowCustomValue = false,
+ emptyText,
}: {
required?: boolean;
options: IAutocompleteOption[];
@@ -311,6 +469,8 @@ export const AutocompleteMulti = ({
className?: string;
value?: ID[];
label?: React.ReactNode;
+ allowCustomValue?: boolean;
+ emptyText?: string;
}) => {
return (
@@ -322,6 +482,8 @@ export const AutocompleteMulti = ({
placeholder={placeholder}
className={className}
label={label}
+ allowCustomValue={allowCustomValue}
+ emptyText={emptyText}
/>
);
};
@@ -357,8 +519,12 @@ export function AutocompleteTreeMulti({
}) {
const [query, setQuery] = useState("");
const [isOpen, setIsOpen] = useState(false);
+ const [dropdownMaxHeight, setDropdownMaxHeight] = useState();
const { isMobile } = useAppContext();
- const ref = useDetectClickOutside({ onTriggered: () => setIsOpen(false) });
+ const clickOutsideRef = useDetectClickOutside({
+ onTriggered: () => setIsOpen(false),
+ });
+ const rootRef = useRef(null);
const inputRef = useRef(null);
// Build ID -> label map (child shows "Parent · Child")
@@ -463,10 +629,49 @@ export function AutocompleteTreeMulti({
[selected, labelMap],
);
+ useEffect(() => {
+ if (!isOpen || !isMobile) {
+ setDropdownMaxHeight(undefined);
+ return;
+ }
+
+ const updateDropdownMaxHeight = () => {
+ const rootRect = rootRef.current?.getBoundingClientRect();
+ if (!rootRect) return;
+
+ const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
+ const gap = 8;
+ const availableHeight = viewportHeight - rootRect.bottom - gap;
+ setDropdownMaxHeight(Math.max(0, Math.min(400, availableHeight)));
+ };
+
+ updateDropdownMaxHeight();
+ window.addEventListener("resize", updateDropdownMaxHeight);
+ window.visualViewport?.addEventListener("resize", updateDropdownMaxHeight);
+ window.visualViewport?.addEventListener("scroll", updateDropdownMaxHeight);
+
+ return () => {
+ window.removeEventListener("resize", updateDropdownMaxHeight);
+ window.visualViewport?.removeEventListener(
+ "resize",
+ updateDropdownMaxHeight,
+ );
+ window.visualViewport?.removeEventListener(
+ "scroll",
+ updateDropdownMaxHeight,
+ );
+ };
+ }, [isOpen, isMobile]);
+
return (
{
+ rootRef.current = node;
+ (
+ clickOutsideRef as React.MutableRefObject
+ ).current = node;
+ }}
>
{label ? (
@@ -509,20 +714,13 @@ export function AutocompleteTreeMulti({
{isOpen && (
<>
- {isMobile && (
- setIsOpen(false)}
- />
- )}
{filteredTree.length ? (
filteredTree.map((p) => {
@@ -597,7 +795,7 @@ export function AutocompleteTreeMulti({
})
) : (
- No matching results.
+ ...
)}
diff --git a/components/ui/labels.tsx b/components/ui/labels.tsx
index d5057251..94ffb9c6 100644
--- a/components/ui/labels.tsx
+++ b/components/ui/labels.tsx
@@ -163,9 +163,14 @@ export const EmployerPropertyLabel: ValueComponent = ({
);
};
-export const ErrorLabel: ValueComponent = ({ value, fallback }) => {
+export const ErrorLabel: ValueComponent = ({ value, fallback, className }) => {
return value ? (
-
+
diff --git a/hooks/forms/filloutFormProcess.tsx b/hooks/forms/filloutFormProcess.tsx
index 7b7ac090..eda8f8ee 100644
--- a/hooks/forms/filloutFormProcess.tsx
+++ b/hooks/forms/filloutFormProcess.tsx
@@ -9,6 +9,7 @@
import { useMyForms } from "@/app/student/forms/myforms.ctx";
import { useFormRendererContext } from "@/components/features/student/forms/form-renderer.ctx";
+import { toastPresets } from "@/components/ui/sonner-toast";
import { FormService } from "@/lib/api/services";
import { useClientProcess } from "@betterinternship/components";
import { useCallback, useMemo } from "react";
@@ -38,18 +39,18 @@ export const useFormFilloutProcessRunner = () => {
[myForms.forms],
),
onSuccess: (processId, _processName, result) => {
- toast.success(`Generated ${form.formLabel}!`, {
+ toast.success(`Generated ${form.formLabel}`, {
id: processId,
duration: 2000,
+ ...toastPresets.success,
});
- console.log("FILLOUT FORM RESULT: ", result);
},
onFailure: (processId, _processName, error) => {
toast.error(`Could not generate ${form.formLabel}: ${error}`, {
id: processId,
duration: 2000,
+ ...toastPresets.error,
});
- console.log("FILLOUT FORM ERROR: ", error);
},
});
};
diff --git a/hooks/use-conversation.tsx b/hooks/use-conversation.tsx
deleted file mode 100644
index 8fd22209..00000000
--- a/hooks/use-conversation.tsx
+++ /dev/null
@@ -1,235 +0,0 @@
-/**
- * @ Author: BetterInternship
- * @ Create Time: 2025-07-11 17:06:17
- * @ Modified time: 2025-07-27 14:06:37
- * @ Description:
- *
- * Used by student users for managing conversation state.
- */
-
-"use client";
-
-import { APIClient, APIRouteBuilder } from "@/lib/api/api-client";
-import { usePocketbase } from "@/lib/pocketbase";
-import { usePathname } from "next/navigation";
-import {
- createContext,
- useCallback,
- useContext,
- useEffect,
- useState,
-} from "react";
-import { useRef } from "react";
-
-interface Message {
- sender_id: string;
- message: string;
- timestamp: string;
-}
-
-/**
- * Allows to manage conversation state.
- *
- * @hook
- * @param conversationId
- */
-export const useConversation = (
- type: "employer" | "user",
- conversationId?: string
-) => {
- const [messages, setMessages] = useState
([]);
- const [senderId, setSenderId] = useState("");
- const [loading, setLoading] = useState(true);
- const { pb, user, refresh } = usePocketbase();
- const [unsubscribe, setUnsubscribe] = useState(() => () => {});
- const unsubscribeRef = useRef<(() => void) | null>(null);
- const setLoadingFalse = () => setTimeout(() => setLoading(false), 500);
-
- const seenConversation = useCallback(async () => {
- if (!conversationId) return;
- const route =
- type === "employer"
- ? APIRouteBuilder("conversations")
- .r("read", "hire", conversationId)
- .build()
- : APIRouteBuilder("conversations").r("read", conversationId).build();
- await APIClient.post(route);
- }, [conversationId]);
-
- useEffect(() => {
- refresh();
- }, []);
-
- useEffect(() => {
- setLoading(true);
-
- unsubscribeRef.current?.();
- unsubscribeRef.current = null;
-
- if (!user || !conversationId || !conversationId.trim().length) {
- setMessages([]);
- setSenderId("");
- setLoadingFalse();
- return;
- }
-
- // Pull messages first
- pb.collection("conversations")
- .getOne(conversationId)
- .then(async (conversation) => {
- setSenderId(
- conversation.subscribers.find((id: string) => id !== user.id)
- );
- setMessages(conversation.contents);
- await seenConversation();
- setLoadingFalse();
- });
-
- // Subscribe to messages
- pb.collection("conversations")
- .subscribe(
- "*",
- async (e) => {
- const conversation = e.record;
- setMessages(conversation.contents);
- await seenConversation();
- setLoadingFalse();
- },
- {
- filter: `id = '${conversationId}'`,
- }
- )
- .then((u) => unsubscribeRef.current = () => {
- setLoadingFalse();
- u();
- });
-
- return () => {
- unsubscribeRef.current?.();
- unsubscribeRef.current = null;
- };
- }, [user, pb, conversationId, seenConversation]);
-
- return {
- messages,
- senderId,
- loading,
- unsubscribe: () => unsubscribeRef.current?.(),
- };
-};
-
-/**
- * Create a new context. We use contexts so we dont have to keep sub/unsubbing to pocketbase.
- */
-interface IConversationsContext {
- data: any[];
- unreads: any[];
- loading: boolean;
-}
-const ConversationsContext = createContext(
- {} as IConversationsContext
-);
-export const ConversationsContextProvider = ({
- type,
- children,
-}: {
- type: "user" | "employer";
- children: React.ReactNode;
-}) => {
- const pathname = usePathname();
- const { pb, user, refresh } = usePocketbase();
- // ! change to Conversation type later on
- const [conversations, setConversations] = useState([]);
- const [unreadConversations, setUnreadConversations] = useState([]);
- const [loading, setLoading] = useState(true);
-
- // Subscribe and init conversations
- useEffect(() => {
- let unsubscribe = () => {};
- if (!user) return () => unsubscribe();
-
- // Pull all convos first
- pb.collection("users")
- .getOne(user.id, {
- expand: "conversations",
- fields: "*,expand.conversations.id,expand.conversations.subscribers",
- })
- .then((subscriber) => {
- const conversations = subscriber.expand?.conversations?.map(
- (conversation: any) => ({
- ...conversation,
- last_unread: subscriber.last_unreads[conversation.id],
- last_read: subscriber.last_reads[conversation.id],
- })
- );
- const unreads =
- conversations?.filter(
- (conversation: any) =>
- conversation?.last_unread?.timestamp !==
- conversation?.last_read?.timestamp
- ) ?? [];
-
- setConversations(conversations);
- setUnreadConversations(unreads);
- setLoading(false);
- })
- .catch(async (e) => {
- console.log(e);
- await refresh();
- });
-
- // Subscribe to notifications
- const unsubscribePromise = pb
- .collection("users")
- .subscribe(
- "*",
- function (e) {
- const subscriber = e.record;
- const conversations = subscriber.expand?.conversations?.map(
- (conversation: any) => ({
- ...conversation,
- last_unread: subscriber.last_unreads[conversation.id],
- last_read: subscriber.last_reads[conversation.id],
- })
- );
- const unreads = conversations.filter(
- (conversation: any) =>
- conversation?.last_unread?.timestamp !==
- conversation?.last_read?.timestamp
- );
-
- setConversations(conversations);
- setUnreadConversations(unreads);
- setLoading(false);
- },
- {
- filter: `id = '${user.id}'`,
- expand: "conversations",
- fields: "*,expand.conversations.id,expand.conversations.subscribers",
- }
- )
- .then((u) => (unsubscribe = u));
-
- return () =>
- (async () => {
- const unsubscribe = await unsubscribePromise;
- unsubscribe();
- })();
- }, [user]);
-
- const conversationsContext = {
- data: conversations,
- unreads: unreadConversations,
- loading,
- };
-
- return (
-
- {children}
-
- );
-};
-
-export const useConversations = () => {
- return useContext(ConversationsContext);
-};
diff --git a/hooks/use-file.tsx b/hooks/use-file.tsx
index 7c55d7d7..59351493 100644
--- a/hooks/use-file.tsx
+++ b/hooks/use-file.tsx
@@ -1,7 +1,7 @@
/**
* @ Author: BetterInternship
* @ Create Time: 2025-06-19 06:01:21
- * @ Modified time: 2026-03-28 18:54:09
+ * @ Modified time: 2026-05-02 00:54:34
* @ Description:
*
* Properly handles dealing with files stored in GCS and their local state.
@@ -14,7 +14,7 @@ import { useCallback, useImperativeHandle, useRef, useState } from "react";
interface IUseFile {
url: string;
loading: boolean;
- sync: () => Promise;
+ sync: (...args: any[]) => Promise;
}
// Valid MimeTypes
@@ -40,8 +40,8 @@ export const useFile = ({
fetcher,
defaultURL = "",
}: {
- route: string;
- fetcher: () => Promise;
+ route: string | ((...args: any[]) => string);
+ fetcher: (...args: any[]) => Promise;
defaultURL?: string;
}): IUseFile => {
const [url, setURL] = useState(defaultURL);
@@ -53,26 +53,31 @@ export const useFile = ({
*
* @returns
*/
- const synchronize = useCallback(async () => {
- const { success, empty, hash } = await fetcher();
+ const synchronize = useCallback(
+ async (...args: any[]) => {
+ const { success, empty, hash } = await fetcher(...args);
+
+ // Something went wrong
+ if (!success) {
+ console.error("Could not fetch file.");
+ setLoading(false);
+ return;
+ }
- // Something went wrong
- if (!success) {
- console.error("Could not fetch file.");
- setLoading(false);
- return;
- }
+ // File has not been uploaded by host / source
+ if (empty) {
+ setLoading(false);
+ return;
+ }
- // File has not been uploaded by host / source
- if (empty) {
+ // Update url
+ const resolvedRoute =
+ typeof route === "function" ? route(...args) : route;
+ setURL(`${process.env.NEXT_PUBLIC_API_URL}${resolvedRoute}?hash=${hash}`);
setLoading(false);
- return;
- }
-
- // Update url
- setURL(`${process.env.NEXT_PUBLIC_API_URL}${route}?hash=${hash}`);
- setLoading(false);
- }, [route]);
+ },
+ [fetcher, route],
+ );
return {
url,
diff --git a/hooks/use-student-otp-verification.ts b/hooks/use-student-otp-verification.ts
new file mode 100644
index 00000000..7017e308
--- /dev/null
+++ b/hooks/use-student-otp-verification.ts
@@ -0,0 +1,213 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import { useQueryClient } from "@tanstack/react-query";
+import { AuthService } from "@/lib/api/services";
+
+type OtpResponse = {
+ success?: boolean;
+ message?: string;
+ error?: string;
+};
+
+type OtpResult = {
+ success: boolean;
+ message?: string;
+ response?: OtpResponse;
+};
+
+type OtpOptions = {
+ failureMessage?: string;
+ networkErrorMessage?: string;
+};
+
+type RequestOtpOptions = OtpOptions & {
+ startCooldown?: boolean;
+};
+
+type UseStudentOtpVerificationOptions = {
+ email?: string;
+ autoActivate?: {
+ enabled?: boolean;
+ failureMessage?: string;
+ networkErrorMessage?: string;
+ onSuccess: (result: OtpResult) => void;
+ };
+ cooldownSeconds?: number;
+ initialCoolingDown?: boolean;
+};
+
+export const STUDENT_OTP_LENGTH = 6;
+const DEFAULT_COOLDOWN_SECONDS = 60;
+
+const getResponseError = (
+ response: OtpResponse | undefined,
+ fallback: string,
+) => response?.message?.trim() || response?.error?.trim() || fallback;
+
+export function useStudentOtpVerification({
+ email,
+ autoActivate,
+ cooldownSeconds = DEFAULT_COOLDOWN_SECONDS,
+ initialCoolingDown = false,
+}: UseStudentOtpVerificationOptions = {}) {
+ const queryClient = useQueryClient();
+ const sendingRef = useRef(false);
+ const activatingRef = useRef(false);
+ const autoActivationAttemptRef = useRef("");
+
+ const [otp, setOtpState] = useState("");
+ const [sending, setSending] = useState(false);
+ const [activating, setActivating] = useState(false);
+ const [error, setError] = useState("");
+ const [isCoolingDown, setIsCoolingDown] = useState(initialCoolingDown);
+ const [countdown, setCountdown] = useState(
+ initialCoolingDown ? cooldownSeconds : 0,
+ );
+
+ const prevEmailRef = useRef(email);
+ useEffect(() => {
+ if (email !== prevEmailRef.current) {
+ setError("");
+ prevEmailRef.current = email;
+ }
+ }, [email]);
+
+ useEffect(() => {
+ if (!isCoolingDown) return;
+
+ if (countdown > 0) {
+ const timer = setTimeout(() => setCountdown(countdown - 1), 1000);
+ return () => clearTimeout(timer);
+ }
+
+ setIsCoolingDown(false);
+ }, [countdown, isCoolingDown]);
+
+ const setOtp = useCallback((value: string) => {
+ autoActivationAttemptRef.current = "";
+ setError("");
+ setOtpState(value);
+ }, []);
+
+ const startCooldown = useCallback(
+ (seconds = cooldownSeconds) => {
+ setIsCoolingDown(true);
+ setCountdown(seconds);
+ },
+ [cooldownSeconds],
+ );
+
+ const requestOtp = useCallback(
+ async ({
+ failureMessage = "Couldn't send verification code. Try again.",
+ networkErrorMessage = failureMessage,
+ startCooldown: shouldStartCooldown = true,
+ }: RequestOtpOptions = {}): Promise => {
+ if (!email || sendingRef.current || isCoolingDown) return null;
+
+ sendingRef.current = true;
+ setSending(true);
+ setError("");
+
+ try {
+ const response = await AuthService.requestActivation(email);
+
+ if (response?.success !== true) {
+ const message = getResponseError(response, failureMessage);
+ setError(message);
+ return { success: false, message, response };
+ }
+
+ if (shouldStartCooldown) startCooldown();
+ return { success: true, response };
+ } catch {
+ setError(networkErrorMessage);
+ return { success: false, message: networkErrorMessage };
+ } finally {
+ sendingRef.current = false;
+ setSending(false);
+ }
+ },
+ [email, isCoolingDown, startCooldown],
+ );
+
+ const activateOtp = useCallback(
+ async (
+ otp: string,
+ {
+ failureMessage = "Verification code not valid.",
+ networkErrorMessage = "Couldn't verify your code. Try again.",
+ }: OtpOptions = {},
+ ): Promise => {
+ if (!email || activatingRef.current) return null;
+
+ activatingRef.current = true;
+ setActivating(true);
+ setError("");
+
+ try {
+ const response = await AuthService.activate(email, otp);
+ await queryClient.invalidateQueries({ queryKey: ["my-profile"] });
+
+ if (response?.success === true) {
+ return { success: true, response };
+ }
+
+ const message = getResponseError(response, failureMessage);
+ setError(message);
+ return { success: false, message, response };
+ } catch {
+ setError(networkErrorMessage);
+ return { success: false, message: networkErrorMessage };
+ } finally {
+ activatingRef.current = false;
+ setActivating(false);
+ }
+ },
+ [email, queryClient],
+ );
+
+ const autoActivateEnabled = autoActivate?.enabled ?? true;
+ const autoActivateFailureMessage = autoActivate?.failureMessage;
+ const autoActivateNetworkErrorMessage = autoActivate?.networkErrorMessage;
+ const autoActivateOnSuccess = autoActivate?.onSuccess;
+
+ useEffect(() => {
+ if (!autoActivateEnabled || !email || !autoActivateOnSuccess) return;
+ if (otp.length !== STUDENT_OTP_LENGTH) return;
+
+ const attemptKey = `${email}:${otp}`;
+ if (autoActivationAttemptRef.current === attemptKey) return;
+ autoActivationAttemptRef.current = attemptKey;
+
+ void (async () => {
+ const result = await activateOtp(otp, {
+ failureMessage: autoActivateFailureMessage,
+ networkErrorMessage: autoActivateNetworkErrorMessage,
+ });
+ if (result?.success === true) autoActivateOnSuccess(result);
+ })();
+ }, [
+ activateOtp,
+ email,
+ autoActivateEnabled,
+ autoActivateFailureMessage,
+ autoActivateNetworkErrorMessage,
+ autoActivateOnSuccess,
+ otp,
+ ]);
+
+ return {
+ activating,
+ countdown,
+ error,
+ isCoolingDown,
+ otpInputProps: {
+ onChange: setOtp,
+ value: otp,
+ },
+ requestOtp,
+ sending,
+ };
+}
diff --git a/lib/api/api-client.ts b/lib/api/api-client.ts
index 91e861ed..0c0763f4 100644
--- a/lib/api/api-client.ts
+++ b/lib/api/api-client.ts
@@ -2,8 +2,10 @@
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL;
if (!API_BASE_URL) console.warn("[WARNING]: Base API URL is not set.");
+type ParamValue = string | number | boolean | Array;
+
interface Params {
- [key: string]: any;
+ [key: string]: ParamValue | null | undefined;
}
/**
@@ -15,8 +17,14 @@ interface Params {
const createParameterString = (params: Params) => {
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
- if (value !== undefined && value !== null && value !== "")
- searchParams.append(key, value.toString());
+ if (value === undefined || value === null || value === "") return;
+
+ if (Array.isArray(value)) {
+ value.forEach((item) => searchParams.append(key, item.toString()));
+ return;
+ }
+
+ searchParams.append(key, value.toString());
});
return searchParams.toString();
};
@@ -83,7 +91,9 @@ class FetchClient {
try {
const response = await fetch(url, config);
if (!response.ok && response.status !== 304) {
- const errorData = await response.json().catch(() => ({}));
+ const errorData = (await response.json().catch(() => ({}))) as {
+ message?: string;
+ };
console.warn(`${url}: ${errorData.message || response.status}`);
return { error: errorData.message } as T;
// throw new Error(errorData.message || "Something went wrong.");
@@ -100,42 +110,54 @@ class FetchClient {
}
}
- async get(url: string): Promise {
- return this.request(url, { method: "GET" });
+ async get(url: string, options: RequestInit = {}): Promise {
+ return this.request(url, { ...options, method: "GET" });
}
- async post(url: string, data?: any, type: string = "json"): Promise {
+ async post(
+ url: string,
+ data?: unknown,
+ type: string = "json",
+ options: RequestInit = {},
+ ): Promise {
return this.request(
url,
{
+ ...options,
method: "POST",
body: data
? type === "json"
? JSON.stringify(data)
- : data
+ : (data as BodyInit)
: undefined,
},
type,
);
}
- async put(url: string, data?: any, type: string = "json"): Promise {
+ async put(
+ url: string,
+ data?: unknown,
+ type: string = "json",
+ options: RequestInit = {},
+ ): Promise {
return this.request(
url,
{
+ ...options,
method: "PUT",
body: data
? type === "json"
? JSON.stringify(data)
- : data
+ : (data as BodyInit)
: undefined,
},
type,
);
}
- async delete(url: string): Promise {
- return this.request(url, { method: "DELETE" });
+ async delete(url: string, options: RequestInit = {}): Promise {
+ return this.request(url, { ...options, method: "DELETE" });
}
}
diff --git a/lib/api/services.ts b/lib/api/services.ts
index ef161b71..cb4e34fc 100644
--- a/lib/api/services.ts
+++ b/lib/api/services.ts
@@ -1,4 +1,4 @@
-import { FormTemplate } from "../db/use-moa-backend";
+import { FormTemplate } from "../db/forms-db.types";
import {
Conversation,
CreateJobChallengeListingPayload,
@@ -157,6 +157,11 @@ interface SaveJobResponse extends FetchResponse {
message: string;
}
+interface JoinFormGroupResponse extends FetchResponse {
+ success: boolean;
+ message?: string;
+}
+
export type ApproveSignatoryRequest = {
pendingDocumentId: string;
signatoryName: string;
@@ -202,10 +207,11 @@ export const FormService = {
},
async getMyFormTemplates() {
- const { formTemplates } = await APIClient.get<{
+ const response = await APIClient.get<{
+ formGroupDescription: string;
formTemplates: FormTemplate[];
}>(APIRouteBuilder("users").r("me/form-templates").build());
- return formTemplates;
+ return response;
},
async getFormTemplatesLastUpdated() {
@@ -269,10 +275,33 @@ export const FormService = {
},
};
+interface UploadResumeResponse {
+ resume: {
+ id: string;
+ label: string;
+ filename: string;
+ uploaded_at: string;
+ };
+ success?: boolean;
+ message?: string;
+}
+
+interface ResumeArrayResponse {
+ resumes: {
+ id: string;
+ label: string;
+ filename: string;
+ uploaded_at: string;
+ }[];
+ success?: boolean;
+ message?: string;
+}
+
export const UserService = {
- async getMyProfile() {
+ async getMyProfile(options: RequestInit = {}) {
const result = APIClient.get(
APIRouteBuilder("users").r("me").build(),
+ options,
);
return result;
},
@@ -284,17 +313,22 @@ export const UserService = {
);
},
- async parseResume(form: FormData) {
- return APIClient.post(
- APIRouteBuilder("users").r("me", "extract-resume").build(),
- form,
- "form-data",
+ async joinFormGroup(code: string) {
+ return APIClient.post(
+ APIRouteBuilder("users").r("join-form-group").build(),
+ { code },
);
},
- async getMyResumeURL() {
+ async getMyResumes() {
+ return APIClient.get(
+ APIRouteBuilder("users").r("me", "resumes").build(),
+ );
+ },
+
+ async getMyResumeURL(resumeId: string) {
return APIClient.get(
- APIRouteBuilder("users").r("me", "resume").build(),
+ APIRouteBuilder("users").r("me", "resume", resumeId).build(),
);
},
@@ -318,20 +352,33 @@ export const UserService = {
);
},
- async getUserResumeURL(userId: string) {
+ async getUserResumeURL(userId: string, resumeId: string) {
return APIClient.get(
- APIRouteBuilder("users").r(userId, "resume").build(),
+ APIRouteBuilder("users").r(userId, "resume", resumeId).build(),
);
},
- async updateMyResume(form: FormData) {
- return APIClient.put(
+ async uploadMyResume(form: FormData) {
+ return APIClient.put(
APIRouteBuilder("users").r("me", "resume").build(),
form,
"form-data",
);
},
+ async updateMyResume(resumeId: string, label: string) {
+ return APIClient.post(
+ APIRouteBuilder("users").r("me", "resume", "update", resumeId).build(),
+ { label: label },
+ );
+ },
+
+ async deleteMyResume(resumeId: string) {
+ return APIClient.post(
+ APIRouteBuilder("users").r("me", "resume", "delete", resumeId).build(),
+ );
+ },
+
async saveJob(jobId: string) {
return APIClient.post(
APIRouteBuilder("users").r("save-job").build(),
@@ -491,6 +538,7 @@ export const ApplicationService = {
async createApplication(data: {
job_id: string;
+ resume_id: string;
cover_letter?: string;
challenge_submission?: string;
}) {
diff --git a/lib/api/student.actions.api.ts b/lib/api/student.actions.api.ts
index 9a48b7a5..04de8c90 100644
--- a/lib/api/student.actions.api.ts
+++ b/lib/api/student.actions.api.ts
@@ -1,7 +1,7 @@
/**
* @ Author: BetterInternship
* @ Create Time: 2025-09-30 20:27:33
- * @ Modified time: 2025-10-01 03:01:23
+ * @ Modified time: 2026-05-01 23:42:55
* @ Description:
*
* This file should contain all actions on the users side of the platform.
@@ -11,6 +11,7 @@
import { useQueryClient, useMutation } from "@tanstack/react-query";
import { ApplicationService, UserService } from "./services";
+import { PublicUser } from "../db/db.types";
/**
* Provides a cleaner interface to handle interactions with the backend.
@@ -24,12 +25,17 @@ export const useApplicationActions = () => {
const queryClient = useQueryClient();
const actions = {
create: useMutation({
- mutationFn: ApplicationService.createApplication,
+ mutationFn: (data: {
+ job_id: string;
+ resume_id: string;
+ cover_letter?: string;
+ challenge_submission?: string;
+ }) => ApplicationService.createApplication(data),
onSettled: () =>
queryClient.invalidateQueries({ queryKey: ["my-applications"] }),
}),
withdraw: useMutation({
- mutationFn: ApplicationService.withdrawApplication,
+ mutationFn: (id: string) => ApplicationService.withdrawApplication(id),
onSettled: () =>
queryClient.invalidateQueries({ queryKey: ["my-applications"] }),
}),
@@ -48,7 +54,7 @@ export const useJobActions = () => {
const queryClient = useQueryClient();
const actions = {
toggleSave: useMutation({
- mutationFn: UserService.saveJob,
+ mutationFn: (jobId: string) => UserService.saveJob(jobId),
onSettled: () =>
queryClient.invalidateQueries({ queryKey: ["my-saved-jobs"] }),
}),
@@ -66,11 +72,12 @@ export const useProfileActions = () => {
const queryClient = useQueryClient();
const actions = {
update: useMutation({
- mutationFn: UserService.updateMyProfile,
+ mutationFn: (data: Partial) =>
+ UserService.updateMyProfile(data),
onSettled: () => {
- queryClient.invalidateQueries({ queryKey: ["my-profile"] });
- queryClient.invalidateQueries({ queryKey: ["my-form-templates"] });
- }
+ void queryClient.invalidateQueries({ queryKey: ["my-profile"] });
+ void queryClient.invalidateQueries({ queryKey: ["my-form-templates"] });
+ },
}),
};
diff --git a/lib/api/student.data.api.ts b/lib/api/student.data.api.ts
index 42c5e4ec..651612fe 100644
--- a/lib/api/student.data.api.ts
+++ b/lib/api/student.data.api.ts
@@ -141,7 +141,7 @@ export function useJobsData(
// MOA filter
const hasMoa = dbMoas.check(
job?.employer_id ?? "",
- dbRefs.get_university_by_name("DLSU - Manila")?.id ?? "",
+ profile.data?.university ?? "",
)
? "Has MOA"
: "No MOA";
diff --git a/lib/application.tsx b/lib/application.tsx
index 93185b16..15cf6850 100644
--- a/lib/application.tsx
+++ b/lib/application.tsx
@@ -1,4 +1,3 @@
-import { useApplicationActions } from "./api/student.actions.api";
import { Job } from "./db/db.types";
/**
diff --git a/lib/ctx-auth.tsx b/lib/ctx-auth.tsx
index bb55424f..0b6e8d4a 100644
--- a/lib/ctx-auth.tsx
+++ b/lib/ctx-auth.tsx
@@ -6,7 +6,6 @@ import { AuthService, UserService } from "@/lib/api/services";
import { useRouter } from "next/navigation";
import { FetchResponse } from "@/lib/api/use-fetch";
import { useQueryClient } from "@tanstack/react-query";
-import { usePocketbase } from "./pocketbase";
interface IAuthContext {
register: (
@@ -40,13 +39,12 @@ export const AuthContextProvider = ({
children: React.ReactNode;
}) => {
const router = useRouter();
- const pocketbase = usePocketbase();
const queryClient = useQueryClient();
const [isLoading, setIsLoading] = useState(true);
const [isAuthenticated, setIsAuthenticated] = useState(() => {
if (typeof window === "undefined") return false;
const isAuthed = sessionStorage.getItem("is_authenticated");
- return isAuthed ? JSON.parse(isAuthed) : false;
+ return isAuthed ? (JSON.parse(isAuthed) as boolean) : false;
});
const refreshAuthentication = async () => {
@@ -57,7 +55,6 @@ export const AuthContextProvider = ({
return null;
}
- pocketbase.refresh();
setIsAuthenticated(true);
setIsLoading(false);
return response.user;
@@ -68,15 +65,22 @@ export const AuthContextProvider = ({
}, []);
const register = async (user: Partial) => {
- 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"] });
- return await AuthService.register(user);
+ const response = await AuthService.register(user);
+
+ if (response?.success) {
+ await refreshAuthentication();
+ 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"] });
+ }
+
+ return response;
};
const verify = async (userId: string, key: string) => {
@@ -90,12 +94,12 @@ export const AuthContextProvider = ({
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 pocketbase.logout();
await AuthService.logout();
await queryClient.invalidateQueries({ queryKey: ["jobs"] });
await queryClient.invalidateQueries({ queryKey: ["my-profile"] });
@@ -105,6 +109,7 @@ export const AuthContextProvider = ({
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(false);
};
diff --git a/lib/db/db.types.ts b/lib/db/db.types.ts
index 2dd7ed4b..7a2e3a0b 100644
--- a/lib/db/db.types.ts
+++ b/lib/db/db.types.ts
@@ -1,44 +1,62 @@
import {
- Database as _Database,
- Json,
- Tables,
+ DB,
+ CareerRefColleges,
+ CareerRefJobAllowances,
+ CareerRefJobCategories,
+ CareerRefJobModes,
+ CareerRefJobTypes,
+ CareerRefUniversities,
+ CareerJobsChallenge,
+ CareerRefJobPayFreq,
+ CareerRefAppStatuses,
+ CareerRefIndustries,
+ CareerRefDepartments,
+ CareerMoa as _Moa,
+ CareerUsers,
+ CareerEmployers,
+ CareerConversations,
+ CareerEmployerUsers,
+ CareerJobs,
+ CareerApplications,
+ CareerSavedJobs,
+ CareerResumes,
} from "@betterinternship/schema.base";
+import { Selectable } from "kysely";
-export type Database = _Database;
-export type College = Tables<"ref_colleges">;
-export type University = Tables<"ref_universities">;
-export type JobType = Tables<"ref_job_types">;
-export type JobAllowance = Tables<"ref_job_allowances">;
-export type JobCategory = Tables<"ref_job_categories">;
-export type JobPayFreq = Tables<"ref_job_pay_freq">;
-export type JobMode = Tables<"ref_job_modes">;
-export type JobChallenge = Tables<"jobs_challenge">;
-export type AppStatus = Tables<"ref_app_statuses">;
-export type Industry = Tables<"ref_industries">;
-export type Department = Tables<"ref_departments">;
-export type Moa = Tables<"moa">;
-export type PrivateUser = Tables<"users">;
-type _PublicUserBase = Omit, "verification_hash">;
-export type PublicUser = Omit<_PublicUserBase, "internship_preferences"> & {
+export type Database = DB;
+export type College = Selectable;
+export type University = Selectable;
+export type JobType = Selectable;
+export type JobAllowance = Selectable;
+export type JobCategory = Selectable;
+export type JobPayFreq = Selectable;
+export type JobMode = Selectable;
+export type JobChallenge = Selectable;
+export type AppStatus = Selectable;
+export type Industry = Selectable;
+export type Department = Selectable;
+export type Moa = Selectable<_Moa>;
+export type Resume = Selectable;
+export type PrivateUser = Selectable;
+export type PublicUser = Omit<
+ PrivateUser,
+ "verification_hash" | "internship_preferences"
+> & {
internship_preferences?: InternshipPreferences;
};
-export type Employer = Partial>;
-export type User = Partial>;
-export interface Conversation extends Tables<"conversations"> {
+export type Employer = Partial>;
+export type User = Partial>;
+export interface Conversation extends Selectable {
employers?: Partial;
employer?: Partial;
users?: Partial;
user?: Partial;
}
-export type PrivateEmployerUser = Tables<"employer_users">;
-export type PublicEmployerUser = Omit<
- Tables<"employer_users">,
- "is_deactivated"
->;
-export interface MoA extends Partial> {}
+export type PrivateEmployerUser = Selectable;
+export type PublicEmployerUser = Omit;
export interface Job extends Omit<
- Partial>,
+ Partial>,
"internship_preferences"
> {
employer?: Partial;
@@ -60,22 +78,28 @@ export type UpdateJobChallengeListingPayload = Partial & {
challenge?: JobChallengePayload | Partial | null;
};
-export interface UserApplication extends Partial> {
+export interface UserApplication extends Partial<
+ Selectable
+> {
job?: Partial;
jobs?: Partial;
employer?: Partial;
employers?: Partial;
+ resume?: Partial;
}
-export interface EmployerApplication extends Partial> {
+export interface EmployerApplication extends Partial<
+ Selectable
+> {
job?: Partial;
jobs?: Partial;
user?: Partial;
users?: Partial;
+ resume_id: string;
challenge_submission?: string | null;
}
-export interface SavedJob extends Partial> {
+export interface SavedJob extends Partial> {
job?: Partial;
jobs?: Partial;
}
@@ -101,3 +125,112 @@ export type ListingInternshipPreferences = {
require_portfolio?: boolean | null;
require_cover_letter?: boolean | null;
};
+
+export interface RefDomain {
+ id: string;
+ name: string;
+ university_id: string;
+}
+
+export interface RefsData {
+ colleges: College[];
+ departments: Department[];
+ universities: University[];
+ job_types: JobType[];
+ job_modes: JobMode[];
+ job_allowances: JobAllowance[];
+ job_categories: JobCategory[];
+ job_pay_freq: JobPayFreq[];
+ app_statuses: AppStatus[];
+ industries: Industry[];
+ domains: RefDomain[];
+}
+
+export interface IRefsContext extends RefsData {
+ ref_loading: boolean;
+
+ get_college: (id: string | null | undefined) => College | null;
+ to_college_name: (
+ id: string | null | undefined,
+ def?: string | null,
+ ) => string | null;
+ get_college_by_name: (name: string | null | undefined) => College | null;
+
+ get_university: (id: string | null | undefined) => University | null;
+ to_university_name: (
+ id: string | null | undefined,
+ def?: string | null,
+ ) => string | null;
+ get_university_by_name: (
+ name: string | null | undefined,
+ ) => University | null;
+
+ get_job_type: (id: number | null | undefined) => JobType | null;
+ to_job_type_name: (
+ id: number | null | undefined,
+ def?: string | null,
+ ) => string | null;
+ get_job_type_by_name: (name: string | null | undefined) => JobType | null;
+
+ get_job_mode: (id: number | null | undefined) => JobMode | null;
+ to_job_mode_name: (
+ id: number | null | undefined,
+ def?: string | null,
+ ) => string | null;
+ get_job_mode_by_name: (name: string | null | undefined) => JobMode | null;
+
+ get_job_allowance: (id: number | null | undefined) => JobAllowance | null;
+ to_job_allowance_name: (
+ id: number | null | undefined,
+ def?: string | null,
+ ) => string | null;
+ get_job_allowance_by_name: (
+ name: string | null | undefined,
+ ) => JobAllowance | null;
+
+ get_job_pay_freq: (id: number | null | undefined) => JobPayFreq | null;
+ to_job_pay_freq_name: (
+ id: number | null | undefined,
+ def?: string | null,
+ ) => string | null;
+ get_job_pay_freq_by_name: (
+ name: string | null | undefined,
+ ) => JobPayFreq | null;
+
+ get_app_status: (id: number | null | undefined) => AppStatus | null;
+ to_app_status_name: (
+ id: number | null | undefined,
+ def?: string | null,
+ ) => string | null;
+ get_app_status_by_name: (name: string | null | undefined) => AppStatus | null;
+
+ get_industry: (id: string | null | undefined) => Industry | null;
+ to_industry_name: (
+ id: string | null | undefined,
+ def?: string | null,
+ ) => string | null;
+ get_industry_by_name: (name: string | null | undefined) => Industry | null;
+
+ get_job_category: (id: string | null | undefined) => JobCategory | null;
+ to_job_category_name: (
+ id: string | null | undefined,
+ def?: string | null,
+ ) => string | null;
+ get_job_category_by_name: (
+ name: string | null | undefined,
+ ) => JobCategory | null;
+
+ get_department: (id: string | null | undefined) => Department | null;
+ to_department_name: (
+ id: string | null | undefined,
+ def?: string | null,
+ ) => string | null;
+ get_department_by_name: (
+ name: string | null | undefined,
+ ) => Department | null;
+
+ get_departments_by_college: (college_id: string) => string[];
+ get_colleges_by_university: (university_id: string) => string[];
+ getUniversityFromDomain: (domain: string) => string[];
+ isNotNull: (ref: any) => boolean;
+}
diff --git a/lib/db/use-moa-backend.tsx b/lib/db/forms-db.types.tsx
similarity index 77%
rename from lib/db/use-moa-backend.tsx
rename to lib/db/forms-db.types.tsx
index 3ab6e214..458f1f86 100644
--- a/lib/db/use-moa-backend.tsx
+++ b/lib/db/forms-db.types.tsx
@@ -1,13 +1,12 @@
/**
* @ Author: BetterInternship
* @ Create Time: 2025-10-11 00:00:00
- * @ Modified time: 2025-12-30 12:06:12
+ * @ Modified time: 2026-04-18 23:50:39
* @ Description:
*
* This handles interactions with our MOA Api server.
*/
-// ! move this into a form utils file
export interface FormTemplate {
formDocument: string;
formVersion: number;
diff --git a/lib/db/use-bi-moa-backend.ts b/lib/db/use-bi-moa-backend.ts
index f6d8ced8..ad224c32 100644
--- a/lib/db/use-bi-moa-backend.ts
+++ b/lib/db/use-bi-moa-backend.ts
@@ -1,67 +1,38 @@
/**
* @ Author: BetterInternship
* @ Create Time: 2025-06-22 14:37:59
- * @ Modified time: 2025-10-11 00:03:47
+ * @ Modified time: 2026-04-19 00:00:00
* @ Description:
*
- * Separates out the server component of the context.
+ * Server-only data loader for moa records.
*/
-import { useCallback, useEffect, useState } from "react";
+import "server-only";
import { Moa } from "./db.types";
-import { createClient } from "@supabase/supabase-js";
+import { Kysely, PostgresDialect } from "kysely";
+import { DB } from "@betterinternship/schema.base";
+import { Pool } from "pg";
-// Environment setup
-const DB_URL = process.env.NEXT_PUBLIC_SUPABASE_URL;
-const DB_ANON_KEY = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
+const DATABASE_URL = process.env.DATABASE_URL;
-if (!DB_URL || !DB_ANON_KEY)
- throw new Error("[ERROR:ENV] Missing supabase configuration.");
-const db = createClient(DB_URL ?? "", DB_ANON_KEY ?? "");
+if (!DATABASE_URL) throw new Error("[ERROR:ENV] Missing database url.");
-/**
- * Fetches actual data from db.
- *
- * @returns
- */
-export const createBiMoaContext = () => {
- const [moa, setMoa] = useState([]);
- const [loading, setLoading] = useState(true);
-
- /**
- * Fetch the entire moa table.
- */
- const fetchMoaRefTable = async () => {
- const { data, error } = await db.from("moa").select("*");
- if (error) console.error(error);
- else setMoa(data);
- setLoading(false);
- };
+const db = new Kysely({
+ dialect: new PostgresDialect({
+ pool: new Pool({
+ connectionString: DATABASE_URL,
+ }),
+ }),
+});
- /**
- * Checks whether or not an association between the employer and university exists.
- *
- * @param employer_id
- * @param university_id
- */
- const check = useCallback(
- (employer_id: string, university_id: string) => {
- if (loading) return false;
- return moa.some(
- (m) =>
- m.employer_id === employer_id &&
- m.university_id === university_id &&
- new Date(m.expires_at ?? "").getTime() > new Date().getTime()
- );
- },
- [moa, loading]
- );
+export interface BIMoaData {
+ moa: Moa[];
+}
- useEffect(() => {
- fetchMoaRefTable();
- }, []);
-
- return {
- check,
- };
+/**
+ * Fetches the moa table on the server and returns serializable data for clients.
+ */
+export const getBiMoaData = async (): Promise => {
+ const moa = await db.selectFrom("moa").selectAll().execute();
+ return { moa };
};
diff --git a/lib/db/use-bi-moa.tsx b/lib/db/use-bi-moa.tsx
index e9ad49c3..27b8fc40 100644
--- a/lib/db/use-bi-moa.tsx
+++ b/lib/db/use-bi-moa.tsx
@@ -1,7 +1,7 @@
/**
* @ Author: BetterInternship
* @ Create Time: 2025-06-22 13:40:54
- * @ Modified time: 2025-10-11 00:00:11
+ * @ Modified time: 2026-04-19 00:10:33
* @ Description:
*
* Gives us utils to check if company has moa.
@@ -10,7 +10,7 @@
"use client";
import { createContext, useContext } from "react";
-import { createBiMoaContext } from "./use-bi-moa-backend";
+import { Moa } from "./db.types";
// The IMoa context should only be loaded once
interface IBIMoa {
@@ -25,15 +25,31 @@ const biMoaContext = createContext({} as IBIMoa);
* @context
*/
export const BIMoaContextProvider = ({
+ moa,
children,
}: {
+ moa?: Moa[];
children: React.ReactNode;
}) => {
- const biMoaContextValue = createBiMoaContext();
+ const biMoaContextValue = createBiMoaContext(moa ?? []);
return (
- {children}
+
+ {children}
+
);
};
export const useDbMoa = () => useContext(biMoaContext);
+
+const createBiMoaContext = (moa: Moa[]): IBIMoa => {
+ return {
+ check: (employer_id: string, university_id: string) =>
+ moa.some(
+ (m) =>
+ m.employer_id === employer_id &&
+ m.university_id === university_id &&
+ new Date(m.expires_at ?? "").getTime() > new Date().getTime(),
+ ),
+ };
+};
diff --git a/lib/db/use-refs-backend.ts b/lib/db/use-refs-backend.ts
index 5fc5d4f7..eb967486 100644
--- a/lib/db/use-refs-backend.ts
+++ b/lib/db/use-refs-backend.ts
@@ -1,322 +1,88 @@
/**
* @ Author: BetterInternship
* @ Create Time: 2025-06-15 03:09:57
- * @ Modified time: 2025-09-25 18:02:00
+ * @ Modified time: 2026-04-30 22:56:23
* @ Description:
*
- * The actual backend connection to provide the refs data
+ * Server-only data loaders for refs tables.
*/
-import { useState, useEffect, useCallback } from "react";
+import "server-only";
import {
College,
University,
JobType,
JobMode,
JobAllowance,
- JobPayFreq,
AppStatus,
Industry,
JobCategory,
Department,
+ RefDomain,
+ RefsData,
+ IRefsContext,
} from "./db.types";
-import { createClient } from "@supabase/supabase-js";
+import { DB } from "@betterinternship/schema.base";
+import { Kysely, PostgresDialect } from "kysely";
+import { Pool } from "pg";
-// Environment setup
-const db_url = process.env.NEXT_PUBLIC_SUPABASE_URL;
-const db_anon_key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
+const DATABASE_URL = process.env.DATABASE_URL;
-if (!db_url || !db_anon_key)
- throw new Error("[ERROR:ENV] Missing supabase configuration.");
-const db = createClient(db_url ?? "", db_anon_key ?? "");
+if (!DATABASE_URL) throw new Error("[ERROR:ENV] Missing database url.");
-// Setup the context
-export interface IRefsContext {
- ref_loading: boolean;
-
- colleges: College[];
- departments: Department[];
- universities: University[];
- job_types: JobType[];
- job_modes: JobMode[];
- job_allowances: JobAllowance[];
- job_categories: JobCategory[];
- job_pay_freq: JobPayFreq[];
- app_statuses: AppStatus[];
- industries: Industry[];
-
- get_college: (id: string | null | undefined) => College | null;
- to_college_name: (
- id: string | null | undefined,
- def?: string | null
- ) => string | null;
- get_college_by_name: (name: string | null | undefined) => College | null;
-
- get_university: (id: string | null | undefined) => University | null;
- to_university_name: (
- id: string | null | undefined,
- def?: string | null
- ) => string | null;
- get_university_by_name: (
- name: string | null | undefined
- ) => University | null;
-
- get_job_type: (id: number | null | undefined) => JobType | null;
- to_job_type_name: (
- id: number | null | undefined,
- def?: string | null
- ) => string | null;
- get_job_type_by_name: (name: string | null | undefined) => JobType | null;
-
- get_job_mode: (id: number | null | undefined) => JobMode | null;
- to_job_mode_name: (
- id: number | null | undefined,
- def?: string | null
- ) => string | null;
- get_job_mode_by_name: (name: string | null | undefined) => JobMode | null;
-
- get_job_allowance: (id: number | null | undefined) => JobAllowance | null;
- to_job_allowance_name: (
- id: number | null | undefined,
- def?: string | null
- ) => string | null;
- get_job_allowance_by_name: (
- name: string | null | undefined
- ) => JobAllowance | null;
-
- get_job_pay_freq: (id: number | null | undefined) => JobPayFreq | null;
- to_job_pay_freq_name: (
- id: number | null | undefined,
- def?: string | null
- ) => string | null;
- get_job_pay_freq_by_name: (
- name: string | null | undefined
- ) => JobPayFreq | null;
-
- get_app_status: (id: number | null | undefined) => AppStatus | null;
- to_app_status_name: (
- id: number | null | undefined,
- def?: string | null
- ) => string | null;
- get_app_status_by_name: (name: string | null | undefined) => AppStatus | null;
-
- get_industry: (id: string | null | undefined) => Industry | null;
- to_industry_name: (
- id: string | null | undefined,
- def?: string | null
- ) => string | null;
- get_industry_by_name: (name: string | null | undefined) => Industry | null;
-
- get_job_category: (id: string | null | undefined) => JobCategory | null;
- to_job_category_name: (
- id: string | null | undefined,
- def?: string | null
- ) => string | null;
- get_job_category_by_name: (
- name: string | null | undefined
- ) => JobCategory | null;
-
- get_department: (id: string | null | undefined) => Department | null;
- to_department_name: (
- id: string | null | undefined,
- def?: string | null
- ) => string | null;
- get_department_by_name: (
- name: string | null | undefined
- ) => Department | null;
-
- get_departments_by_college: (college_id: string) => string[];
- get_colleges_by_university: (university_id: string) => string[];
- getUniversityFromDomain: (domain: string) => string[];
- isNotNull: (ref: any) => boolean;
-}
+const db = new Kysely({
+ dialect: new PostgresDialect({
+ pool: new Pool({
+ connectionString: DATABASE_URL,
+ }),
+ }),
+});
/**
- * A utility that allows us to create ref hooks from our reference tables.
- *
- * @hook
- * @internal
+ * Fetches all refs tables on the server and returns serializable data for clients.
*/
-const createRefInternalHook = <
- ID extends string | number,
- T extends { id: ID; name: string }
->(
- table: string
-) => {
- const [data, set_data] = useState([]);
- const [loading, set_loading] = useState(true);
-
- /**
- * Fetches the data from the backend.
- */
- async function fetch_data() {
- set_loading(true);
- const { data, error } = await db.from(table).select("*");
- if (error) console.error(error);
- else set_data(data);
- set_loading(false);
- }
-
- /**
- * Converts an id to it's name.
- *
- * @param id
- * @returns
- */
- const to_name = useCallback(
- (id: ID | null | undefined, def: string = "Not specified"): string => {
- if (!id && id !== 0) return def;
- const f = data?.filter((d) => d.id === id);
- if (!f.length) return def;
- return f[0].name;
- },
- [data]
- );
-
- /**
- * Gets a ref by id
- *
- * @param id
- * @returns
- */
- const get = useCallback(
- (id: ID | null | undefined): T | null => {
- if (!id && id !== 0) return null;
- const f = data?.filter((d) => d.id === id);
- if (!f.length) return null;
- return f[0];
- },
- [data]
- );
-
- /**
- * Gets a ref by name
- *
- * @param name
- * @returns
- */
- const get_by_name = useCallback(
- (name: string | null | undefined): T | null => {
- if (!name) return null;
- const f = data?.filter((d) => d.name === name);
- if (!f.length) return null;
- return f[0];
- },
- [data]
- );
-
- // Fetch the data at the start
- useEffect(() => {
- fetch_data();
- }, []);
+export const getRefsData = async (): Promise => {
+ const [
+ colleges,
+ universities,
+ job_types,
+ job_modes,
+ job_allowances,
+ job_pay_freq,
+ app_statuses,
+ industries,
+ job_categories,
+ departments,
+ domains,
+ ] = await Promise.all([
+ db.selectFrom("ref_colleges").selectAll().execute() as Promise,
+ db.selectFrom("ref_universities").selectAll().execute() as Promise<
+ University[]
+ >,
+ db.selectFrom("ref_job_types").selectAll().execute() as Promise,
+ db.selectFrom("ref_job_modes").selectAll().execute() as Promise,
+ db.selectFrom("ref_job_allowances").selectAll().execute() as Promise<
+ JobAllowance[]
+ >,
+ db.selectFrom("ref_job_pay_freq").selectAll().execute() as Promise<
+ JobPayFreq[]
+ >,
+ db.selectFrom("ref_app_statuses").selectAll().execute() as Promise<
+ AppStatus[]
+ >,
+ db.selectFrom("ref_industries").selectAll().execute() as Promise<
+ Industry[]
+ >,
+ db.selectFrom("ref_job_categories").selectAll().execute() as Promise<
+ JobCategory[]
+ >,
+ db.selectFrom("ref_departments").selectAll().execute() as Promise<
+ Department[]
+ >,
+ db.selectFrom("ref_domains").selectAll().execute() as Promise,
+ ]);
return {
- data,
- get,
- to_name,
- get_by_name,
- loading,
- };
-};
-
-export const createRefsContext = () => {
- const [loading, setLoading] = useState(true);
-
- const {
- data: colleges,
- get: get_college,
- to_name: to_college_name,
- get_by_name: get_college_by_name,
- loading: l2,
- } = createRefInternalHook("ref_colleges");
-
- const {
- data: universities,
- get: get_university,
- to_name: to_university_name,
- get_by_name: get_university_by_name,
- loading: l3,
- } = createRefInternalHook("ref_universities");
-
- const {
- data: job_types,
- get: get_job_type,
- to_name: to_job_type_name,
- get_by_name: get_job_type_by_name,
- loading: l4,
- } = createRefInternalHook("ref_job_types");
-
- const {
- data: job_modes,
- get: get_job_mode,
- to_name: to_job_mode_name,
- get_by_name: get_job_mode_by_name,
- loading: l5,
- } = createRefInternalHook("ref_job_modes");
-
- const {
- data: job_allowances,
- get: get_job_allowance,
- to_name: to_job_allowance_name,
- get_by_name: get_job_allowance_by_name,
- loading: l6,
- } = createRefInternalHook("ref_job_allowances");
-
- const {
- data: job_pay_freq,
- get: get_job_pay_freq,
- to_name: to_job_pay_freq_name,
- get_by_name: get_job_pay_freq_by_name,
- loading: l7,
- } = createRefInternalHook("ref_job_pay_freq");
-
- const {
- data: app_statuses,
- get: get_app_status,
- to_name: to_app_status_name,
- get_by_name: get_app_status_by_name,
- loading: l8,
- } = createRefInternalHook("ref_app_statuses");
-
- const {
- data: industries,
- get: get_industry,
- to_name: to_industry_name,
- get_by_name: get_industry_by_name,
- loading: l9,
- } = createRefInternalHook("ref_industries");
-
- const {
- data: job_categories,
- get: get_job_category,
- to_name: to_job_category_name,
- get_by_name: get_job_category_by_name,
- loading: l10,
- } = createRefInternalHook("ref_job_categories");
-
- const {
- data: departments,
- get: get_department,
- to_name: to_department_name,
- get_by_name: get_department_by_name,
- loading: l11,
- } = createRefInternalHook("ref_departments");
-
- const { data: domains, loading: l13 } = createRefInternalHook<
- string,
- { id: string; name: string; university_id: string }
- >("ref_domains");
-
- useEffect(() => {
- setLoading(
- l2 || l3 || l4 || l5 || l6 || l7 || l8 || l9 || l10 || l11 || l13
- );
- }, [l2, l3, l4, l5, l6, l7, l8, l9, l10, l11, l13]);
-
- // The API to provide to the app
- const refs_context = {
- ref_loading: loading,
-
colleges,
departments,
universities,
@@ -325,55 +91,8 @@ export const createRefsContext = () => {
job_allowances,
job_categories,
job_pay_freq,
- industries,
app_statuses,
+ industries,
domains,
-
- to_college_name,
- to_department_name,
- to_university_name,
- to_job_type_name,
- to_job_mode_name,
- to_job_allowance_name,
- to_job_category_name,
- to_job_pay_freq_name,
- to_app_status_name,
- to_industry_name,
-
- get_college,
- get_department,
- get_university,
- get_job_type,
- get_job_mode,
- get_job_category,
- get_job_allowance,
- get_job_pay_freq,
- get_app_status,
- get_industry,
-
- get_college_by_name,
- get_department_by_name,
- get_university_by_name,
- get_job_type_by_name,
- get_job_mode_by_name,
- get_job_allowance_by_name,
- get_job_category_by_name,
- get_job_pay_freq_by_name,
- get_app_status_by_name,
- get_industry_by_name,
-
- get_departments_by_college: (college_id: string) =>
- departments.filter((d) => d.college_id === college_id).map((d) => d.id),
-
- get_colleges_by_university: (university_id: string) =>
- colleges
- .filter((c) => c.university_id === university_id)
- .map((c) => c.id),
-
- getUniversityFromDomain: (domain: string) =>
- domains.filter((d) => d.name === domain).map((d) => d.university_id),
- isNotNull: (ref: any) => ref || ref === 0,
};
-
- return refs_context;
};
diff --git a/lib/db/use-refs.tsx b/lib/db/use-refs.tsx
index 3e03326f..6c777124 100644
--- a/lib/db/use-refs.tsx
+++ b/lib/db/use-refs.tsx
@@ -1,7 +1,7 @@
/**
* @ Author: BetterInternship
* @ Create Time: 2025-06-10 04:31:46
- * @ Modified time: 2025-09-25 18:02:05
+ * @ Modified time: 2026-04-19 00:24:27
* @ Description:
*
* Accesses refs directly from the database.
@@ -10,8 +10,21 @@
"use client";
import { createContext, useContext } from "react";
-import { IRefsContext, createRefsContext } from "./use-refs-backend";
-
+import {
+ AppStatus,
+ College,
+ Department,
+ Industry,
+ JobAllowance,
+ JobCategory,
+ JobMode,
+ JobPayFreq,
+ JobType,
+ University,
+ RefDomain,
+ RefsData,
+ IRefsContext,
+} from "./db.types";
// The context template
const RefsContext = createContext({} as IRefsContext);
@@ -21,21 +34,144 @@ const RefsContext = createContext({} as IRefsContext);
* @component
*/
export const RefsContextProvider = ({
+ data,
children,
}: {
+ data?: RefsData;
children: React.ReactNode;
}) => {
- const refs_context = createRefsContext();
+ const refsContext = createRefsContext(data ?? emptyRefsData);
return (
- {children}
+ {children}
);
};
/**
- * Allows using the refs table we have in supabase as a hook.
+ * Allows using the refs table.
*
* @hook
*/
export const useDbRefs = (): IRefsContext => {
return useContext(RefsContext);
};
+
+const createRefHelpers = <
+ ID extends string | number,
+ T extends { id: ID; name: string },
+>(
+ data: T[],
+) => {
+ const get = (id: ID | null | undefined): T | null => {
+ if (!id && id !== 0) return null;
+ const found = data.find((d) => d.id === id);
+ return found ?? null;
+ };
+
+ const toName = (
+ id: ID | null | undefined,
+ def: string | null | undefined = "Not specified",
+ ): string => {
+ if (!id && id !== 0) return def ?? "";
+ const found = data.find((d) => d.id === id);
+ return found?.name ?? def ?? "";
+ };
+
+ const getByName = (name: string | null | undefined): T | null => {
+ if (!name) return null;
+ const found = data.find((d) => d.name === name);
+ return found ?? null;
+ };
+
+ return {
+ get,
+ toName,
+ getByName,
+ };
+};
+
+const createRefsContext = (data: RefsData): IRefsContext => {
+ const collegeHelpers = createRefHelpers(data.colleges);
+ const universityHelpers = createRefHelpers