diff --git a/.github/workflows/fork-sync-trigger-develop.yml b/.github/workflows/fork-sync-trigger-develop.yml index 19a4ded5..05b62273 100644 --- a/.github/workflows/fork-sync-trigger-develop.yml +++ b/.github/workflows/fork-sync-trigger-develop.yml @@ -44,6 +44,9 @@ jobs: with: node-version: '20' + - name: Clean npm cache + run: npm cache clean --force + - name: Install Dependencies run: npm i --legacy-peer-deps diff --git a/README.md b/README.md index 4fb2f6c2..d5aa256c 100644 --- a/README.md +++ b/README.md @@ -15,4 +15,4 @@ npm run dev ## Jest Testing ```bash npm run test -``` \ No newline at end of file +``` diff --git a/app/globals.css b/app/globals.css index 85254047..6367e666 100644 --- a/app/globals.css +++ b/app/globals.css @@ -609,6 +609,46 @@ button[role="checkbox"]:checked { } /* move right */ } +@keyframes tab-attention-jitter { + 0%, + 72%, + 100% { + transform: translateX(0); + } + 76% { + transform: translateX(4px); + } + 82% { + transform: translateX(-3px); + } + 88% { + transform: translateX(4px); + } + 94% { + transform: translateX(-2px); + } +} + +@keyframes tab-attention-dot-jitter { + 0%, + 72%, + 100% { + transform: translateX(0) scale(1); + } + 76% { + transform: translateX(4px) scale(1.16); + } + 82% { + transform: translateX(-3px) scale(0.92); + } + 88% { + transform: translateX(4px) scale(1.16); + } + 94% { + transform: translateX(-2px) scale(0.96); + } +} + /* Mobile-specific utilities */ @layer utilities { .pb-safe { @@ -630,6 +670,14 @@ button[role="checkbox"]:checked { .webkit-overflow-scrolling-touch { -webkit-overflow-scrolling: touch; } + + .tab-attention-jitter { + animation: tab-attention-jitter 1.8s ease-in-out infinite; + } + + .tab-attention-dot-jitter { + animation: tab-attention-dot-jitter 1.8s ease-in-out infinite; + } } .input-wrap { @@ -717,4 +765,239 @@ button[role="checkbox"]:checked { .shiny-text { animation: none; } + + .tab-attention-jitter, + .tab-attention-dot-jitter { + animation: none; + } +} + +/* ─── Super Listing Premium Styles ─── */ + +@keyframes super-border-glow { + 0%, + 100% { + border-color: rgba(245, 158, 11, 0.5); + } + 50% { + border-color: rgba(249, 115, 22, 0.7); + } +} + +@keyframes super-shimmer { + 0% { + background-position: -200% 0; + } + 100% { + background-position: 200% 0; + } +} + +@keyframes float-zap { + 0%, + 100% { + transform: translateY(0) scale(1); + opacity: 0.7; + } + 50% { + transform: translateY(-3px) scale(1.1); + opacity: 1; + } +} + +@keyframes float-zap-alt { + 0%, + 100% { + transform: translateY(0) scale(1) rotate(0deg); + opacity: 0.5; + } + 33% { + transform: translateY(-4px) scale(1.15) rotate(8deg); + opacity: 0.9; + } + 66% { + transform: translateY(-1px) scale(1.05) rotate(-5deg); + opacity: 0.7; + } +} + +@keyframes super-cta-pulse { + 0%, + 100% { + box-shadow: 0 10px 18px rgba(249, 115, 22, 0.24); + } + 50% { + box-shadow: + 0 12px 28px rgba(249, 115, 22, 0.38), + 0 0 12px rgba(251, 191, 36, 0.2); + } +} + +@keyframes badge-shimmer { + 0% { + background-position: -100% 0; + } + 100% { + background-position: 200% 0; + } +} + +/* Scrapbook sticker bobbing — three offset variants for organic feel */ +@keyframes sticker-bob { + 0%, + 100% { + transform: translateY(0) rotate(var(--r, 0deg)); + } + 50% { + transform: translateY(-3px) rotate(calc(var(--r, 0deg) + 4deg)); + } +} + +@keyframes sticker-bob-delayed { + 0%, + 100% { + transform: translateY(0) rotate(var(--r, 0deg)); + } + 40% { + transform: translateY(-2.5px) rotate(calc(var(--r, 0deg) - 3deg)); + } + 70% { + transform: translateY(-1px) rotate(calc(var(--r, 0deg) + 2deg)); + } +} + +@keyframes sticker-bob-slow { + 0%, + 100% { + transform: translateY(0) rotate(var(--r, 0deg)) scale(1); + } + 50% { + transform: translateY(-2px) rotate(calc(var(--r, 0deg) + 3deg)) scale(1.05); + } +} + +.super-sticker { + filter: drop-shadow(0 3px 6px rgba(120, 53, 15, 0.25)) + drop-shadow(0 0 2px rgba(245, 158, 11, 0.3)); + animation: sticker-bob 3s ease-in-out infinite; +} + +.super-sticker-delayed { + filter: drop-shadow(0 3px 6px rgba(120, 53, 15, 0.25)) + drop-shadow(0 0 2px rgba(245, 158, 11, 0.3)); + animation: sticker-bob-delayed 3.4s ease-in-out 0.6s infinite; +} + +.super-sticker-slow { + filter: drop-shadow(0 3px 6px rgba(120, 53, 15, 0.25)) + drop-shadow(0 0 2px rgba(245, 158, 11, 0.3)); + animation: sticker-bob-slow 4s ease-in-out 1.1s infinite; +} + +.super-card { + border: 1.5px solid rgba(245, 158, 11, 0.4); + animation: super-border-glow 3s ease-in-out infinite; +} + +.super-card::before { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + background: linear-gradient( + 105deg, + transparent 40%, + rgba(251, 191, 36, 0.08) 45%, + rgba(251, 191, 36, 0.15) 50%, + rgba(251, 191, 36, 0.08) 55%, + transparent 60% + ); + background-size: 200% 100%; + animation: super-shimmer 4s ease-in-out infinite; + pointer-events: none; + z-index: 1; +} + +.super-badge-shimmer { + background: linear-gradient( + 110deg, + rgba(245, 158, 11, 0.08) 0%, + rgba(245, 158, 11, 0.08) 40%, + rgba(255, 255, 255, 0.6) 50%, + rgba(245, 158, 11, 0.08) 60%, + rgba(245, 158, 11, 0.08) 100% + ); + background-size: 200% 100%; + animation: badge-shimmer 3s ease-in-out infinite; +} + +.super-cta-glow { + animation: super-cta-pulse 2.5s ease-in-out infinite; +} + +.super-zap { + animation: float-zap 2.5s ease-in-out infinite; +} + +.super-zap-alt { + animation: float-zap-alt 3.2s ease-in-out infinite; +} + +.super-zap-delayed { + animation: float-zap 2.5s ease-in-out 0.8s infinite; +} + +.super-zap-delayed-alt { + animation: float-zap-alt 3.2s ease-in-out 1.2s infinite; +} + +.super-challenge-card { + border: 1.5px solid rgba(245, 158, 11, 0.35); + animation: super-border-glow 3s ease-in-out infinite; + position: relative; + overflow: visible; +} + +.super-challenge-card::before { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + background: linear-gradient( + 105deg, + transparent 40%, + rgba(251, 191, 36, 0.06) 45%, + rgba(251, 191, 36, 0.12) 50%, + rgba(251, 191, 36, 0.06) 55%, + transparent 60% + ); + background-size: 200% 100%; + animation: super-shimmer 5s ease-in-out infinite; + pointer-events: none; +} + +.super-header-badge { + border: 1px solid rgba(245, 158, 11, 0.35); + box-shadow: + 0 4px 12px rgba(245, 158, 11, 0.14), + 0 0 0 1px rgba(245, 158, 11, 0.08); +} + +@media (prefers-reduced-motion: reduce) { + .super-card, + .super-card::before, + .super-badge-shimmer, + .super-cta-glow, + .super-zap, + .super-zap-alt, + .super-zap-delayed, + .super-zap-delayed-alt, + .super-challenge-card::before { + animation: none; + } + .super-sticker, + .super-sticker-delayed, + .super-sticker-slow { + animation: none; + } } diff --git a/app/hire/dashboard/applicant/page.tsx b/app/hire/dashboard/applicant/page.tsx index 52bc13ab..925608e7 100644 --- a/app/hire/dashboard/applicant/page.tsx +++ b/app/hire/dashboard/applicant/page.tsx @@ -11,85 +11,138 @@ import { useDbRefs } from "@/lib/db/use-refs"; import { useSearchParams } from "next/navigation"; import { Suspense, useEffect, useState } from "react"; -function ApplicantPageContent () { - const searchParams = useSearchParams(); - const userId = searchParams.get("userId"); - const jobId = searchParams.get("jobId") - const [userData, setUserData] = useState(undefined) - const [loading, setLoading] = useState(true); - const applications = useEmployerApplications(); - let userApplication = applications?.employer_applications.find(a => userId === a.user_id); - let otherApplications = applications?.employer_applications.filter(a => userId === a.user_id); +function ApplicantPageContent() { + const searchParams = useSearchParams(); + const userId = searchParams.get("userId"); + const jobId = searchParams.get("jobId"); + const isDummyProfile = searchParams.get("dummy") === "1"; + const [loading, setLoading] = useState(true); + const applications = useEmployerApplications(); - if (jobId){ - userApplication = applications?.employer_applications.find(a => userId === a.user_id && a.job_id === jobId) - otherApplications = applications?.employer_applications.filter(a => userId === a.user_id && a.id !== userApplication?.id); - } + const dummyApplication: EmployerApplication = { + id: "dummy-super-application", + user_id: "", + job_id: jobId ?? "dummy-super-job", + status: 0, + applied_at: "2026-03-09T00:00:00.000Z", + cover_letter: + "I am excited to apply and contribute with thoughtful solutions.", + challenge_submission: + "This is a sample challenge submission used for previewing super listing flows.", + job: { + title: "Super Listing - Sample Role", + internship_preferences: { + require_cover_letter: true, + }, + }, + user: { + id: "dummy-super-user", + first_name: "Sample", + last_name: "Applicant", + phone_number: "+63 900 000 0000", + edu_verification_email: "sample.applicant@school.edu", + degree: "BS Computer Science", + bio: "I build practical products and enjoy solving product and UX problems.", + github_link: "https://github.com/sample-applicant", + portfolio_link: "https://sample-applicant.dev", + linkedin_link: "https://linkedin.com/in/sample-applicant", + internship_preferences: { + internship_type: "credited", + expected_start_date: 1751328000000, + expected_duration_hours: 400, + }, + expected_graduation_date: 1767225600000, + }, + }; - const { app_statuses } = useDbRefs(); + let userApplication = applications?.employer_applications.find( + (a) => userId === a.user_id, + ); + let otherApplications = applications?.employer_applications.filter( + (a) => userId === a.user_id, + ); - useEffect(() => { - const fetchUserData = async() => { - if(!userId) { - setLoading(false); - return; - } - try { - setLoading(true); - const response = await UserService.getUserById(userId); //change - // console.log(response) - if(response?.success && response.users){ - setUserData(userApplication) - } - } catch (error) { - console.error(error); - } finally { - setLoading(false); - } - }; - fetchUserData(); - }, [userId]); + if (jobId) { + userApplication = applications?.employer_applications.find( + (a) => userId === a.user_id && a.job_id === jobId, + ); + otherApplications = applications?.employer_applications.filter( + (a) => userId === a.user_id && a.id !== userApplication?.id, + ); + } - if (!app_statuses) return null; + if (isDummyProfile) { + userApplication = dummyApplication; + otherApplications = []; + } - const unique_app_statuses = app_statuses.reduce( - (acc: {id: number, name: string}[], cur: {id: number, name: string}) => - (acc.find(a => a.name === cur.name) ? acc : [...acc, cur]), [] - ); + const { app_statuses } = useDbRefs(); - const getStatuses = (applicationId: string) => { - return unique_app_statuses - .filter((status) => status.id !== 7 && status.id !== 5 && status.id !== 0) - .map((status): ActionItem => { - const uiProps = statusMap.get(status.id); - return { - id: status.id.toString(), - label: status.name, - icon: uiProps?.icon, - onClick: () => updateApplicationStatus(applicationId, status.id), - destructive: uiProps?.destructive, - }; - }); + useEffect(() => { + const fetchUserData = async () => { + if (isDummyProfile) { + setLoading(false); + return; + } + if (!userId) { + setLoading(false); + return; + } + try { + setLoading(true); + await UserService.getUserById(userId); + } catch (error) { + console.error(error); + } finally { + setLoading(false); + } }; + fetchUserData(); + }, [isDummyProfile, userId]); + + if (!app_statuses) return null; - return ( - -
- -
-
- ) + const unique_app_statuses = app_statuses.reduce( + (acc: { id: number; name: string }[], cur: { id: number; name: string }) => + acc.find((a) => a.name === cur.name) ? acc : [...acc, cur], + [], + ); + + const getStatuses = (applicationId: string) => { + return unique_app_statuses + .filter((status) => status.id !== 7 && status.id !== 5 && status.id !== 0) + .map((status): ActionItem => { + const uiProps = statusMap.get(status.id); + return { + id: status.id.toString(), + label: status.name, + icon: uiProps?.icon, + onClick: () => updateApplicationStatus(applicationId, status.id), + destructive: uiProps?.destructive, + }; + }); + }; + + return ( + +
+ +
+
+ ); } -export default function ApplicantInfo () { - return( - - - - ) -} \ No newline at end of file +export default function ApplicantInfo() { + return ( + + + + ); +} diff --git a/app/hire/dashboard/page.tsx b/app/hire/dashboard/page.tsx index 3b3e8371..979f974a 100644 --- a/app/hire/dashboard/page.tsx +++ b/app/hire/dashboard/page.tsx @@ -7,36 +7,74 @@ import ContentLayout from "@/components/features/hire/content-layout"; import { JobsContent } from "@/components/features/hire/dashboard/JobsContent"; import { ShowUnverifiedBanner } from "@/components/ui/banner"; import { Loader } from "@/components/ui/loader"; -import { useEmployerApplications, useOwnedJobs, useProfile } from "@/hooks/use-employer-api"; +import { + useEmployerApplications, + useOwnedJobs, + useProfile, +} from "@/hooks/use-employer-api"; import { useMobile } from "@/hooks/use-mobile"; import { cn } from "@/lib/utils"; import { Bell, Briefcase, Plus } from "lucide-react"; -import Link from "next/link"; import { useState, useRef, useEffect } from "react"; import { useAuthContext } from "../authctx"; import { Job } from "@/lib/db/db.types"; import { FadeIn } from "@/components/animata/fade"; import { useModal } from "@/hooks/use-modal"; -import { getNotificationPermission, requestNotificationPermission, checkNotificationSupport, shouldShowNotification, sendNotification } from "@/lib/notification-service"; +import { + getNotificationPermission, + requestNotificationPermission, + checkNotificationSupport, + shouldShowNotification, + sendNotification, +} from "@/lib/notification-service"; import { HeaderIcon, HeaderText } from "@/components/ui/text"; +import { useRouter } from "next/navigation"; + +const SUPER_LISTING_CREATE_PATH = + "/ft2mkyEVxHrAJwaphVVSop3TIau0pWDq/listings/create"; +const NORMAL_LISTING_CREATE_PATH = "/listings/create"; function DashboardContent() { const { isMobile } = useMobile(); const { isAuthenticated, redirectIfNotLoggedIn, loading } = useAuthContext(); + const router = useRouter(); + const superListingTapState = useRef({ count: 0, lastTapMs: 0 }); const profile = useProfile(); const applications = useEmployerApplications(); const { ownedJobs, update_job, delete_job } = useOwnedJobs(); - const activeJobs = ownedJobs.filter((job) => job.is_active) - const inactiveJobs = ownedJobs.filter((job) => !job.is_active) + const activeJobs = ownedJobs.filter((job) => job.is_active); + const inactiveJobs = ownedJobs.filter((job) => !job.is_active); const [isLoading, setLoading] = useState(true); redirectIfNotLoggedIn(); + const handleAddListingClick = () => { + router.push(NORMAL_LISTING_CREATE_PATH); + }; + + const handleSecretSuperListingAccess = () => { + const now = Date.now(); + const TAP_TIMEOUT_MS = 1200; + const REQUIRED_TAPS = 5; + + if (now - superListingTapState.current.lastTapMs > TAP_TIMEOUT_MS) { + superListingTapState.current.count = 0; + } + + superListingTapState.current.count += 1; + superListingTapState.current.lastTapMs = now; + + if (superListingTapState.current.count >= REQUIRED_TAPS) { + superListingTapState.current.count = 0; + router.push(SUPER_LISTING_CREATE_PATH); + } + }; + const handleUpdateJob = async (jobId: string, updates: Partial) => { const result = await update_job(jobId, updates); return result; - } + }; const { open: openNotifPermsModal, @@ -48,8 +86,8 @@ function DashboardContent() { const requestPermission = async () => { if (checkNotificationSupport()) { const currentPermission = getNotificationPermission(); - - if (currentPermission === 'default') { + + if (currentPermission === "default") { openNotifPermsModal(); const permission = await requestNotificationPermission(); closeNotifPermsModal(); @@ -61,11 +99,11 @@ function DashboardContent() { }, []); useEffect(() => { - if (!ownedJobs) { - setLoading(true) - } else { - setLoading(false); - } + if (!ownedJobs) { + setLoading(true); + } else { + setLoading(false); + } }, [ownedJobs, activeJobs, inactiveJobs]); if (loading || !isAuthenticated()) { @@ -82,37 +120,58 @@ function DashboardContent() {
- Please grant notification access so we can send you chat notifications. + + Please grant notification access so we can send you chat + notifications. +
-
-
+
+
Job listings
- {activeJobs.length} active listing{activeJobs.length !== 1 ? "s" : ""} - {inactiveJobs.length} inactive listing{inactiveJobs.length !== 1 ? "s" : ""} + + + {activeJobs.length} + {" "} + active listing{activeJobs.length !== 1 ? "s" : ""} + + + + {inactiveJobs.length} + {" "} + inactive listing{inactiveJobs.length !== 1 ? "s" : ""} +
{isMobile && ( - - - + )}
diff --git a/app/hire/forgot-password/page.tsx b/app/hire/forgot-password/page.tsx index 84e8dc71..b1109abe 100644 --- a/app/hire/forgot-password/page.tsx +++ b/app/hire/forgot-password/page.tsx @@ -49,12 +49,12 @@ const ForgotPasswordForm = ({}) => { setMessage(""); try { - const r = await EmployerUserService.requestPasswordReset(email); + const r = await EmployerUserService.requestPasswordReset(email.toLowerCase()); // @ts-ignore setMessage(r.message); } catch (err: any) { - setError(err.message ?? "Something went wrong. Please try again later."); + setError(err.response?.data?.message ?? err.message ?? "Something went wrong. Please try again later."); } finally { setIsLoading(false); } @@ -75,12 +75,12 @@ const ForgotPasswordForm = ({}) => { Reset password
{error && ( -
+

{error}

)} {message && ( -
+

{message}

)} diff --git a/app/hire/ft2mkyEVxHrAJwaphVVSop3TIau0pWDq/listings/create/page.tsx b/app/hire/ft2mkyEVxHrAJwaphVVSop3TIau0pWDq/listings/create/page.tsx new file mode 100644 index 00000000..995d4ea9 --- /dev/null +++ b/app/hire/ft2mkyEVxHrAJwaphVVSop3TIau0pWDq/listings/create/page.tsx @@ -0,0 +1,29 @@ +"use client"; + +import CreateJobPage from "@/components/features/hire/listings/createJob"; +import { JobService } from "@/lib/api/services"; +import { CreateJobChallengeListingPayload } from "@/lib/db/db.types"; + +export default function CreateSuperJobPageRoute() { + const createSuperJob = async (job: CreateJobChallengeListingPayload) => { + try { + const response = await JobService.createSuperJob(job); + if (!response?.success) { + return { + success: false, + error: response?.message || "Could not create super listing", + }; + } + return response; + } catch (error: any) { + return { + success: false, + error: error.message || "Failed to create super listing", + }; + } + }; + + return ( + + ); +} diff --git a/app/hire/layout.tsx b/app/hire/layout.tsx index 5bae964f..1b6fc1a8 100644 --- a/app/hire/layout.tsx +++ b/app/hire/layout.tsx @@ -11,7 +11,7 @@ import Head from "next/head"; import AllowLanding from "./allowLanding"; import { ConversationsContextProvider } from "@/hooks/use-conversation"; import { PocketbaseProvider, usePocketbase } from "@/lib/pocketbase"; -import { ModalProvider } from "@/components/providers/ModalProvider"; +import { ModalProvider } from "@/components/providers/modal-provider/ModalProvider"; import { NotificationListener } from "./notification-listener"; const baseUrl = diff --git a/app/hire/listings/edit/page.tsx b/app/hire/listings/edit/page.tsx index 31744770..1b31f170 100644 --- a/app/hire/listings/edit/page.tsx +++ b/app/hire/listings/edit/page.tsx @@ -4,7 +4,7 @@ import ContentLayout from "@/components/features/hire/content-layout"; import EditJobPage from "@/components/features/hire/listings/editJob"; import { Loader } from "@/components/ui/loader"; import { JobService } from "@/lib/api/services"; -import { Job } from "@/lib/db/db.types"; +import { Job, UpdateJobChallengeListingPayload } from "@/lib/db/db.types"; import { useSearchParams } from "next/navigation"; import { Suspense, useEffect, useState } from "react"; @@ -26,7 +26,7 @@ function EditJobPageRouteContent() { const updateJob = async ( job_id: string, - job: Partial, + job: UpdateJobChallengeListingPayload, ): Promise<{ success: boolean }> => { try { setSaving(true); diff --git a/app/posthog-provider.tsx b/app/posthog-provider.tsx index 8435cdb1..5cae18c4 100644 --- a/app/posthog-provider.tsx +++ b/app/posthog-provider.tsx @@ -2,7 +2,7 @@ import { useEffect } from "react"; import posthog from "posthog-js"; -import { PostHogProvider as PHProvider } from "posthog-js/react"; +import { PostHogProvider as PHProvider } from "@posthog/react"; export function PostHogProvider({ children }: { children: React.ReactNode }) { useEffect(() => { diff --git a/app/student/forms/[name]/page.tsx b/app/student/forms/[name]/page.tsx deleted file mode 100644 index 4ccb2d94..00000000 --- a/app/student/forms/[name]/page.tsx +++ /dev/null @@ -1,61 +0,0 @@ -"use client"; - -import { useFormRendererContext } from "@/components/features/student/forms/form-renderer.ctx"; -import { FormAndDocumentLayout } from "@/components/features/student/forms/FormFlowRouter"; -import { useFormsLayout } from "../layout"; -import { useParams } from "next/navigation"; -import { useEffect } from "react"; -import { toast } from "sonner"; - -/** - * The individual form page. - * Allows viewing an individual form. - */ -export default function FormPage() { - const params = useParams(); - const form = useFormRendererContext(); - const { setCurrentFormName, setCurrentFormLabel } = useFormsLayout(); - - // Show mobile notice toast on mount - useEffect(() => { - const isMobile = window.innerWidth < 640; // sm breakpoint - if (isMobile) { - toast( - "Our desktop experience might currently be preferable, so let us know if you have insights about how we can make mobile better! Chat us on Facebook or email us at hello@betterinternship.com if you go through any issues.", - { - duration: 6000, - className: "text-justify", - }, - ); - } - }, []); - - useEffect(() => { - const { name } = params; - form.updateFormName(name as string); - setCurrentFormName(name as string); - - return () => setCurrentFormName(null); - }, [params, setCurrentFormName, form]); - - useEffect(() => { - setCurrentFormLabel(form.formLabel); - }, [form.formLabel, setCurrentFormLabel]); - - // Warn user before unloading the page - useEffect(() => { - const handleBeforeUnload = (e: BeforeUnloadEvent) => { - e.preventDefault(); - e.returnValue = ""; - }; - - window.addEventListener("beforeunload", handleBeforeUnload); - return () => window.removeEventListener("beforeunload", handleBeforeUnload); - }, []); - - return ( -
- -
- ); -} diff --git a/app/student/forms/components/FormActionAccordion.tsx b/app/student/forms/components/FormActionAccordion.tsx new file mode 100644 index 00000000..dfd7ee84 --- /dev/null +++ b/app/student/forms/components/FormActionAccordion.tsx @@ -0,0 +1,36 @@ +import { + Accordion, + AccordionItem, + AccordionContent, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { FormActionButtons } from "./FormActionButtons"; +import { FormSigningPartyTimeline } from "./FormSigningPartyTimeline"; + +export const FormActionAccordion = ({ + handleSignViaBetterInternship, + handlePrintForWetSignature, +}: { + handleSignViaBetterInternship: () => void; + handlePrintForWetSignature: () => void; +}) => { + return ( + + + + Generate another + + + +
+ +
+
+
+
+ ); +}; diff --git a/app/student/forms/components/FormActionButtons.tsx b/app/student/forms/components/FormActionButtons.tsx new file mode 100644 index 00000000..7cfb166e --- /dev/null +++ b/app/student/forms/components/FormActionButtons.tsx @@ -0,0 +1,72 @@ +import { Eye, PenLineIcon, Printer } from "lucide-react"; + +import { useFormRendererContext } from "@/components/features/student/forms/form-renderer.ctx"; +import { Button } from "@/components/ui/button"; +import useModalRegistry from "@/components/modals/modal-registry"; +import { cn } from "@/lib/utils"; + +export const FormActionButtons = ({ + handleSignViaBetterInternship, + handlePrintForWetSignature, + align = "start", +}: { + handleSignViaBetterInternship: () => void; + handlePrintForWetSignature: () => void; + align?: "start" | "end"; +}) => { + const form = useFormRendererContext(); + const modalRegistry = useModalRegistry(); + const recipients = form.formMetadata.getSigningParties(); + + return ( + <> +
+ + +
+ + + ); +}; diff --git a/app/student/forms/components/FormDashboard.tsx b/app/student/forms/components/FormDashboard.tsx new file mode 100644 index 00000000..fb5ec8e4 --- /dev/null +++ b/app/student/forms/components/FormDashboard.tsx @@ -0,0 +1,628 @@ +"use client"; + +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 { Loader } from "@/components/ui/loader"; +import { + FormRendererContextBridge, + useFormRendererContext, +} from "@/components/features/student/forms/form-renderer.ctx"; +import { + FormFillerContextBridge, + useFormFiller, +} from "@/components/features/student/forms/form-filler.ctx"; +import { + SignContextBridge, + useSignContext, +} from "@/components/providers/sign.ctx"; +import { FormSigningLayout } from "./FormSigningLayout"; +import { IFormSigningParty } from "@betterinternship/core/forms"; +import { FormHistoryView } from "@/components/forms/FormHistoryView"; +import { FormTemplatesList } from "./FormTemplatesList"; +import { FormActionButtons } from "./FormActionButtons"; +import { FormActionAccordion } from "./FormActionAccordion"; +import { FormSigningPartyTimeline } from "./FormSigningPartyTimeline"; +import { FormMobileCloseConfirmation } from "./FormMobileCloseConfirmation"; +import { useMobile } from "@/hooks/use-mobile"; +import useModalRegistry from "@/components/modals/modal-registry"; +import { HeaderIcon, HeaderText } from "@/components/ui/text"; +import { useSearchParams } from "next/navigation"; +import { toast } from "sonner"; +import { useHeaderContext } from "@/lib/ctx-header"; +import { useGlobalModal } from "@/components/providers/modal-provider/ModalProvider"; +import { + FRESH_FORMS_QUERY_PARAM, + clearFreshHistoryCutoffMsInStorage, + getFreshHistoryCutoffMsFromStorage, + setFreshHistoryCutoffMsInStorage, +} from "../fresh-history"; + +type GeneratedFormItem = { + form_process_id?: string; + label: string; + prefilled_document_id?: string | null; + pending_document_id?: string | null; + signed_document_id?: string | null; + latest_document_url?: string | null; + timestamp: string; + signing_parties?: IFormSigningParty[]; + status?: string | null; + rejection_reason?: string; + pending?: boolean; +}; + +const getTimestampMs = (timestamp?: string) => { + const parsed = Date.parse(timestamp ?? ""); + return Number.isNaN(parsed) ? null : parsed; +}; + +export default function FormDashboard({ + generatedForms, + formTemplates, + isLoading, +}: { + generatedForms: GeneratedFormItem[]; + formTemplates: FormTemplate[]; + isLoading: boolean; +}) { + const { isMobile } = useMobile(); + const { setDesktopHeaderHidden } = useHeaderContext(); + const { closeModal } = useGlobalModal(); + const searchParams = useSearchParams(); + const modalRegistry = useModalRegistry(); + const headerTapStateRef = useRef<{ + count: number; + timer: ReturnType | null; + }>({ count: 0, timer: null }); + const [selectedTemplate, setSelectedTemplate] = useState(); + const [isMobileSigningFlow, setIsMobileSigningFlow] = useState(false); + const [isMobileExitConfirmationOpen, setIsMobileExitConfirmationOpen] = + useState(false); + const [noEsign, setNoEsign] = useState(false); + const [isSigningFlow, setIsSigningFlow] = useState(false); + const [freshHistoryCutoffMs, setFreshHistoryCutoffMs] = useState< + number | null + >(null); + const form = useFormRendererContext(); + const formFiller = useFormFiller(); + const signContext = useSignContext(); + const recipients = form.formMetadata.getSigningParties(); + const filteredGeneratedForms = useMemo(() => { + if (!freshHistoryCutoffMs) return generatedForms ?? []; + + return (generatedForms ?? []).filter((entry) => { + const timestampMs = getTimestampMs(entry.timestamp); + if (timestampMs === null) return true; + return timestampMs >= freshHistoryCutoffMs; + }); + }, [generatedForms, freshHistoryCutoffMs]); + const sortedTemplates = useMemo( + () => + formTemplates?.toSorted((a, b) => { + const aLabel = a.formLabel.replaceAll(/[()[\]\-,]/g, ""); + const bLabel = b.formLabel.replaceAll(/[()[\]\-,]/g, ""); + return aLabel.localeCompare(bLabel); + }) ?? [], + [formTemplates], + ); + const hasHistoryLogs = useMemo( + () => + filteredGeneratedForms.some( + (entry) => entry.label === form.formLabel || !form.formLabel, + ), + [filteredGeneratedForms, form.formLabel], + ); + + useEffect(() => { + if (typeof window === "undefined") return; + + const mode = searchParams.get(FRESH_FORMS_QUERY_PARAM); + + if (mode === "0" || mode === "off") { + clearFreshHistoryCutoffMsInStorage(); + setFreshHistoryCutoffMs(null); + return; + } + + if (mode === "reset") { + const cutoff = Date.now(); + setFreshHistoryCutoffMsInStorage(cutoff); + setFreshHistoryCutoffMs(cutoff); + return; + } + + if (mode === "1") { + const existing = getFreshHistoryCutoffMsFromStorage(); + if (existing) { + setFreshHistoryCutoffMs(existing); + return; + } + + const cutoff = Date.now(); + setFreshHistoryCutoffMsInStorage(cutoff); + setFreshHistoryCutoffMs(cutoff); + return; + } + + setFreshHistoryCutoffMs(getFreshHistoryCutoffMsFromStorage()); + }, [searchParams]); + + useEffect(() => { + return () => { + if (headerTapStateRef.current.timer) { + clearTimeout(headerTapStateRef.current.timer); + } + }; + }, []); + + useEffect(() => { + setDesktopHeaderHidden(!isMobile && isSigningFlow); + return () => setDesktopHeaderHidden(false); + }, [isMobile, isSigningFlow, setDesktopHeaderHidden]); + + const handleHeaderSecretTap = useCallback(() => { + const tapState = headerTapStateRef.current; + tapState.count += 1; + + if (tapState.timer) clearTimeout(tapState.timer); + + if (tapState.count >= 5) { + const cutoff = Date.now(); + setFreshHistoryCutoffMsInStorage(cutoff); + setFreshHistoryCutoffMs(cutoff); + tapState.count = 0; + tapState.timer = null; + toast.success( + "Fresh testing mode enabled. Old history is hidden until new forms are created.", + ); + return; + } + + tapState.timer = setTimeout(() => { + tapState.count = 0; + tapState.timer = null; + }, 2000); + }, []); + + const handleSignViaBetterInternship = () => { + setIsSigningFlow(true); + setNoEsign(false); + }; + + const handlePrintForWetSignature = () => { + setIsSigningFlow(true); + setNoEsign(true); + }; + + const handleTemplateSelect = useCallback( + (template: FormTemplate) => { + setSelectedTemplate(template); + form.updateFormName(template.formName); + }, + [form], + ); + + useEffect(() => { + if (!isMobile) { + modalRegistry.formTemplateDetails.close(); + setIsMobileSigningFlow(false); + return; + } + + if (!selectedTemplate) { + modalRegistry.formTemplateDetails.close(); + setIsMobileSigningFlow(false); + return; + } + + modalRegistry.formTemplateDetails.open({ + title: selectedTemplate.formLabel, + content: ( + + + + + + + + ), + onRequestClose: () => { + if (isMobileSigningFlow) { + setIsMobileExitConfirmationOpen(true); + return; + } + setSelectedTemplate(undefined); + }, + closeOnBackdropClick: !isMobileSigningFlow, + closeOnEscapeKey: !isMobileSigningFlow, + mobileFullscreen: isMobileSigningFlow, + onClose: () => { + setSelectedTemplate(undefined); + setIsMobileSigningFlow(false); + setIsMobileExitConfirmationOpen(false); + }, + }); + }, [ + isMobile, + selectedTemplate, + filteredGeneratedForms, + isMobileSigningFlow, + isMobileExitConfirmationOpen, + form, + formFiller, + signContext, + form.loading, + form.document.name, + form.document.url, + form.formLabel, + ]); + + useEffect(() => { + if (!isMobile) return; + + const handlePopState = () => { + setIsMobileSigningFlow(false); + setIsMobileExitConfirmationOpen(false); + setSelectedTemplate(undefined); + closeModal("form-template-details"); + }; + + window.addEventListener("popstate", handlePopState); + return () => window.removeEventListener("popstate", handlePopState); + }, [isMobile, closeModal]); + + useEffect(() => { + if (!isMobile) return; + + const resetMobileTemplateModalState = () => { + setIsMobileSigningFlow(false); + setIsMobileExitConfirmationOpen(false); + setSelectedTemplate(undefined); + closeModal("form-template-details"); + }; + + const handlePageHide = () => { + resetMobileTemplateModalState(); + }; + + const handlePageShow = (event: PageTransitionEvent) => { + if (!event.persisted) return; + resetMobileTemplateModalState(); + }; + + window.addEventListener("pagehide", handlePageHide); + window.addEventListener("pageshow", handlePageShow); + return () => { + window.removeEventListener("pagehide", handlePageHide); + window.removeEventListener("pageshow", handlePageShow); + }; + }, [isMobile, closeModal]); + + useEffect(() => { + return () => { + closeModal("form-template-details"); + }; + }, [closeModal]); + + if (isLoading) return Loading form templates...; + + return ( +
+
+ + +
+ {selectedTemplate ? ( +
+
+ setIsSigningFlow(false)} + /> +
+ +
+ {form.loading || + form.document.name !== selectedTemplate.formName ? ( + Loading form template... + ) : ( +
+
+

+ {selectedTemplate?.formLabel} +

+
+ {hasHistoryLogs ? ( + + ) : ( + <> +
+ {recipients.length > 1 + ? "These people will receive this form, in this order:" + : "This form does not require any signatures."} +
+ + +
+ +
+ + )} + {hasHistoryLogs && ( + <> +
+

+ History +

+

+ Previously generated versions of this form. +

+
+ + + )} +
+ )} +
+
+ ) : ( +
+ + {sortedTemplates.length ? ( +
+ Click on a form template to view. +
+ ) : ( +
+ We don't have form templates for your department. +
+ )} +
+ )} +
+
+
+ ); +} + +function MobileFormTemplateDetailsContent({ + selectedTemplate, + generatedForms, + isSigningFlow, + setIsSigningFlow, + showExitConfirmation, + setShowExitConfirmation, +}: { + selectedTemplate: FormTemplate; + generatedForms: GeneratedFormItem[]; + isSigningFlow: boolean; + setIsSigningFlow: (value: boolean | ((prev: boolean) => boolean)) => void; + showExitConfirmation: boolean; + setShowExitConfirmation: (value: boolean) => void; +}) { + const form = useFormRendererContext(); + const [noEsign, setNoEsign] = useState(false); + const recipients = form.formMetadata.getSigningParties(); + const hasHistoryLogs = useMemo( + () => + (generatedForms ?? []).some( + (entry) => entry.label === form.formLabel || !form.formLabel, + ), + [generatedForms, form.formLabel], + ); + + const handleSignViaBetterInternship = () => { + setIsSigningFlow(true); + setNoEsign(false); + }; + + const handlePrintForWetSignature = () => { + setIsSigningFlow(true); + setNoEsign(true); + }; + + return ( +
+ + {isSigningFlow ? ( + + setIsSigningFlow(false)} + /> + setShowExitConfirmation(false)} + onConfirm={() => { + setShowExitConfirmation(false); + setIsSigningFlow(false); + }} + /> + + ) : ( + + {form.loading || + form.document.name !== selectedTemplate.formName ? ( + Loading form template... + ) : ( +
+ {hasHistoryLogs ? ( + + ) : ( + <> +
+ {recipients.length > 1 + ? "These people will receive this form, in this order:" + : "This form does not require any signatures."} +
+ + +
+ +
+ + )} + {hasHistoryLogs && ( + <> +
+

+ History +

+

+ Previously generated versions of this form. +

+
+ + + )} +
+ )} +
+ )} +
+
+ ); +} diff --git a/app/student/forms/components/FormMobileCloseConfirmation.tsx b/app/student/forms/components/FormMobileCloseConfirmation.tsx new file mode 100644 index 00000000..b1296be6 --- /dev/null +++ b/app/student/forms/components/FormMobileCloseConfirmation.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { AnimatePresence, motion } from "framer-motion"; +import { Button } from "@/components/ui/button"; + +interface FormMobileCloseConfirmationProps { + open: boolean; + onCancel: () => void; + onConfirm: () => void; +} + +export function FormMobileCloseConfirmation({ + open, + onCancel, + onConfirm, +}: FormMobileCloseConfirmationProps) { + return ( + + {open && ( + + e.stopPropagation()} + > +

+ Exit form? +

+

+ If you exit now, your progress will be lost. +

+
+ + +
+
+
+ )} +
+ ); +} diff --git a/app/student/forms/components/FormSigningLayout.tsx b/app/student/forms/components/FormSigningLayout.tsx new file mode 100644 index 00000000..dd99d7d1 --- /dev/null +++ b/app/student/forms/components/FormSigningLayout.tsx @@ -0,0 +1,1089 @@ +"use client"; + +import { ArrowLeft, LucideClipboardCheck, MailWarningIcon } from "lucide-react"; +import { FormPreviewPdfDisplay } from "@/components/features/student/forms/previewer"; +import { FormFillerRenderer } from "@/components/features/student/forms/FormFillerRenderer"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Progress } from "@/components/ui/progress"; +import { cn } from "@/lib/utils"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useFormRendererContext } from "@/components/features/student/forms/form-renderer.ctx"; +import { useFormFiller } from "@/components/features/student/forms/form-filler.ctx"; +import { useMyAutofill, useMyAutofillUpdate } from "@/hooks/use-my-autofill"; +import { toast } from "sonner"; +import { toastPresets } from "@/components/ui/sonner-toast"; +import { FormValues, IFormSigningParty } from "@betterinternship/core/forms"; +import { TextLoader } from "@/components/ui/loader"; +import { FormService } from "@/lib/api/services"; +import useModalRegistry from "@/components/modals/modal-registry"; +import { getClientAudit } from "@/lib/audit"; +import { useQueryClient } from "@tanstack/react-query"; +import { useStateRecord } from "@/hooks/base/useStateRecord"; +import { useFormFilloutProcessRunner } from "@/hooks/forms/filloutFormProcess"; +import { useAppContext } from "@/lib/ctx-app"; +import { FormSigningPartyTimeline } from "./FormSigningPartyTimeline"; +import { getFreshHistoryCutoffMsFromStorage } from "../fresh-history"; +import { getRecipientEmailErrors } from "./recipient-email-validation"; +import { useSignContext } from "@/components/providers/sign.ctx"; + +interface FlowTestSigningLayoutProps { + formLabel?: string; + documentUrl?: string; + recipients: IFormSigningParty[]; + noEsign?: boolean; + onBack: () => void; +} + +const getFieldValue = (values: Record, fieldKey: string) => { + if (Object.prototype.hasOwnProperty.call(values, fieldKey)) { + return values[fieldKey]; + } + + const defaultKey = `${fieldKey}:default`; + if (Object.prototype.hasOwnProperty.call(values, defaultKey)) { + return values[defaultKey]; + } + + if (fieldKey.endsWith(":default")) { + const baseKey = fieldKey.slice(0, -8); + if (Object.prototype.hasOwnProperty.call(values, baseKey)) { + return values[baseKey]; + } + } + + return undefined; +}; + +const areFormValuesEqual = ( + left: Record, + right: Record, +) => { + const leftEntries = Object.entries(left); + const rightEntries = Object.entries(right); + + if (leftEntries.length !== rightEntries.length) return false; + + return leftEntries.every(([key, value]) => right[key] === value); +}; + +type SigningStep = "timeline" | "fields" | "preview-review" | "confirm"; + +const DESKTOP_BACK_STEP_RESET_DELAY_MS = 320; +const COMPACT_SIGNING_LAYOUT_BREAKPOINT_PX = 1150; + +export function FormSigningLayout({ + formLabel, + documentUrl, + recipients, + noEsign, + onBack, +}: FlowTestSigningLayoutProps) { + const form = useFormRendererContext(); + const modalRegistry = useModalRegistry(); + const formFiller = useFormFiller(); + const autofillValues = useMyAutofill(); + const updateAutofill = useMyAutofillUpdate(); + const queryClient = useQueryClient(); + const signContext = useSignContext(); + const { isMobile } = useAppContext(); + const [isCompactSigningLayout, setIsCompactSigningLayout] = useState(false); + const isMobileLayout = isMobile || isCompactSigningLayout; + const isFreshFormsModeEnabled = getFreshHistoryCutoffMsFromStorage() !== null; + const hasInitiatorRecipient = recipients.some( + (recipient) => recipient.signatory_source?._id === "initiator", + ); + const initialNoRecipientStep = !hasInitiatorRecipient || !!noEsign; + const initialStep: SigningStep = isMobileLayout + ? initialNoRecipientStep + ? "fields" + : "timeline" + : initialNoRecipientStep + ? "fields" + : "timeline"; + const hasInitializedFieldValuesRef = useRef(false); + const latestValuesRef = useRef({}); + const [previewValues, setPreviewValues] = useState({}); + const [nextLoading, setNextLoading] = useState(false); + const [recipientEmails, recipientEmailActions] = useStateRecord({}); + const [recipientErrors, recipientErrorActions] = useStateRecord({}); + const [hasConfirmedDetails, setHasConfirmedDetails] = useState(false); + const [areRequiredFieldsComplete, setAreRequiredFieldsComplete] = + useState(false); + const [currentStep, setCurrentStep] = useState(initialStep); + const [mobileFieldsTab, setMobileFieldsTab] = useState<"form" | "preview">( + "form", + ); + const [mobilePreviewNeedsAttention, setMobilePreviewNeedsAttention] = + useState(false); + const [selectedFieldSource, setSelectedFieldSource] = useState< + "form" | "pdf" | null + >(null); + const [selectionTick, setSelectionTick] = useState(0); + + useEffect(() => { + if (typeof window === "undefined") return; + + const updateCompactSigningLayout = () => { + setIsCompactSigningLayout( + window.innerWidth < COMPACT_SIGNING_LAYOUT_BREAKPOINT_PX, + ); + }; + + updateCompactSigningLayout(); + window.addEventListener("resize", updateCompactSigningLayout); + return () => + window.removeEventListener("resize", updateCompactSigningLayout); + }, []); + + const fieldOwnerByName = useMemo(() => { + const ownerMap = new Map(); + const allBlocks = form.formMetadata.getBlocksForEditorService(); + allBlocks.forEach((block) => { + const fieldName = + block.field_schema?.field ?? block.phantom_field_schema?.field; + if (!fieldName || ownerMap.has(fieldName)) return; + ownerMap.set(fieldName, block.signing_party_id); + }); + return ownerMap; + }, [form.formMetadata, form.formName]); + const formFilloutProcess = useFormFilloutProcessRunner(); + const fromMe = useMemo( + () => + recipients.some( + (recipient) => recipient.signatory_source?._id === "initiator", + ), + [recipients], + ); + const noRecipientStep = useMemo(() => !fromMe || noEsign, [fromMe, noEsign]); + const generateWithNoSignature = useMemo( + () => !recipients.length || noEsign, + [recipients, noEsign], + ); + const desktopSteps: SigningStep[] = noRecipientStep + ? ["fields", "confirm"] + : ["timeline", "fields", "confirm"]; + const mobileSteps: SigningStep[] = noRecipientStep + ? ["fields", "preview-review", "confirm"] + : ["timeline", "fields", "preview-review", "confirm"]; + const steps = isMobileLayout ? mobileSteps : desktopSteps; + const isMobilePreviewTabActive = + isMobileLayout && currentStep === "fields" && mobileFieldsTab === "preview"; + const isMobilePreviewReviewStep = + isMobileLayout && currentStep === "preview-review"; + const isMobilePreviewPaneActive = + isMobileLayout && (isMobilePreviewTabActive || isMobilePreviewReviewStep); + const showDesktopPreviewPane = !isMobileLayout && currentStep !== "timeline"; + const shouldMountTimelinePane = !isMobileLayout || currentStep === "timeline"; + const shouldMountFieldsPane = !isMobileLayout || currentStep === "fields"; + const shouldMountConfirmPane = !isMobileLayout || currentStep === "confirm"; + const stepNumber = Math.max(steps.indexOf(currentStep) + 1, 1); + const desktopStepNumber = Math.max(desktopSteps.indexOf(currentStep) + 1, 1); + const desktopTotalSteps = desktopSteps.length; + const desktopCurrentTaskTitle = + currentStep === "timeline" + ? "Recipients" + : currentStep === "confirm" + ? "Review" + : "Fill Details"; + const desktopProgressPercent = + desktopTotalSteps <= 1 + ? 100 + : (desktopStepNumber / desktopTotalSteps) * 100; + const mobileStepIndexByStep = useMemo( + () => new Map(mobileSteps.map((step, index) => [step, index])), + [mobileSteps], + ); + const mobileStepPaneHiddenClass = "opacity-0 pointer-events-none invisible"; + + const getMobileStepHiddenClass = useCallback( + (step: SigningStep) => { + const currentIndex = mobileStepIndexByStep.get(currentStep) ?? 0; + const stepIndex = mobileStepIndexByStep.get(step) ?? 0; + + return stepIndex < currentIndex + ? "-translate-x-6 opacity-0 pointer-events-none invisible" + : "translate-x-6 opacity-0 pointer-events-none invisible"; + }, + [currentStep, mobileStepIndexByStep], + ); + + const blurActiveFormControl = useCallback(() => { + if (typeof document === "undefined") return; + const activeElement = document.activeElement as HTMLElement | null; + if (!activeElement) return; + + const tagName = activeElement.tagName; + const isFormControl = + tagName === "INPUT" || + tagName === "TEXTAREA" || + tagName === "SELECT" || + activeElement.isContentEditable; + + if (isFormControl) activeElement.blur(); + }, []); + + const goToStep = useCallback((nextStep: SigningStep) => { + setCurrentStep(nextStep); + }, []); + + const handleMobileFieldsTabChange = useCallback( + (nextTab: "form" | "preview") => { + setMobileFieldsTab(nextTab); + + if (nextTab === "preview") { + setPreviewValues((prev) => + areFormValuesEqual(prev, latestValuesRef.current) + ? prev + : latestValuesRef.current, + ); + setMobilePreviewNeedsAttention(false); + } + }, + [], + ); + + const previewKeyedFields = useMemo( + () => + (form.keyedFields ?? []).map((field) => ({ + ...field, + signing_party_id: fieldOwnerByName.get(field.field), + })), + [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 computeRequiredFieldsComplete = useCallback( + (nextValues: FormValues) => + initiatorManualFieldKeys.every( + (fieldKey) => !!getFieldValue(nextValues, fieldKey), + ), + [initiatorManualFieldKeys], + ); + + const handleValuesChange = useCallback( + (nextValues: FormValues) => { + const didValuesChange = !areFormValuesEqual( + latestValuesRef.current, + nextValues, + ); + const hasInitializedFieldValues = hasInitializedFieldValuesRef.current; + latestValuesRef.current = nextValues; + hasInitializedFieldValuesRef.current = true; + const nextRequiredFieldsComplete = + computeRequiredFieldsComplete(nextValues); + + setAreRequiredFieldsComplete((prev) => + prev === nextRequiredFieldsComplete ? prev : nextRequiredFieldsComplete, + ); + + setPreviewValues((prev) => + areFormValuesEqual(prev, nextValues) ? prev : nextValues, + ); + + if ( + hasInitializedFieldValues && + didValuesChange && + isMobileLayout && + currentStep === "fields" && + mobileFieldsTab !== "preview" + ) { + setMobilePreviewNeedsAttention(true); + } + }, + [ + computeRequiredFieldsComplete, + currentStep, + isMobileLayout, + mobileFieldsTab, + ], + ); + + useEffect(() => { + if (!isMobileLayout || currentStep === "fields") return; + + setPreviewValues((prev) => + areFormValuesEqual(prev, latestValuesRef.current) + ? prev + : latestValuesRef.current, + ); + }, [currentStep, isMobileLayout]); + + useEffect(() => { + if (isMobileLayout) return; + if (currentStep !== "preview-review") return; + setCurrentStep("confirm"); + }, [currentStep, isMobileLayout]); + + useEffect(() => { + if (!isMobileLayout) return; + const shouldBlur = + currentStep !== "fields" || + mobileFieldsTab === "preview" || + isMobilePreviewReviewStep; + if (shouldBlur) blurActiveFormControl(); + }, [ + blurActiveFormControl, + currentStep, + isMobileLayout, + isMobilePreviewReviewStep, + mobileFieldsTab, + ]); + + const handlePdfFieldSelect = (fieldName: string) => { + setSelectedFieldSource("pdf"); + setSelectionTick((prev) => prev + 1); + form.setSelectedPreviewId(fieldName); + if (isMobileLayout && currentStep === "fields") { + handleMobileFieldsTabChange("form"); + } else if (currentStep !== "fields") { + goToStep("fields"); + } + }; + + const handleFormFieldSelect = (fieldName: string) => { + setSelectedFieldSource("form"); + setSelectionTick((prev) => prev + 1); + form.setSelectedPreviewId(fieldName); + }; + + const recipientEmailErrors = useMemo( + () => getRecipientEmailErrors(recipientEmails), + [recipientEmails], + ); + + const nextEnabled = useMemo(() => { + switch (currentStep) { + case "timeline": + return ( + recipients.every((recipient) => { + if (recipient.signatory_source?._id !== "initiator") return true; + + const recipientEmail = + recipientEmails[ + form.formMetadata.getSigningPartyFieldName(recipient._id) + ]; + + return !!recipientEmail?.trim(); + }) && Object.keys(recipientEmailErrors).length === 0 + ); + case "fields": + return areRequiredFieldsComplete && signContext.hasAgreed; + case "preview-review": + return true; + case "confirm": + return true; + } + }, [ + areRequiredFieldsComplete, + currentStep, + recipients, + recipientEmailErrors, + recipientEmails, + signContext, + form, + ]); + + const handleNext = useCallback(async () => { + const additionalValues = { ...autofillValues, ...recipientEmails }; + const finalValues = formFiller.getFinalValues(additionalValues); + const errors = formFiller.validate(form.fields, additionalValues); + const emailErrors = recipientEmailErrors; + + // So it doesn't look like it's hanging + setNextLoading(true); + + switch (currentStep) { + case "timeline": + if (Object.keys(emailErrors).length) { + recipientErrorActions.overwrite(emailErrors); + toast.error( + "Some information is missing or incorrect", + toastPresets.destructive, + ); + } else { + recipientErrorActions.clearAll(); + handleMobileFieldsTabChange("form"); + goToStep("fields"); + } + + setNextLoading(false); + break; + case "fields": + if (Object.keys(errors).length) { + toast.error( + "Some information is missing or incorrect", + toastPresets.destructive, + ); + } else { + if (!isFreshFormsModeEnabled) + await updateAutofill(form.formName, form.fields, finalValues); + if (isMobileLayout) { + setMobilePreviewNeedsAttention(false); + goToStep("preview-review"); + } else { + goToStep("confirm"); + } + } + + setNextLoading(false); + break; + case "preview-review": + goToStep("confirm"); + setNextLoading(false); + break; + case "confirm": + setNextLoading(false); + break; + } + }, [ + autofillValues, + currentStep, + form, + formFiller, + isFreshFormsModeEnabled, + handleMobileFieldsTabChange, + isMobileLayout, + recipientEmailErrors, + recipientEmails, + recipientErrorActions, + updateAutofill, + ]); + + const getFirstRecipient = useCallback((): IFormSigningParty | undefined => { + const firstRecipient = recipients.find( + (recipient) => + recipient.signatory_source?._id === "initiator" || + !!recipient.signatory_account?.email, + ); + if (!firstRecipient) return; + const fieldName = form.formMetadata.getSigningPartyFieldName( + firstRecipient._id, + ); + + return { + ...firstRecipient, + signatory_account: { + email: + (recipientEmails[fieldName] || + firstRecipient.signatory_account?.email) ?? + "", + }, + }; + }, [form, recipients, recipientEmails]); + + const handleSubmit = useCallback(async () => { + setNextLoading(true); + const finalValues = formFiller.getFinalValues({ + ...autofillValues, + ...recipientEmails, + }); + + if (generateWithNoSignature) { + const response = await formFilloutProcess.run( + { + formName: form.formName, + formVersion: form.formVersion, + values: finalValues, + }, + { + label: form.formLabel, + timestamp: new Date().toISOString(), + }, + ); + + if (!response.success) { + setNextLoading(false); + alert("Something went wrong, please try again."); + console.error(response.message); + return; + } + + modalRegistry.formSubmissionSuccess.open("manual", () => { + setCurrentStep(initialStep); + onBack(); + }); + } else { + const response = await FormService.initiateForm({ + formName: form.formName, + formVersion: form.formVersion, + values: finalValues, + audit: getClientAudit(), + }); + + if (!response.success) { + setNextLoading(false); + alert("Something went wrong, please try again."); + console.error(response.message); + return; + } + + await queryClient.invalidateQueries({ queryKey: ["my-forms"] }); + modalRegistry.formSubmissionSuccess.open( + "esign", + () => { + setCurrentStep(initialStep); + onBack(); + }, + getFirstRecipient(), + ); + } + setNextLoading(false); + }, [ + autofillValues, + form, + formFilloutProcess, + generateWithNoSignature, + getFirstRecipient, + initialStep, + modalRegistry.formSubmissionSuccess, + onBack, + queryClient, + recipientEmails, + ]); + + // Clean up when switching form + useEffect(() => { + recipientEmailActions.clearAll(); + hasInitializedFieldValuesRef.current = false; + latestValuesRef.current = {}; + setPreviewValues({}); + setMobileFieldsTab("form"); + setMobilePreviewNeedsAttention(false); + setHasConfirmedDetails(false); + setAreRequiredFieldsComplete(initiatorManualFieldKeys.length === 0); + setCurrentStep(initialStep); + }, [formLabel, initialStep, initiatorManualFieldKeys.length, noEsign]); + + useEffect(() => { + if (currentStep === "confirm") { + setHasConfirmedDetails(false); + } + }, [currentStep]); + + const renderMobileFieldsTabs = () => + isMobileLayout && currentStep === "fields" ? ( +
+
+ + +
+
+ ) : null; + + return ( +
+
+
+ {!isMobileLayout && ( +
+
+ +
+ + {formLabel} + +
+
+ + {desktopCurrentTaskTitle} + + Step {desktopStepNumber} of {desktopTotalSteps} + + +
+
+
+ )} + {isMobileLayout && ( +
+
+ Step {stepNumber} of {steps.length} +
+
+ )} +
+
+ {renderMobileFieldsTabs()} + {isMobilePreviewReviewStep && ( +
+
+ Please review your inputs +
+
+ )} + {documentUrl ? ( + <> + + + ) : ( +
+ PDF preview unavailable. +
+ )} + {isMobilePreviewReviewStep && ( +
+
+ + +
+
+ )} +
+ +
+
+ {shouldMountTimelinePane && ( +
+
+
+

+ These people will receive this form, in this order: +

+ +
+
+

+ {!noRecipientStep && ( +

+ + + Don't know the recipient emails? That's okay! +
+ Enter a contact who can forward it to the correct + address. +
+
+ )} +

+
+
+
+
+ {isMobileLayout && ( + + )} + +
+
+
+ )} + + {shouldMountFieldsPane && ( +
+
+ {renderMobileFieldsTabs()} +
+ +
+
+ {isMobileLayout ? ( +
+ + +
+ ) : ( +
+ + +
+ )} +
+
+
+ )} + + {shouldMountConfirmPane && ( +
+
+
+ + + Please check if all your inputs are correct + + {!noRecipientStep && ( + + )} + +
+
+ {isMobileLayout ? ( +
+ + +
+ ) : ( +
+ + +
+ )} +
+
+
+ )} +
+
+
+
+
+
+ ); +} diff --git a/app/student/forms/components/FormSigningPartyTimeline.tsx b/app/student/forms/components/FormSigningPartyTimeline.tsx new file mode 100644 index 00000000..b3943f01 --- /dev/null +++ b/app/student/forms/components/FormSigningPartyTimeline.tsx @@ -0,0 +1,123 @@ +import { FormInput } from "@/components/EditForm"; +import { useFormRendererContext } from "@/components/features/student/forms/form-renderer.ctx"; +import { Badge } from "@/components/ui/badge"; +import { Timeline, TimelineItem } from "@/components/ui/timeline"; +import { StateRecord, StateRecordActions } from "@/hooks/base/useStateRecord"; +import { cn } from "@/lib/utils"; +import { useEffect } from "react"; +import { + getRecipientEmailErrors, + RECIPIENT_EMAIL_VALIDATION_DEBOUNCE_MS, +} from "./recipient-email-validation"; + +export const FormSigningPartyTimeline = ({ + recipientInputAPI, + isConfirmingRecipients, +}: { + recipientInputAPI?: { + recipientEmails: StateRecord; + recipientErrors: StateRecord; + recipientEmailActions: StateRecordActions; + recipientErrorActions: StateRecordActions; + }; + isConfirmingRecipients?: boolean; +}) => { + const form = useFormRendererContext(); + const recipients = form.formMetadata.getSigningParties(); + + useEffect(() => { + if (!recipientInputAPI?.recipientErrorActions || isConfirmingRecipients) { + return; + } + + const validationTimeout = window.setTimeout(() => { + recipientInputAPI.recipientErrorActions.overwrite( + getRecipientEmailErrors(recipientInputAPI.recipientEmails), + ); + }, RECIPIENT_EMAIL_VALIDATION_DEBOUNCE_MS); + + return () => window.clearTimeout(validationTimeout); + }, [ + isConfirmingRecipients, + recipientInputAPI?.recipientEmails, + recipientInputAPI?.recipientErrorActions, + ]); + + return ( + recipients.length > 1 && ( + + {recipients.map((recipient, index) => { + const isMe = recipient._id === "initiator"; + const fromMe = recipient.signatory_source?._id === "initiator"; + const fieldName = form.formMetadata.getSigningPartyFieldName( + recipient._id, + ); + return ( + + {recipient.signatory_title} + + } + subtitle={ + fromMe ? ( + !recipientInputAPI?.recipientEmails ? ( + + you will specify this email + + ) : isConfirmingRecipients ? ( + + {recipientInputAPI.recipientEmails[fieldName]} + + ) : ( +
+ + recipientInputAPI?.recipientErrorActions.clearOne( + fieldName, + ) + } + value={recipientInputAPI.recipientEmails[fieldName]} + placeholder={"Enter email..."} + className={cn( + recipientInputAPI.recipientErrors[fieldName] + ? "border-destructive text-destructive" + : "", + )} + setter={(value) => + recipientInputAPI.recipientEmailActions.setOne( + fieldName, + value, + ) + } + /> + {recipientInputAPI.recipientErrors[fieldName] && ( + + {recipientInputAPI.recipientErrors[fieldName]} + + )} +
+ ) + ) : recipient.signatory_account?.email ? ( + + {recipient.signatory_account.email ?? ""} + + ) : recipient._id !== "initiator" ? ( + + this email will come from someone else + + ) : ( + + ) + } + isLast={index === recipients.length - 1} + /> + ); + })} +
+ ) + ); +}; diff --git a/app/student/forms/components/FormTemplatesList.tsx b/app/student/forms/components/FormTemplatesList.tsx new file mode 100644 index 00000000..f459c0e6 --- /dev/null +++ b/app/student/forms/components/FormTemplatesList.tsx @@ -0,0 +1,65 @@ +import { useFormRendererContext } from "@/components/features/student/forms/form-renderer.ctx"; +import { FormTemplate } from "@/lib/db/use-moa-backend"; +import { ChevronRight } from "lucide-react"; +import { Card } from "@/components/ui/card"; +import { cn } from "@/lib/utils"; + +export const FormTemplatesList = ({ + templates, + selectedTemplate, + setSelectedTemplate, +}: { + templates: FormTemplate[]; + selectedTemplate?: FormTemplate; + setSelectedTemplate: (template: FormTemplate) => void; +}) => { + const form = useFormRendererContext(); + + return ( +
+ {templates?.map((template) => { + const isActive = template.formName === selectedTemplate?.formName; + return ( + + ); + })} +
+ ); +}; diff --git a/app/student/forms/components/recipient-email-validation.ts b/app/student/forms/components/recipient-email-validation.ts new file mode 100644 index 00000000..85e9cb0a --- /dev/null +++ b/app/student/forms/components/recipient-email-validation.ts @@ -0,0 +1,20 @@ +import { isValidEmail } from "@/lib/utils/string-utils"; + +export const RECIPIENT_EMAIL_VALIDATION_DEBOUNCE_MS = 300; + +export const getRecipientEmailErrors = ( + recipientEmails: Record, +) => { + return Object.entries(recipientEmails).reduce>( + (errors, [fieldName, emailValue]) => { + const trimmedEmail = emailValue.trim(); + + if (trimmedEmail && !isValidEmail(trimmedEmail)) { + errors[fieldName] = `${trimmedEmail} is not a valid email.`; + } + + return errors; + }, + {}, + ); +}; diff --git a/app/student/forms/fresh-history.ts b/app/student/forms/fresh-history.ts new file mode 100644 index 00000000..d358876c --- /dev/null +++ b/app/student/forms/fresh-history.ts @@ -0,0 +1,20 @@ +export const FRESH_FORMS_QUERY_PARAM = "freshForms"; +export const FRESH_FORMS_STORAGE_KEY = "formsFreshHistoryCutoffMs"; + +export const getFreshHistoryCutoffMsFromStorage = (): number | null => { + if (typeof window === "undefined") return null; + const raw = window.localStorage.getItem(FRESH_FORMS_STORAGE_KEY); + if (!raw) return null; + const parsed = Number(raw); + return Number.isFinite(parsed) ? parsed : null; +}; + +export const setFreshHistoryCutoffMsInStorage = (cutoffMs: number) => { + if (typeof window === "undefined") return; + window.localStorage.setItem(FRESH_FORMS_STORAGE_KEY, String(cutoffMs)); +}; + +export const clearFreshHistoryCutoffMsInStorage = () => { + if (typeof window === "undefined") return; + window.localStorage.removeItem(FRESH_FORMS_STORAGE_KEY); +}; diff --git a/app/student/forms/layout.tsx b/app/student/forms/layout.tsx index 715cdfe5..0ee4e366 100644 --- a/app/student/forms/layout.tsx +++ b/app/student/forms/layout.tsx @@ -1,166 +1,9 @@ "use client"; -import { - useState, - useEffect, - createContext, - useContext, - useMemo, - useCallback, - useLayoutEffect, -} from "react"; -import { useRouter, usePathname } from "next/navigation"; import { MyFormsContextProvider } from "./myforms.ctx"; import { FormRendererContextProvider } from "@/components/features/student/forms/form-renderer.ctx"; import { FormFillerContextProvider } from "@/components/features/student/forms/form-filler.ctx"; -import { FormsNavigation } from "@/components/features/student/forms/FormsNavigation"; -import { useMyForms } from "./myforms.ctx"; import { SignContextProvider } from "@/components/providers/sign.ctx"; -import { SonnerToaster } from "@/components/ui/sonner-toast"; -import { useMobile } from "@/hooks/use-mobile"; -import { useHeaderContext, MobileAddonConfig } from "@/lib/ctx-header"; - -interface FormsLayoutContextType { - activeView: "generate" | "history"; - setActiveView: (view: "generate" | "history") => void; - currentFormName: string | null; - setCurrentFormName: (name: string | null) => void; - currentFormLabel: string | null; - setCurrentFormLabel: (label: string | null) => void; -} - -const FormsLayoutContext = createContext( - undefined, -); - -export const useFormsLayout = () => { - const context = useContext(FormsLayoutContext); - if (!context) { - throw new Error("useFormsLayout must be used within FormsLayout"); - } - return context; -}; - -function FormsLayoutContent({ children }: { children: React.ReactNode }) { - const router = useRouter(); - const pathname = usePathname(); - const isMobile = useMobile(); - const { setMobileAddonConfig } = useHeaderContext(); - const myForms = useMyForms(); - const [manualActiveView, setManualActiveView] = useState< - "generate" | "history" | null - >(null); - const [isInitialized, setIsInitialized] = useState(false); - const [currentFormName, setCurrentFormName] = useState(null); - const [currentFormLabel, setCurrentFormLabel] = useState(null); - - const hasFormsToShow = (myForms?.forms?.length ?? 0) > 0; - - // Determine the active view: use manual selection if user clicked nav, otherwise derive from forms - const activeView = useMemo(() => { - if (manualActiveView !== null) { - return manualActiveView; - } - // Default: show history if there are forms, otherwise show generate - return hasFormsToShow ? "history" : "generate"; - }, [manualActiveView, hasFormsToShow]); - - // Check for view query parameter on mount - useEffect(() => { - if (typeof window !== "undefined") { - const params = new URLSearchParams(window.location.search); - const viewParam = params.get("view"); - if (viewParam === "history" || viewParam === "generate") { - setManualActiveView(viewParam); - // Clean up URL after reading param - window.history.replaceState({}, "", pathname); - } - // Mark as initialized regardless of whether we found a param - setIsInitialized(true); - } - }, [pathname]); - - const handleViewChange = useCallback( - (view: "generate" | "history") => { - setManualActiveView(view); - // If we're on a detail page, navigate back to /forms - if (pathname !== "/forms") { - router.push("/forms"); - } - }, - [pathname, router], - ); - - // Compute mobile addon config directly based on current state - const mobileAddonConfig: MobileAddonConfig = useMemo(() => { - if (!isMobile || !isInitialized) { - return { show: false }; - } - return { - show: true, - activeView, - onViewChange: handleViewChange, - currentFormName, - currentFormLabel, - }; - }, [ - isMobile, - isInitialized, - activeView, - handleViewChange, - currentFormName, - currentFormLabel, - ]); - - // Sync config to context after render - useLayoutEffect(() => { - setMobileAddonConfig(mobileAddonConfig); - }, [ - mobileAddonConfig.show, - mobileAddonConfig.activeView, - mobileAddonConfig.currentFormName, - mobileAddonConfig.currentFormLabel, - ]); - - // Clear addon config when layout unmounts - useLayoutEffect(() => { - return () => { - setMobileAddonConfig({ show: false }); - }; - }, [setMobileAddonConfig]); - - return ( - -
-
- -
- -
- {!isInitialized ?
: children} -
-
- - ); -} const FormsLayout = ({ children }: { children: React.ReactNode }) => { return ( @@ -171,10 +14,7 @@ const FormsLayout = ({ children }: { children: React.ReactNode }) => { - - {children} - - + {children} diff --git a/app/student/forms/myforms.ctx.tsx b/app/student/forms/myforms.ctx.tsx index 77c3d48f..0aea7469 100644 --- a/app/student/forms/myforms.ctx.tsx +++ b/app/student/forms/myforms.ctx.tsx @@ -1,7 +1,7 @@ /** * @ Author: BetterInternship * @ Create Time: 2025-12-18 15:17:08 - * @ Modified time: 2026-01-06 19:30:02 + * @ Modified time: 2026-03-04 17:03:50 * @ Description: * * These are the forms a user has generated or initiated. @@ -25,7 +25,7 @@ interface IMyForms { timestamp: string; rejection_reason?: string; signing_parties?: IFormSigningParty[]; - status?: string; + status?: string | null; }[]; loading: boolean; error?: string; @@ -55,7 +55,7 @@ export const MyFormsContextProvider = ({ } = useQuery({ queryKey: ["my-forms"], queryFn: () => FormService.getMyGeneratedForms(), - staleTime: 0, + staleTime: Infinity, gcTime: 60 * 60 * 1000, }); diff --git a/app/student/forms/page.tsx b/app/student/forms/page.tsx index 3f89ae69..2e1b649c 100644 --- a/app/student/forms/page.tsx +++ b/app/student/forms/page.tsx @@ -3,17 +3,20 @@ import { useProfileData } from "@/lib/api/student.data.api"; import { useRouter } from "next/navigation"; import { FormService } from "@/lib/api/services"; -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useMyForms } from "./myforms.ctx"; -import { FormGenerateView } from "../../../components/forms/FormGenerateView"; -import { FormHistoryView } from "../../../components/forms/FormHistoryView"; -import { useFormsLayout } from "./layout"; import { FORM_TEMPLATES_STALE_TIME, FORM_TEMPLATES_GC_TIME, } from "@/lib/consts/cache"; import { useEffect } from "react"; import { useAuthContext } from "@/lib/ctx-auth"; +import FormDashboard from "./components/FormDashboard"; +import { + useFormFilloutProcessHandled, + useFormFilloutProcessPending, + useFormFilloutProcessReader, +} from "@/hooks/forms/filloutFormProcess"; /** * The forms page component - shows either history or generate based on form count @@ -24,7 +27,7 @@ export default function FormsPage() { const profile = useProfileData(); const router = useRouter(); const myForms = useMyForms(); - const { activeView } = useFormsLayout(); + const queryClient = useQueryClient(); const { redirectIfNotLoggedIn, isAuthenticated } = useAuthContext(); // Auth redirect at body level (runs first) @@ -62,14 +65,29 @@ export default function FormsPage() { // enabled: !!updateInfo, // Only fetch after we have update info }); + // ? I think I can abstract this somehow in the future + // ? How it works right now: + // ? The form history renders a combination of the pulled forms (from the db) AND the pending forms (from the client process manager) + // ? BUT when a pending form turns into a handled form, the pulled forms doesn't update right away + // ? SO handled forms are also temporarily rendered WHILE they're not part of the pulled forms yet + // ? All the logic below really does is make sure that once the handled forms are in the pulled forms, they're not rendered anymore + // ? There has to be a better way to do this repeatably + const formFilloutProcess = useFormFilloutProcessReader(); + const pendingForms = useFormFilloutProcessPending(); + const handledForms = useFormFilloutProcessHandled(); + + // Refetch forms when no more pending left + // Yeppers kinda janky I know + useEffect(() => { + if (!formFilloutProcess.getAllPending().length) + void queryClient.invalidateQueries({ queryKey: ["my-forms"] }); + }, [formFilloutProcess.getAllPending()]); + return ( - <> - {/* Show the active view */} - {activeView === "history" ? ( - - ) : ( - - )} - + !!ft) ?? []} + isLoading={isLoading} + /> ); } diff --git a/app/student/layout.tsx b/app/student/layout.tsx index f054d63d..5649a9e9 100644 --- a/app/student/layout.tsx +++ b/app/student/layout.tsx @@ -10,8 +10,10 @@ 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/ModalProvider"; +import { ModalProvider } from "@/components/providers/modal-provider/ModalProvider"; import MobileNavWrapper from "@/components/shared/mobile-nav-wrapper"; +import { SonnerToaster } from "@/components/ui/sonner-toast"; +import { ClientProcessesProvider } from "@betterinternship/components"; const baseUrl = process.env.NEXT_PUBLIC_CLIENT_URL || "https://betterinternship.com"; @@ -93,16 +95,19 @@ const HTMLContent = ({ - - -
-
- {children} + + + +
+
+ {children} +
+
- -
- - + + + + diff --git a/app/student/profile/complete-profile/page.tsx b/app/student/profile/complete-profile/page.tsx index 2910d276..6de1d3d7 100644 --- a/app/student/profile/complete-profile/page.tsx +++ b/app/student/profile/complete-profile/page.tsx @@ -49,7 +49,7 @@ export default function IncompleteProfileContent({
-
@@ -108,10 +108,10 @@ function CompleteProfileStepper({ onFinish }: { onFinish: () => void }) { 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'); + const destination = params.get("dest"); // profile being edited const [profile, setProfile] = useState(() => ({ @@ -200,7 +200,8 @@ function CompleteProfileStepper({ onFinish }: { onFinish: () => void }) { subtitle: "Upload a PDF and we'll auto-fill what we can.", icon: Upload, canNext: () => { - const result = !!file && !isParsing && parsedReady && (response?.success ?? false); + const result = + !!file && !isParsing && parsedReady && (response?.success ?? false); return result; }, component: ( @@ -320,7 +321,10 @@ function CompleteProfileStepper({ onFinish }: { onFinish: () => void }) { }, }, }) - .then(() => { + .then(async () => { + await queryClient.invalidateQueries({ + queryKey: ["my-profile"], + }); setIsUpdating(false); const isLast = step + 1 >= steps.length; if (isLast) setShowComplete(true); @@ -355,10 +359,7 @@ function CompleteProfileStepper({ onFinish }: { onFinish: () => void }) { }; if (showComplete) { - return ; + return ; } return ( @@ -466,7 +467,6 @@ function StepBasicIdentity({ departments, get_departments_by_college, to_department_name, - to_university_name, get_colleges_by_university, to_college_name, } = useDbRefs(); @@ -500,12 +500,15 @@ function StepBasicIdentity({ if (collegeId) { const list = get_departments_by_college?.(collegeId); - setDepartmentOptions( - list.map((d) => ({ - id: d, - name: to_department_name(d) ?? "", - })), - ); + 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( @@ -543,7 +546,7 @@ function StepBasicIdentity({ onChange({ ...value, college: undefined, department: undefined }); } } else { - // no university selected -> show all colleges and clear college/department + // no university selected -> show all colleges without forcing a reset setCollegesOptions( colleges.map((d) => ({ id: d.id, @@ -552,11 +555,6 @@ function StepBasicIdentity({ university_id: d.university_id, })), ); - - // Clear selected college and department because no university is chosen - if (value.college || value.department) { - onChange({ ...value, college: undefined, department: undefined }); - } } }, [ value.university, @@ -647,7 +645,7 @@ function StepBasicIdentity({ } options={departmentOptions} placeholder="Select department…" - disabled={value.college === ""} + disabled={!value.college} />
@@ -657,7 +655,7 @@ function StepBasicIdentity({ value={value.degree ?? ""} setter={(val) => onChange({ ...value, degree: val.toString() })} placeholder="Select degree / program…" - disabled={value.department === ""} + disabled={!value.department} />
@@ -713,10 +711,10 @@ function StepAutoApply({ /* ---------------------- Completion (rendered OUTSIDE stepper) ---------------------- */ -function StepComplete({ +function StepComplete({ onDone, destination, -} : { +}: { onDone: () => void; destination: string | null; }) { @@ -724,14 +722,16 @@ function StepComplete({ const queryClient = useQueryClient(); useEffect(() => { - const t = setTimeout(async () => { - if (destination) { - // invalidate profile cache before redirecting. - await queryClient.invalidateQueries({ queryKey: ["my-profile"] }); - router.push(`/${destination}`); - } else { - onDone(); - } + 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]); diff --git a/app/student/search/[job_id]/page.tsx b/app/student/search/[job_id]/page.tsx index 7caf09b7..4a5d7aeb 100644 --- a/app/student/search/[job_id]/page.tsx +++ b/app/student/search/[job_id]/page.tsx @@ -106,7 +106,7 @@ export default function JobPage() { {job.data && ( -
+
@@ -152,9 +152,9 @@ export default function JobPage() { job={job.data} onClose={() => applyConfirmModalRef.current?.close()} onAddNow={goProfile} - onSubmit={async (text: string) => { + onSubmit={async (payload) => { applyConfirmModalRef.current?.close(); - await applyToJob(applicationActions, job.data, text).then( + 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 125391a3..bb34d9e5 100644 --- a/app/student/search/page.tsx +++ b/app/student/search/page.tsx @@ -138,16 +138,19 @@ export default function SearchPage() { } }, [selectedIds.size, selectMode]); - const toggleSelect = (jobId: string) => + const toggleSelect = (job: Job) => { + if (!job.id || job.challenge) return; + setSelectedIds((prev) => { const next = new Set(prev); - if (next.has(jobId)) { - next.delete(jobId); + if (next.has(job.id)) { + next.delete(job.id); } else { - next.add(jobId); + next.add(job.id); } return next; }); + }; const isSelected = (jobId?: string) => !!jobId && selectedIds.has(jobId); @@ -155,18 +158,25 @@ export default function SearchPage() { const selectAllOnPage = () => { const next = new Set(selectedIds); - jobsPage.forEach((j) => j.id && next.add(j.id)); + jobsPage.forEach((j) => { + if (j.id && !j.challenge) next.add(j.id); + }); setSelectedIds(next); }; const unselectAllOnPage = () => { const next = new Set(selectedIds); - jobsPage.forEach((j) => j.id && next.delete(j.id)); + jobsPage.forEach((j) => { + if (j.id && !j.challenge) next.delete(j.id); + }); setSelectedIds(next); }; const selectedJobsList = useMemo( - () => jobs.filteredJobs.filter((j) => j.id && selectedIds.has(j.id)), + () => + jobs.filteredJobs.filter( + (j) => j.id && selectedIds.has(j.id) && !j.challenge, + ), [jobs.filteredJobs, selectedIds], ); @@ -189,11 +199,7 @@ export default function SearchPage() { !isProfileBaseComplete(profile.data) || profile.data?.acknowledged_auto_apply === false ) { - if (isMobile) { - return router.push(`profile/complete-profile?dest=search`); - } else { - return modalRegistry.incompleteProfile.open(); - } + return router.push(`profile/complete-profile?dest=search`); } const allApplied = @@ -421,25 +427,26 @@ export default function SearchPage() { className="relative group" onClick={() => handleJobCardClick(job)} > - {/* Checkbox for mass apply - always visible */} - + {!job.challenge && ( + + )} {jobsPage.map((job) => (
- + {!job.challenge && ( + + )}
applyConfirmModalRef.current?.close()} onAddNow={goProfile} - onSubmit={(text: string) => { - return applyToJob(applicationActions, selectedJob, text).then( + onSubmit={(payload) => { + return applyToJob(applicationActions, selectedJob, payload).then( (response) => { if (!response.success) return alert(response.message); applyConfirmModalRef.current?.close(); diff --git a/app/tanstack-provider.tsx b/app/tanstack-provider.tsx index 4b5b35c8..3081cee0 100644 --- a/app/tanstack-provider.tsx +++ b/app/tanstack-provider.tsx @@ -21,7 +21,7 @@ const asyncStoragePersister = createAsyncStoragePersister({ storage: typeof window === "undefined" ? undefined : AsyncStorage, }); -persistQueryClient({ +void persistQueryClient({ queryClient, persister: asyncStoragePersister, maxAge: 24 * 60 * 60 * 1000, diff --git a/components/EditForm.tsx b/components/EditForm.tsx index 14e62e36..3f4cf342 100644 --- a/components/EditForm.tsx +++ b/components/EditForm.tsx @@ -231,7 +231,10 @@ export const FormInput = ({ setter && setter(e.target.value)} - className={className} + className={cn( + className, + "placeholder:text-gray-400 placeholder:italic focus:placeholder:text-primary/70", + )} {...props} />
@@ -241,8 +244,7 @@ export const FormInput = ({ /** * Big input */ -interface FormTextareaProps - extends React.InputHTMLAttributes { +interface FormTextareaProps extends React.InputHTMLAttributes { label?: string; setter?: (value: string) => void; required?: boolean; @@ -292,8 +294,7 @@ export const FormTextarea = ({ * * @component */ -interface FormDropdownProps - extends React.InputHTMLAttributes { +interface FormDropdownProps extends React.InputHTMLAttributes { options: { id: number | string; name: string }[]; label?: string; value?: string | number | string[]; @@ -345,8 +346,7 @@ export const FormDropdown = ({ * * @component */ -interface FormCheckboxProps - extends React.InputHTMLAttributes { +interface FormCheckboxProps extends React.InputHTMLAttributes { checked?: boolean; indeterminate?: boolean; label?: string; @@ -414,8 +414,7 @@ export const FormCheckbox = ({ ); }; -interface FormCheckBoxGroupProps - extends React.InputHTMLAttributes { +interface FormCheckBoxGroupProps extends React.InputHTMLAttributes { options: { value: string | number; label: string; description?: string }[]; values: (string | number)[]; setter: (value: any) => void; @@ -581,8 +580,7 @@ export const FormRadio = ({ * * Accepts/returns a number timestamp (ms) via `date` / `setter`. */ -interface FormDatePickerProps - extends React.InputHTMLAttributes { +interface FormDatePickerProps extends React.InputHTMLAttributes { label?: string; date?: number; setter?: (value?: number) => void; @@ -692,8 +690,7 @@ export const FormDatePicker = ({ * * Accepts/returns a number timestamp (ms) via `date` / `setter`. */ -interface FormMonthPickerProps - extends React.InputHTMLAttributes { +interface FormMonthPickerProps extends React.InputHTMLAttributes { label: string; /** ms since epoch; will be normalized to the first day of the month */ date?: number; diff --git a/components/features/hire/dashboard/ApplicantPage.tsx b/components/features/hire/dashboard/ApplicantPage.tsx index a4eef793..04c8967d 100644 --- a/components/features/hire/dashboard/ApplicantPage.tsx +++ b/components/features/hire/dashboard/ApplicantPage.tsx @@ -12,25 +12,26 @@ 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 { ArrowLeft, - Award, - FileText, - MessageCircle, - Phone, - Mail, - BriefcaseBusiness, - Github, - Linkedin, - SquareArrowOutUpRight, - MessageSquareText, - MessageSquarePlus, - MessageCirclePlus, - SendHorizonal, - Archive, - HandHelping, - } from "lucide-react"; +import { + ArrowLeft, + Award, + FileText, + MessageCircle, + Phone, + Mail, + BriefcaseBusiness, + Github, + Linkedin, + SquareArrowOutUpRight, + MessageSquareText, + MessageSquarePlus, + MessageCirclePlus, + SendHorizonal, + Archive, + HandHelping, +} from "lucide-react"; import { useRouter } from "next/navigation"; -import { useCallback, useEffect, useState, useRef } from "react"; +import { useCallback, useEffect, useState, useRef } from "react"; import { Divider } from "@/components/ui/divider"; import { Badge } from "@/components/ui/badge"; import { @@ -42,10 +43,7 @@ import { useModal } from "@/hooks/use-modal"; import { useSideModal } from "@/hooks/use-side-modal"; import { useConversation, useConversations } from "@/hooks/use-conversation"; import { Loader } from "@/components/ui/loader"; -import { - useProfile, - useEmployerApplications, -} from "@/hooks/use-employer-api"; +import { useProfile, useEmployerApplications } from "@/hooks/use-employer-api"; import { Message } from "@/components/ui/messages"; import { Textarea } from "@/components/ui/textarea"; import { Button } from "@/components/ui/button"; @@ -55,783 +53,949 @@ import { useAuthContext } from "@/app/hire/authctx"; import { ActionButton } from "@/components/ui/action-button"; interface ApplicantPageProps { - application: EmployerApplication | undefined; - jobID?: string | undefined; - userApplications?: EmployerApplication[] | undefined; - statuses: ActionItem[]; + application: EmployerApplication | undefined; + jobID?: string | undefined; + userApplications?: EmployerApplication[] | undefined; + statuses: ActionItem[]; } export function ApplicantPage({ - application, - jobID, - userApplications, - statuses -}:ApplicantPageProps) { - const router = useRouter(); - const user = application?.user as Partial; - const internshipPreferences = user?.internship_preferences; - const applications = useEmployerApplications(); - - const profile = useProfile(); - - const { isMobile } = useAppContext(); - const { isAuthenticated, redirectIfNotLoggedIn, loading } = useAuthContext(); - - redirectIfNotLoggedIn(); - - const [conversationId, setConversationId] = useState(""); - const conversations = useConversations(); - const updateConversationId = (userId: string) => { - let userConversation = conversations.data?.find((c) => - c?.subscribers?.includes(userId), - ); - setConversationId(userConversation?.id); - }; - - const messageInputRef = useRef(null); - const chatAnchorRef = useRef(null); - const [lastSending, setLastSending] = useState(false); - const [sending, setSending] = useState(false); - const conversation = useConversation("employer", conversationId); - const [exitingBack, setExitingBack] = useState(false); - - const { - to_university_name, - job_modes, - job_types, - job_categories, - get_app_status, - } = useDbRefs(); - - const endSend = () => { - setSending(false); - setTimeout(() => { - chatAnchorRef.current?.scrollIntoView({ behavior: "instant" }); - }, 100); - }; - - useEffect(() => { - updateConversationId(application?.user_id || "") - }, [application?.user_id]) - - useEffect(() => { - setLastSending(sending); - }, [sending]); - - useEffect(() => { - let timeout: NodeJS.Timeout; - if (!sending && !lastSending) - timeout = setTimeout(() => messageInputRef.current?.focus(), 200); - return () => timeout && clearTimeout(timeout); - }, [lastSending]); - - const currentStatusId = application?.status?.toString() ?? "0"; - const defaultStatus: ActionItem = { - id: currentStatusId, - label: get_app_status(application?.status!)?.name, - active: true, - disabled: false, - destructive: false, - highlighted: true, - highlightColor: statusMap.get(application?.status!)?.bgColor, - }; - - const { url: resumeURL, sync: syncResumeURL, loading: resumeLoading } = useFile({ - fetcher: useCallback( - async () => - await UserService.getUserResumeURL(application?.user_id ?? ""), - [user?.id], - ), - route: application - ? `/users/${user?.id}/resume` - : "", - }); - - const handleBack = () => { - setExitingBack(true); - if (window.history.length > 1) { - router.back(); - } else { - router.push('/dashboard'); - } + application, + jobID, + userApplications, + statuses, +}: ApplicantPageProps) { + const router = useRouter(); + const user = application?.user as Partial; + const internshipPreferences = user?.internship_preferences; + const challengeSubmission = application?.challenge_submission?.trim() ?? ""; + const hasChallengeSubmission = challengeSubmission.length > 0; + const applications = useEmployerApplications(); + + const profile = useProfile(); + + const { isMobile } = useAppContext(); + const { isAuthenticated, redirectIfNotLoggedIn, loading } = useAuthContext(); + + redirectIfNotLoggedIn(); + + const [conversationId, setConversationId] = useState(""); + const conversations = useConversations(); + const updateConversationId = (userId: string) => { + let userConversation = conversations.data?.find((c) => + c?.subscribers?.includes(userId), + ); + setConversationId(userConversation?.id); + }; + + const messageInputRef = useRef(null); + const chatAnchorRef = useRef(null); + const [lastSending, setLastSending] = useState(false); + const [sending, setSending] = useState(false); + const conversation = useConversation("employer", conversationId); + const [exitingBack, setExitingBack] = useState(false); + + const { + to_university_name, + job_modes, + job_types, + job_categories, + get_app_status, + } = useDbRefs(); + + const endSend = () => { + setSending(false); + setTimeout(() => { + chatAnchorRef.current?.scrollIntoView({ behavior: "instant" }); + }, 100); + }; + + useEffect(() => { + updateConversationId(application?.user_id || ""); + }, [application?.user_id]); + + useEffect(() => { + setLastSending(sending); + }, [sending]); + + useEffect(() => { + let timeout: NodeJS.Timeout; + if (!sending && !lastSending) + timeout = setTimeout(() => messageInputRef.current?.focus(), 200); + return () => timeout && clearTimeout(timeout); + }, [lastSending]); + + const currentStatusId = application?.status?.toString() ?? "0"; + const defaultStatus: ActionItem = { + id: currentStatusId, + label: get_app_status(application?.status!)?.name, + active: true, + disabled: false, + destructive: false, + highlighted: true, + highlightColor: statusMap.get(application?.status!)?.bgColor, + }; + + const { + url: resumeURL, + sync: syncResumeURL, + loading: resumeLoading, + } = useFile({ + fetcher: useCallback( + async () => + await UserService.getUserResumeURL(application?.user_id ?? ""), + [user?.id], + ), + route: application ? `/users/${user?.id}/resume` : "", + }); + + const handleBack = () => { + setExitingBack(true); + if (window.history.length > 1) { + router.back(); + } else { + router.push("/dashboard"); } + }; + + const { + open: openNewChatModal, + close: closeNewChatModal, + Modal: NewChatModal, + } = useModal("new-chat-modal", { + onClose: () => (conversation.unsubscribe(), setConversationId("")), + showCloseButton: false, + }); + + const { + open: openOldChatModal, + close: closeOldChatModal, + Modal: OldChatModal, + } = useModal("old-chat-modal", { + onClose: () => (conversation.unsubscribe(), setConversationId("")), + showCloseButton: false, + }); + + const { + open: openChatModal, + close: closeChatModal, + SideModal: ChatModal, + } = useSideModal("chat-modal", { + onClose: () => (conversation.unsubscribe(), setConversationId("")), + }); + + const { + open: openApplicantArchiveModal, + close: closeApplicantArchiveModal, + Modal: ApplicantArchiveModal, + } = useModal("applicant-archive-modal"); + + useEffect(() => { + if (application?.user_id) { + syncResumeURL(); + } + }, [application?.user_id, syncResumeURL]); - const { - open: openNewChatModal, - close: closeNewChatModal, - Modal: NewChatModal, - } = useModal("new-chat-modal", { - onClose: () => (conversation.unsubscribe(), setConversationId("")), - showCloseButton: false, - }); - - const { - open: openOldChatModal, - close: closeOldChatModal, - Modal: OldChatModal, - } = useModal("old-chat-modal", { - onClose: () => (conversation.unsubscribe(), setConversationId("")), - showCloseButton: false, - }); - - const { - open: openChatModal, - close: closeChatModal, - SideModal: ChatModal, - } = useSideModal("chat-modal", { - onClose: () => (conversation.unsubscribe(), setConversationId("")) - }); - - const { - open: openApplicantArchiveModal, - close: closeApplicantArchiveModal, - Modal: ApplicantArchiveModal, - } = useModal("applicant-archive-modal"); - - useEffect(() => { - if (application?.user_id) { - syncResumeURL(); - } - }, [application?.user_id, syncResumeURL]); - - const onChatClick = async () => { + const onChatClick = async () => { if (!application?.user_id) return; const userConversation = conversations.data?.find((c) => - c?.subscribers?.includes(application?.user_id) + c?.subscribers?.includes(application?.user_id), ); - if(userConversation){ + if (userConversation) { setConversationId(userConversation.id); openOldChatModal(); } else { openNewChatModal(); } - } - - // Handle message - const handleMessage = async (studentId: string | undefined, message: string) => { - if (message.trim() === "") return; + }; + + // 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), + ); - setSending(true); - let userConversation = conversations.data?.find((c) => - c?.subscribers?.includes(studentId), + if (!userConversation && studentId) { + const response = + await EmployerConversationService.createConversation(studentId).catch( + endSend, ); - if (!userConversation && studentId) { - const response = - await EmployerConversationService.createConversation(studentId).catch( - endSend, - ); + if (!response?.success) { + alert("Could not initiate conversation with user."); + endSend(); + return; + } - if (!response?.success) { - alert("Could not initiate conversation with user."); - endSend(); - return; - } + setConversationId(response.conversation?.id ?? ""); + userConversation = response.conversation; + endSend(); + } - setConversationId(response.conversation?.id ?? ""); - userConversation = response.conversation; - endSend(); - } + setTimeout(async () => { + if (!userConversation) return endSend(); + await EmployerConversationService.sendToUser( + userConversation?.id, + message, + ).catch(endSend); + endSend(); + }); + }; - setTimeout(async () => { - if (!userConversation) return endSend(); - await EmployerConversationService.sendToUser( - userConversation?.id, - message, - ).catch(endSend); - endSend(); - }); - }; + const handleSetStatus = (statusId: string | number) => { + const action = statuses?.find( + (s) => s.id?.toString() === statusId.toString(), + ); + action?.onClick?.(); + }; - const handleSetStatus = (statusId: string | number) => { - const action = statuses?.find((s) => s.id?.toString() === statusId.toString()); - action?.onClick?.(); - } + const handleConfirmApplicantArchive = async () => { + if (!application?.user_id) return; + await applications.review(application.id || "", { status: 7 }); + router.push(`/dashboard/manage?jobId=${application.job_id}`); + closeApplicantArchiveModal(); + }; - const handleConfirmApplicantArchive = async () => { - if (!application?.user_id) return; - await applications.review((application.id || ""), { status: 7 }); - router.push(`/dashboard/manage?jobId=${application.job_id}`); - closeApplicantArchiveModal(); - }; - - const handleCancelApplicantArchive = () => { - closeApplicantArchiveModal(); - }; - - if (loading || resumeLoading) { - return Getting applicant information...; - } + const handleCancelApplicantArchive = () => { + closeApplicantArchiveModal(); + }; - let lastSelf = false; + if (loading || resumeLoading) { + return Getting applicant information...; + } - return( - <> - - -
-
- {/* "header" ish portion */} -
-
-
-
- -
-
-
-

{getFullName(application?.user)}

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

This applicant is looking for internships for credit

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

This applicant is looking for internships voluntarily

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

{application?.user?.phone_number}

-
- {!isMobile &&

|

} -
- -

{application?.user?.edu_verification_email}

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

- -

- )} -
- -

Applicant Portfolio

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

- -

- )} -
- -

Applicant Github

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

- -

- )} -
- -

Applicant Linkedin

-
-
-
-
-
-
+ let lastSelf = false; + + return ( + <> + + +
+
+ {/* "header" ish portion */} +
+
+
+
+ +
+
+
+

+ {getFullName(application?.user)} +

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

+ This applicant is looking for internships for + credit +

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

+ This applicant is looking for internships + voluntarily +

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

+ {application?.user?.phone_number} +

+
+ {!isMobile && ( +

|

+ )} +
+ +

+ {application?.user?.edu_verification_email} +

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

+ +

+ )} +
+ +

+ Applicant Portfolio +

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

+ +

+ )} +
+ +

+ Applicant Github +

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

+ +

+ )} +
+ +

+ Applicant Linkedin +

+
+
+
+
+
+
+
+
- {/* actions */} -
-
- {/* */} - -
-
- { - e.stopPropagation() - openApplicantArchiveModal() - }} - enabled={application!.status !== 7} - /> -
-
- - {isMobile ? ( - <> - {application!.job?.internship_preferences?.require_cover_letter || - application!.cover_letter && - - - {application!.cover_letter?.length === 0 - ? "The user has not provided a cover letter." - : application!.cover_letter - } - - - } - -
-
-

Program / Degree

-

{user?.degree}

-
-
-

Institution

-

- {to_university_name(user?.university)} -

-
-
-

- Expected Graduation Date -

-

- {formatMonth(user?.expected_graduation_date)} -

-
-
-
- - -
-
-
-

Expected Start Date

-

{formatTimestampDate(internshipPreferences?.expected_start_date)}

-
-
-

Expected Duration (Hours)

-

- {internshipPreferences?.expected_duration_hours} -

-
-
-
-
- - - ) : ( - <> - {application!.job?.internship_preferences?.require_cover_letter || - application!.cover_letter && - - - {application!.cover_letter?.length === 0 - ? "The user has not provided a cover letter." - : application!.cover_letter - } - - - } -
- {application?.user?.bio ? ( -
-

{application?.user?.bio}

- -
- ) : ( - -
-

Applicant has not added a bio.

- -
- - )} -
-

- Applicant Information -

- {jobID && (

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

)} -
- -
-
-

Education

-

{to_university_name(user?.university)}

-

{user?.degree}

-
-
-

- Expected Graduation Date -

-

- {formatMonth(user?.expected_graduation_date)} -

-
-
- -
-

- Internship Requirements -

-
-
-
-

Expected Start Date

-

{formatTimestampDate(internshipPreferences?.expected_start_date)}

-
-
-

Expected Duration (Hours)

-

- {internshipPreferences?.expected_duration_hours} -

-
-
-
- - {/* other roles *note: will make this look better */} -
-
- { jobID ? - ( -

- Other Applied Roles -

- ) : ( -

- Applied Roles -

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

- {a.job?.title} -

-
- )) : ( - <> - {jobID ? ( -

No applied roles

- ) : ( -

No other applied roles

- )} - - ) - } -
-
- - )} -
+ +
+
+ { + e.stopPropagation(); + openApplicantArchiveModal(); + }} + enabled={application!.status !== 7} + /> +
+
- {/* resume */} - {application?.user?.resume ? ( -
- -
- ) : ( -
-
- -

- No Resume Available -

-
- This applicant has not uploaded a resume yet. -
-
-
+ {isMobile ? ( + <> + {application!.job?.internship_preferences + ?.require_cover_letter || + (application!.cover_letter && ( + + - - - -
-
-
-
- {getFullName(application?.user)} -
-
-

Applied for:

- {userApplications?.map((a) => -

- {a.job?.title} - {a !== userApplications?.filter(a => a.user_id === application?.user_id).at(-1) && - <>, - } + > + {application!.cover_letter?.length === 0 + ? "The user has not provided a cover letter." + : application!.cover_letter} + + + ))} + {hasChallengeSubmission && ( + + + {challengeSubmission} + + + )} + +

+
+

+ Program / Degree +

+

+ {user?.degree} +

+
+
+

Institution

+

+ {to_university_name(user?.university)} +

+
+
+

+ Expected Graduation Date +

+

+ {formatMonth(user?.expected_graduation_date)} +

+
+
+ + + +
+
+
+

+ Expected Start Date

- ) - } +

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

+
+
+

+ Expected Duration (Hours) +

+

+ {internshipPreferences?.expected_duration_hours} +

+
- -
-
-
-
- {(conversation?.loading ?? true) ? ( -
- Loading conversation... + + {application!.cover_letter?.length === 0 + ? "The user has not provided a cover letter." + : application!.cover_letter} + + + ))} + {hasChallengeSubmission && ( + + + {challengeSubmission} + + + )} +
+ {application?.user?.bio ? ( +
+

{application?.user?.bio}

+
- ) : 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: getFullName(application?.user), - }; - }) - ?.toReversed() - ?.map((d: any) => ( - - )) - ) : ( -
- - - -
- No Messages Yet -
-

Start a conversation to see your messages.

-
-
+ ) : ( +
+

Applicant has not added a bio.

+
+ )} +
+

+ Applicant Information +

+ {jobID && ( +

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

)} +
+ +
+
+

Education

+

+ {to_university_name(user?.university)} +

+

{user?.degree}

+
+
+

+ Expected Graduation Date +

+

+ {formatMonth(user?.expected_graduation_date)} +

+
+
+ +
+

+ Internship Requirements +

+
+
+
+

+ Expected Start Date +

+

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

+
+
+

+ Expected Duration (Hours) +

+

+ {internshipPreferences?.expected_duration_hours} +

+
+
+ + {/* other roles *note: will make this look better */} +
+
+ {jobID ? ( +

+ Other Applied Roles +

+ ) : ( +

+ Applied Roles +

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

+ {a.job?.title} +

+
+ )) + ) : ( + <> + {jobID ? ( +

+ {" "} + No applied roles +

+ ) : ( +

+ {" "} + No other applied roles +

+ )} + + )} +
-
-